@opengeni/codex 0.2.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/dist/chunk-2ESTGQ57.js +54 -0
- package/dist/chunk-2ESTGQ57.js.map +1 -0
- package/dist/constants.d.ts +24 -0
- package/dist/constants.js +49 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.d.ts +267 -0
- package/dist/index.js +879 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/api-client.ts +54 -0
- package/src/billing.ts +15 -0
- package/src/constants.ts +57 -0
- package/src/device-code.ts +111 -0
- package/src/fetch.ts +337 -0
- package/src/index.ts +10 -0
- package/src/mcp-sanitize.ts +290 -0
- package/src/normalize.ts +132 -0
- package/src/refresh.ts +124 -0
- package/src/request-context.ts +50 -0
- package/src/usage-normalize.ts +263 -0
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Pure request-body + model-slug transforms for the ChatGPT/Codex backend.
|
|
2
|
+
//
|
|
3
|
+
// Per the verified NORMALIZATION VERDICT (CODEX-IMPL-PACKET §0), against our
|
|
4
|
+
// @openai/agents stack we do EXACTLY this and no more:
|
|
5
|
+
// - force store:false
|
|
6
|
+
// - union include with reasoning.encrypted_content
|
|
7
|
+
// - strip max_output_tokens / max_completion_tokens
|
|
8
|
+
// - reasoning effort minimal -> low
|
|
9
|
+
// - normalize the model slug (longest-prefix against the live catalog)
|
|
10
|
+
// - strip every item `id` but PRESERVE `call_id`
|
|
11
|
+
// We do NOT filter item_reference (the SDK never emits it) and do NOT convert
|
|
12
|
+
// orphaned tool outputs (the SDK's runner already prunes by call_id).
|
|
13
|
+
|
|
14
|
+
const MINIMAL = "minimal";
|
|
15
|
+
|
|
16
|
+
// The ChatGPT/Codex backend is a STRICT ALLOWLIST: it 400s on ANY top-level field
|
|
17
|
+
// the Codex CLI itself does not send (confirmed live against the backend —
|
|
18
|
+
// "Unsupported parameter: temperature / top_p / metadata / previous_response_id /
|
|
19
|
+
// logprobs / service_tier / user / safety_identifier / truncation / max_tool_calls /
|
|
20
|
+
// background / conversation", and "Unsupported tool type: mcp"). Our @openai/agents
|
|
21
|
+
// stack adds several of these, so after our transforms we keep ONLY the codex
|
|
22
|
+
// Responses payload fields (CODEX-SUBSCRIPTION-SPEC §1 field table).
|
|
23
|
+
const CODEX_ALLOWED_TOP_LEVEL_KEYS = new Set<string>([
|
|
24
|
+
"model",
|
|
25
|
+
"instructions",
|
|
26
|
+
"input",
|
|
27
|
+
"tools",
|
|
28
|
+
"tool_choice",
|
|
29
|
+
"parallel_tool_calls",
|
|
30
|
+
"reasoning",
|
|
31
|
+
"store",
|
|
32
|
+
"stream",
|
|
33
|
+
"include",
|
|
34
|
+
"prompt_cache_key",
|
|
35
|
+
"text",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** Mutates a parsed Responses request body in place and returns it. Pure + synchronous + unit-testable. */
|
|
39
|
+
export function normalizeCodexRequestBody(
|
|
40
|
+
body: Record<string, unknown>,
|
|
41
|
+
resolveModel: (slug: string) => string,
|
|
42
|
+
): Record<string, unknown> {
|
|
43
|
+
body.store = false; // ChatGPT backend REQUIRES store=false (spec §1.3)
|
|
44
|
+
body.stream = true; // ChatGPT backend REQUIRES stream=true (confirmed live: 400 "Stream must be set to true").
|
|
45
|
+
|
|
46
|
+
// include MUST contain reasoning.encrypted_content (stateless continuity, spec §1.6)
|
|
47
|
+
const include = Array.isArray(body.include) ? (body.include as unknown[]).filter((v): v is string => typeof v === "string") : [];
|
|
48
|
+
if (!include.includes("reasoning.encrypted_content")) {
|
|
49
|
+
include.push("reasoning.encrypted_content");
|
|
50
|
+
}
|
|
51
|
+
body.include = include;
|
|
52
|
+
|
|
53
|
+
// reasoning effort: minimal -> low (backend rejects minimal). spec §1.5
|
|
54
|
+
const reasoning = body.reasoning as { effort?: string } | null | undefined;
|
|
55
|
+
if (reasoning && reasoning.effort === MINIMAL) {
|
|
56
|
+
reasoning.effort = "low";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// model slug: longest-prefix against the live catalog. spec §1.4
|
|
60
|
+
if (typeof body.model === "string") {
|
|
61
|
+
body.model = resolveModel(body.model);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// strip every item id; PRESERVE call_id. spec §1.6 / verdict §0(b)
|
|
65
|
+
// (This also covers tool_search items: the backend accepts an id-less
|
|
66
|
+
// tool_search_call/output pair correlated by call_id — verified live — and
|
|
67
|
+
// stripping the account-bound `tsc_…` id here sanitizes BOTH replay paths.)
|
|
68
|
+
if (Array.isArray(body.input)) {
|
|
69
|
+
for (const item of body.input as unknown[]) {
|
|
70
|
+
if (!item || typeof item !== "object") {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const record = item as Record<string, unknown>;
|
|
74
|
+
if ("id" in record) {
|
|
75
|
+
delete record.id;
|
|
76
|
+
}
|
|
77
|
+
// A replayed tool_search_call must carry `arguments` as an OBJECT — the
|
|
78
|
+
// backend 400s a string ("Invalid type for 'input[N].arguments': expected
|
|
79
|
+
// an object", verified live). The live wire emits an object (the SDK's
|
|
80
|
+
// protocol schema is z.unknown() and round-trips it), so this only fires
|
|
81
|
+
// for a defensively-stringified row; unparseable strings fall back to {}.
|
|
82
|
+
if (record.type === "tool_search_call" && typeof record.arguments === "string") {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(record.arguments) as unknown;
|
|
85
|
+
record.arguments = parsed && typeof parsed === "object" ? parsed : {};
|
|
86
|
+
} catch {
|
|
87
|
+
record.arguments = {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Drop hosted-MCP tool entries: the backend rejects them ("Unsupported tool
|
|
94
|
+
// type: mcp"). OpenGeni's MCP servers are client-connected, so their tools
|
|
95
|
+
// already arrive as `function` tools — this only sheds a stray `mcp` entry.
|
|
96
|
+
if (Array.isArray(body.tools)) {
|
|
97
|
+
body.tools = (body.tools as unknown[]).filter(
|
|
98
|
+
(t) => !(t && typeof t === "object" && (t as Record<string, unknown>).type === "mcp"),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Final allowlist: shed every other top-level field our @openai/agents stack
|
|
103
|
+
// may have added (temperature, top_p, metadata, previous_response_id,
|
|
104
|
+
// max_output_tokens, truncation, …) so the strict backend does not 400.
|
|
105
|
+
for (const key of Object.keys(body)) {
|
|
106
|
+
if (!CODEX_ALLOWED_TOP_LEVEL_KEYS.has(key)) {
|
|
107
|
+
delete body[key];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return body;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build a longest-prefix model resolver. Catalog slugs come from GET /models
|
|
115
|
+
* (api-client.ts). One leading `namespace/` segment is stripped first; an
|
|
116
|
+
* unknown slug returns the fallback (caller should log — spec §1.4 step 4).
|
|
117
|
+
*/
|
|
118
|
+
export function buildModelResolver(
|
|
119
|
+
liveSlugs: readonly string[],
|
|
120
|
+
fallbackSlug: string,
|
|
121
|
+
): (slug: string) => string {
|
|
122
|
+
return (requested: string): string => {
|
|
123
|
+
const stripped = requested.includes("/") ? requested.slice(requested.indexOf("/") + 1) : requested;
|
|
124
|
+
let best = "";
|
|
125
|
+
for (const slug of liveSlugs) {
|
|
126
|
+
if (stripped.startsWith(slug) && slug.length > best.length) {
|
|
127
|
+
best = slug;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return best || fallbackSlug;
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/refresh.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Token refresh + JWT helpers + permanent-failure classification.
|
|
2
|
+
// Refresh is JSON-bodied (exchange is form-encoded — spec §1.1 contrasts these).
|
|
3
|
+
// Classification mirrors codex-rs manager.rs:180-184.
|
|
4
|
+
|
|
5
|
+
import { CODEX_CLIENT_ID, CODEX_ID_TOKEN_AUTH_CLAIM, CODEX_TOKEN_URL } from "./constants";
|
|
6
|
+
import type { CodexFetch } from "./device-code";
|
|
7
|
+
|
|
8
|
+
/** Permanent — the workspace must reconnect (status => needs_relogin). */
|
|
9
|
+
export class CodexReloginRequired extends Error {
|
|
10
|
+
constructor(message: string) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "CodexReloginRequired";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Transient — safe to retry later. */
|
|
17
|
+
export class CodexRefreshTransient extends Error {
|
|
18
|
+
constructor(message: string) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "CodexRefreshTransient";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Only present fields are returned (the server may rotate any subset). */
|
|
25
|
+
export type CodexRefreshTokens = {
|
|
26
|
+
idToken?: string | undefined;
|
|
27
|
+
accessToken?: string | undefined;
|
|
28
|
+
refreshToken?: string | undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** POST {issuer}/oauth/token JSON {client_id, grant_type:"refresh_token", refresh_token}. manager.rs:1336-1340 */
|
|
32
|
+
export async function refreshCodexToken(
|
|
33
|
+
refreshToken: string,
|
|
34
|
+
fetchImpl: CodexFetch = fetch,
|
|
35
|
+
): Promise<CodexRefreshTokens> {
|
|
36
|
+
const res = await fetchImpl(CODEX_TOKEN_URL, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify({ client_id: CODEX_CLIENT_ID, grant_type: "refresh_token", refresh_token: refreshToken }),
|
|
40
|
+
});
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const code = extractRefreshErrorCode(text);
|
|
44
|
+
const msg = code ? PERMANENT_REFRESH_FAILURES[code] : undefined;
|
|
45
|
+
if (msg) {
|
|
46
|
+
throw new CodexReloginRequired(msg);
|
|
47
|
+
}
|
|
48
|
+
if (res.status === 401) {
|
|
49
|
+
throw new CodexReloginRequired("Your Codex session could not be refreshed. Please disconnect and sign in again.");
|
|
50
|
+
}
|
|
51
|
+
throw new CodexRefreshTransient(`Failed to refresh Codex token: ${res.status}`);
|
|
52
|
+
}
|
|
53
|
+
const body = JSON.parse(text) as { id_token?: string; access_token?: string; refresh_token?: string };
|
|
54
|
+
return { idToken: body.id_token, accessToken: body.access_token, refreshToken: body.refresh_token };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Codes that mean the refresh token is permanently dead -> reconnect required.
|
|
58
|
+
// Includes the standard OAuth `invalid_grant` alongside the Codex-specific codes.
|
|
59
|
+
const PERMANENT_REFRESH_FAILURES: Record<string, string> = {
|
|
60
|
+
refresh_token_expired: "Your Codex refresh token has expired. Please disconnect and sign in again.",
|
|
61
|
+
refresh_token_reused: "Your Codex refresh token was already used. Please disconnect and sign in again.",
|
|
62
|
+
refresh_token_invalidated: "Your Codex refresh token was revoked. Please disconnect and sign in again.",
|
|
63
|
+
invalid_grant: "Your Codex session is no longer valid. Please disconnect and sign in again.",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Pull an error code from any of the shapes the auth server may return. */
|
|
67
|
+
function extractRefreshErrorCode(text: string): string | undefined {
|
|
68
|
+
try {
|
|
69
|
+
const o = JSON.parse(text) as Record<string, unknown>;
|
|
70
|
+
const err = o.error;
|
|
71
|
+
if (typeof err === "string") {
|
|
72
|
+
return err; // { "error": "invalid_grant" }
|
|
73
|
+
}
|
|
74
|
+
if (err && typeof err === "object") {
|
|
75
|
+
const e = err as Record<string, unknown>;
|
|
76
|
+
if (typeof e.code === "string") return e.code; // { "error": { "code": "..." } }
|
|
77
|
+
if (typeof e.type === "string") return e.type;
|
|
78
|
+
}
|
|
79
|
+
if (typeof o.code === "string") return o.code; // { "code": "..." }
|
|
80
|
+
if (typeof o.type === "string") return o.type;
|
|
81
|
+
} catch {
|
|
82
|
+
/* not JSON */
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Decode a JWT payload (base64url, no signature check). */
|
|
88
|
+
export function decodeJwtPayload(jwt: string): Record<string, unknown> | null {
|
|
89
|
+
const part = jwt.split(".")[1];
|
|
90
|
+
if (!part) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
95
|
+
return JSON.parse(json) as Record<string, unknown>;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** access-token `exp` claim -> Date | null. token_data.rs:101-105 */
|
|
102
|
+
export function accessTokenExpiry(accessToken: string): Date | null {
|
|
103
|
+
const payload = decodeJwtPayload(accessToken);
|
|
104
|
+
return typeof payload?.exp === "number" ? new Date(payload.exp * 1000) : null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** id_token -> {chatgptAccountId, planType, isFedramp}. server.rs:827-832; token_data.rs:71-99 */
|
|
108
|
+
export function parseIdToken(idToken: string): {
|
|
109
|
+
chatgptAccountId: string | null;
|
|
110
|
+
planType: string | null;
|
|
111
|
+
isFedramp: boolean;
|
|
112
|
+
email: string | null;
|
|
113
|
+
} {
|
|
114
|
+
const payload = decodeJwtPayload(idToken);
|
|
115
|
+
const auth = (payload?.[CODEX_ID_TOKEN_AUTH_CLAIM] ?? {}) as Record<string, unknown>;
|
|
116
|
+
return {
|
|
117
|
+
chatgptAccountId: typeof auth.chatgpt_account_id === "string" ? auth.chatgpt_account_id : null,
|
|
118
|
+
planType: typeof auth.chatgpt_plan_type === "string" ? auth.chatgpt_plan_type : null,
|
|
119
|
+
isFedramp: auth.chatgpt_account_is_fedramp === true,
|
|
120
|
+
// The user's own email (standard OIDC `email` claim on the id_token); a
|
|
121
|
+
// non-secret display field for the accounts UI. Null when absent.
|
|
122
|
+
email: typeof payload?.email === "string" ? payload.email : null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Per-request Codex context, carried via AsyncLocalStorage.
|
|
2
|
+
//
|
|
3
|
+
// The runtime caches one OpenAI client per provider id (process-wide), so the
|
|
4
|
+
// per-workspace token must NOT be baked into the client. Instead the worker sets
|
|
5
|
+
// this context around the model run, and codexSubscriptionFetch reads it at call
|
|
6
|
+
// time — one cached client, correct per-workspace token, no cross-tenant leak.
|
|
7
|
+
|
|
8
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
9
|
+
|
|
10
|
+
export type CodexTokenSnapshot = {
|
|
11
|
+
accessToken: string;
|
|
12
|
+
chatgptAccountId: string | null;
|
|
13
|
+
isFedramp: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Multi-account P4 (Part A): a full usage snapshot scraped FOR FREE from the
|
|
18
|
+
* `x-codex-primary-*` / `x-codex-secondary-*` response headers the codex backend
|
|
19
|
+
* stamps on every `/codex/responses` turn (success AND 429 hard-cap). Integer-
|
|
20
|
+
* identical to GET /wham/usage but with zero extra round-trip. parseCodexUsageHeaders
|
|
21
|
+
* returns this only when BOTH windows parse, so a write is always a full 5-column
|
|
22
|
+
* snapshot (no partial-window clobber). Shape mirrors db's CodexAccountUsageSnapshot
|
|
23
|
+
* (non-null here: a partial read is filtered to null upstream, never half-written).
|
|
24
|
+
*/
|
|
25
|
+
export type CodexUsageHeaderSnapshot = {
|
|
26
|
+
primaryUsedPercent: number;
|
|
27
|
+
primaryResetAt: Date;
|
|
28
|
+
secondaryUsedPercent: number;
|
|
29
|
+
secondaryResetAt: Date;
|
|
30
|
+
checkedAt: Date;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type CodexRequestContext = {
|
|
34
|
+
clientVersion: string;
|
|
35
|
+
/** Worker-supplied: proactive refresh + single-flight + db persist. */
|
|
36
|
+
getToken: () => Promise<CodexTokenSnapshot>;
|
|
37
|
+
/** Forced refresh used for the 401 retry. */
|
|
38
|
+
refresh: () => Promise<CodexTokenSnapshot>;
|
|
39
|
+
/** Model-slug resolver (longest-prefix against the live catalog). */
|
|
40
|
+
resolveModel: (slug: string) => string;
|
|
41
|
+
/**
|
|
42
|
+
* Multi-account P4 (Part A): fire-and-forget usage-header sink. Called by
|
|
43
|
+
* codexSubscriptionFetch on EVERY response (sync, non-throwing, never awaited)
|
|
44
|
+
* with the parsed full-window snapshot. The worker records the latest into the
|
|
45
|
+
* P2 usage cache once per turn in its `finally` — packages/codex stays db-free.
|
|
46
|
+
*/
|
|
47
|
+
onUsageHeaders?: (snapshot: CodexUsageHeaderSnapshot) => void;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const codexRequestStorage = new AsyncLocalStorage<CodexRequestContext>();
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Normalizer for GET /wham/usage (P2). The live body exposes `used_percent` +
|
|
2
|
+
// reset timing per window and NO raw used/limit/remaining integer counts (the only
|
|
3
|
+
// raw counts live under `credits.approx_*_messages`). So the brief's
|
|
4
|
+
// used/limit/remaining/percent/resetAt shape is SYNTHESIZED off `used_percent`,
|
|
5
|
+
// with `percent` authoritative and used/limit/remaining carried on a normalized
|
|
6
|
+
// 0–100 scale (limit = 100). `remaining = 100 - percent` is the P3 rotation key
|
|
7
|
+
// (rotationStrategy:"most_remaining" ranks by max(min(fiveHour, weekly).remaining)).
|
|
8
|
+
//
|
|
9
|
+
// Windows are identified by `limit_window_seconds` (18000 ⇒ 5h, 604800 ⇒ weekly),
|
|
10
|
+
// NEVER by position. A 200 may carry `limit_reached:true`; a 404 carries a
|
|
11
|
+
// limit-reached body. The parser is zod over rate_limit.{primary,secondary}_window.
|
|
12
|
+
|
|
13
|
+
import * as z from "zod/v4";
|
|
14
|
+
|
|
15
|
+
/** The 5-hour (primary) window's `limit_window_seconds`. */
|
|
16
|
+
export const CODEX_FIVE_HOUR_WINDOW_SECONDS = 18000;
|
|
17
|
+
/** The weekly (secondary) window's `limit_window_seconds`. */
|
|
18
|
+
export const CODEX_WEEKLY_WINDOW_SECONDS = 604800;
|
|
19
|
+
|
|
20
|
+
/** One normalized usage window (applied to BOTH primary_window and secondary_window). */
|
|
21
|
+
export type CodexUsageWindow = {
|
|
22
|
+
used: number; // = percent (0–100 scale, limit = 100)
|
|
23
|
+
limit: number; // = 100 (normalized; the provider gives no raw cap)
|
|
24
|
+
remaining: number; // = 100 - percent ← P3 rotation key
|
|
25
|
+
percent: number; // = used_percent (authoritative)
|
|
26
|
+
resetAt: string | null; // ISO 8601, from reset_at*1000 (absolute), or derived from reset_after_seconds
|
|
27
|
+
resetAfterSeconds: number | null; // from reset_after_seconds (skew-free countdown)
|
|
28
|
+
limitWindowSeconds: number; // 18000 | 604800 — identify the window, never positional
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** One additional (per-feature) limit (forward-compat; P2 renders nothing from it). */
|
|
32
|
+
export type CodexAdditionalLimit = {
|
|
33
|
+
limitName: string;
|
|
34
|
+
meteredFeature: string;
|
|
35
|
+
fiveHour: CodexUsageWindow | null;
|
|
36
|
+
weekly: CodexUsageWindow | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type CodexUsageStatus = "ok" | "limit_reached" | "error" | "no-data";
|
|
40
|
+
|
|
41
|
+
/** The normalized usage payload — the P2/P3 contract. */
|
|
42
|
+
export type CodexUsagePayload = {
|
|
43
|
+
status: CodexUsageStatus;
|
|
44
|
+
planType: string | null; // "pro" | "plus" | ... (rate row label)
|
|
45
|
+
fiveHour: CodexUsageWindow | null; // ← rate_limit.primary_window (limitWindowSeconds === 18000)
|
|
46
|
+
weekly: CodexUsageWindow | null; // ← rate_limit.secondary_window (604800)
|
|
47
|
+
limitReached: boolean; // rate_limit.limit_reached || !rate_limit.allowed
|
|
48
|
+
fetchedAt: string; // ISO; server stamp
|
|
49
|
+
/** Present only on a refresh/auth failure path; carries the precise reason. */
|
|
50
|
+
reason?: "needs_relogin" | undefined;
|
|
51
|
+
// forward-compat, populated but unused in P2:
|
|
52
|
+
additionalLimits?: CodexAdditionalLimit[] | undefined;
|
|
53
|
+
credits?: { hasCredits: boolean; unlimited: boolean; overageLimitReached: boolean; balance: string } | undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build a normalized window from the PERSISTED cache columns (used_percent +
|
|
58
|
+
* absolute reset timestamp). The same 0–100 synthesis as the live path, with the
|
|
59
|
+
* skew-free countdown derived from `resetAt − now` at read time. Returns null when
|
|
60
|
+
* there is no cached percent yet. `limitWindowSeconds` is the constant that
|
|
61
|
+
* identifies the window (18000 ⇒ 5h, 604800 ⇒ weekly).
|
|
62
|
+
*/
|
|
63
|
+
export function buildCodexUsageWindowFromCache(
|
|
64
|
+
usedPercent: number | null | undefined,
|
|
65
|
+
resetAt: Date | string | null | undefined,
|
|
66
|
+
limitWindowSeconds: number,
|
|
67
|
+
): CodexUsageWindow | null {
|
|
68
|
+
if (typeof usedPercent !== "number") {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const percent = clampPercent(usedPercent);
|
|
72
|
+
const resetDate = resetAt ? new Date(resetAt) : null;
|
|
73
|
+
const resetIso = resetDate && !Number.isNaN(resetDate.getTime()) ? resetDate.toISOString() : null;
|
|
74
|
+
const resetAfterSeconds = resetDate && !Number.isNaN(resetDate.getTime())
|
|
75
|
+
? Math.max(0, Math.round((resetDate.getTime() - Date.now()) / 1000))
|
|
76
|
+
: null;
|
|
77
|
+
return {
|
|
78
|
+
used: percent,
|
|
79
|
+
limit: 100,
|
|
80
|
+
remaining: 100 - percent,
|
|
81
|
+
percent,
|
|
82
|
+
resetAt: resetIso,
|
|
83
|
+
resetAfterSeconds,
|
|
84
|
+
limitWindowSeconds,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const windowSchema = z
|
|
89
|
+
.object({
|
|
90
|
+
used_percent: z.number().optional(),
|
|
91
|
+
reset_after_seconds: z.number().optional(),
|
|
92
|
+
reset_at: z.number().optional(),
|
|
93
|
+
limit_window_seconds: z.number().optional(),
|
|
94
|
+
})
|
|
95
|
+
.nullish();
|
|
96
|
+
|
|
97
|
+
const rateLimitSchema = z
|
|
98
|
+
.object({
|
|
99
|
+
allowed: z.boolean().optional(),
|
|
100
|
+
limit_reached: z.boolean().optional(),
|
|
101
|
+
primary_window: windowSchema,
|
|
102
|
+
secondary_window: windowSchema,
|
|
103
|
+
})
|
|
104
|
+
.nullish();
|
|
105
|
+
|
|
106
|
+
const additionalLimitSchema = z.object({
|
|
107
|
+
limit_name: z.string().optional(),
|
|
108
|
+
metered_feature: z.string().optional(),
|
|
109
|
+
primary_window: windowSchema,
|
|
110
|
+
secondary_window: windowSchema,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const creditsSchema = z
|
|
114
|
+
.object({
|
|
115
|
+
has_credits: z.boolean().optional(),
|
|
116
|
+
unlimited: z.boolean().optional(),
|
|
117
|
+
overage_limit_reached: z.boolean().optional(),
|
|
118
|
+
balance: z.union([z.string(), z.number()]).optional(),
|
|
119
|
+
})
|
|
120
|
+
.nullish();
|
|
121
|
+
|
|
122
|
+
const usageBodySchema = z.object({
|
|
123
|
+
plan_type: z.string().nullish(),
|
|
124
|
+
rate_limit: rateLimitSchema,
|
|
125
|
+
additional_limits: z.array(additionalLimitSchema).nullish(),
|
|
126
|
+
credits: creditsSchema,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
type RawWindow = z.infer<typeof windowSchema>;
|
|
130
|
+
|
|
131
|
+
function clampPercent(value: number): number {
|
|
132
|
+
if (!Number.isFinite(value)) return 0;
|
|
133
|
+
return Math.min(100, Math.max(0, Math.round(value)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Build a normalized window from a raw provider window, or null when it carries no percent. */
|
|
137
|
+
function normalizeWindow(w: RawWindow): CodexUsageWindow | null {
|
|
138
|
+
if (!w || typeof w.used_percent !== "number") {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const percent = clampPercent(w.used_percent);
|
|
142
|
+
const resetAfterSeconds = typeof w.reset_after_seconds === "number" ? Math.max(0, Math.round(w.reset_after_seconds)) : null;
|
|
143
|
+
let resetAt: string | null = null;
|
|
144
|
+
if (typeof w.reset_at === "number") {
|
|
145
|
+
resetAt = new Date(w.reset_at * 1000).toISOString(); // epoch SECONDS → ms
|
|
146
|
+
} else if (resetAfterSeconds != null) {
|
|
147
|
+
resetAt = new Date(Date.now() + resetAfterSeconds * 1000).toISOString();
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
used: percent,
|
|
151
|
+
limit: 100,
|
|
152
|
+
remaining: 100 - percent,
|
|
153
|
+
percent,
|
|
154
|
+
resetAt,
|
|
155
|
+
resetAfterSeconds,
|
|
156
|
+
limitWindowSeconds: typeof w.limit_window_seconds === "number" ? w.limit_window_seconds : 0,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Map the two named windows to fiveHour/weekly by `limit_window_seconds`
|
|
162
|
+
* (18000 vs 604800), NEVER by position; fall back to position (primary ⇒ 5h,
|
|
163
|
+
* secondary ⇒ weekly) only for a window whose limit_window_seconds is absent.
|
|
164
|
+
*/
|
|
165
|
+
function pickWindows(primary: RawWindow, secondary: RawWindow): { fiveHour: CodexUsageWindow | null; weekly: CodexUsageWindow | null } {
|
|
166
|
+
let fiveHour: CodexUsageWindow | null = null;
|
|
167
|
+
let weekly: CodexUsageWindow | null = null;
|
|
168
|
+
// Track each unplaced window with the slot it came from, so the positional
|
|
169
|
+
// fallback can place it (re-normalizing produces a fresh object that would
|
|
170
|
+
// never match by reference — the bug this replaces).
|
|
171
|
+
const unplaced: Array<{ slot: "primary" | "secondary"; window: CodexUsageWindow }> = [];
|
|
172
|
+
for (const [slot, raw] of [["primary", primary], ["secondary", secondary]] as const) {
|
|
173
|
+
const nw = normalizeWindow(raw);
|
|
174
|
+
if (!nw) continue;
|
|
175
|
+
if (nw.limitWindowSeconds === CODEX_WEEKLY_WINDOW_SECONDS) {
|
|
176
|
+
weekly = nw;
|
|
177
|
+
} else if (nw.limitWindowSeconds === CODEX_FIVE_HOUR_WINDOW_SECONDS) {
|
|
178
|
+
fiveHour = nw;
|
|
179
|
+
} else {
|
|
180
|
+
unplaced.push({ slot, window: nw });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Positional fallback for windows whose limit_window_seconds was absent/unknown
|
|
184
|
+
// (primary ⇒ 5h, secondary ⇒ weekly).
|
|
185
|
+
for (const { slot, window } of unplaced) {
|
|
186
|
+
if (slot === "primary" && !fiveHour) fiveHour = window;
|
|
187
|
+
else if (slot === "secondary" && !weekly) weekly = window;
|
|
188
|
+
}
|
|
189
|
+
return { fiveHour, weekly };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Normalize a /wham/usage fetch result into the P2/P3 contract.
|
|
194
|
+
*
|
|
195
|
+
* @param httpStatus the HTTP status from fetchCodexUsage (404 ⇒ a limit body)
|
|
196
|
+
* @param rawPayload the parsed JSON body (or null when the body was unreadable)
|
|
197
|
+
*/
|
|
198
|
+
export function normalizeCodexUsage(httpStatus: number, rawPayload: unknown): CodexUsagePayload {
|
|
199
|
+
const fetchedAt = new Date().toISOString();
|
|
200
|
+
const parsed = usageBodySchema.safeParse(rawPayload);
|
|
201
|
+
const body = parsed.success ? parsed.data : null;
|
|
202
|
+
|
|
203
|
+
const base: CodexUsagePayload = {
|
|
204
|
+
status: "no-data",
|
|
205
|
+
planType: body?.plan_type ?? null,
|
|
206
|
+
fiveHour: null,
|
|
207
|
+
weekly: null,
|
|
208
|
+
limitReached: false,
|
|
209
|
+
fetchedAt,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// A non-404 HTTP error, or a body we could not parse at all, is an error state.
|
|
213
|
+
if ((httpStatus >= 400 && httpStatus !== 404) || body == null) {
|
|
214
|
+
return { ...base, status: "error" };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const rate = body.rate_limit ?? null;
|
|
218
|
+
const { fiveHour, weekly } = pickWindows(rate?.primary_window ?? null, rate?.secondary_window ?? null);
|
|
219
|
+
const limitReached =
|
|
220
|
+
!!(rate?.limit_reached || rate?.allowed === false) || (fiveHour?.percent ?? 0) >= 100 || (weekly?.percent ?? 0) >= 100;
|
|
221
|
+
|
|
222
|
+
const additionalLimits: CodexAdditionalLimit[] | undefined = body.additional_limits
|
|
223
|
+
? body.additional_limits.map((al) => {
|
|
224
|
+
const windows = pickWindows(al.primary_window ?? null, al.secondary_window ?? null);
|
|
225
|
+
return {
|
|
226
|
+
limitName: al.limit_name ?? "",
|
|
227
|
+
meteredFeature: al.metered_feature ?? "",
|
|
228
|
+
fiveHour: windows.fiveHour,
|
|
229
|
+
weekly: windows.weekly,
|
|
230
|
+
};
|
|
231
|
+
})
|
|
232
|
+
: undefined;
|
|
233
|
+
|
|
234
|
+
const credits = body.credits
|
|
235
|
+
? {
|
|
236
|
+
hasCredits: body.credits.has_credits ?? false,
|
|
237
|
+
unlimited: body.credits.unlimited ?? false,
|
|
238
|
+
overageLimitReached: body.credits.overage_limit_reached ?? false,
|
|
239
|
+
balance: body.credits.balance != null ? String(body.credits.balance) : "0",
|
|
240
|
+
}
|
|
241
|
+
: undefined;
|
|
242
|
+
|
|
243
|
+
// Status derivation: 404 ⇒ limit_reached; a 200 may still carry limit_reached;
|
|
244
|
+
// succeeded-but-no-windows ⇒ no-data; otherwise ok.
|
|
245
|
+
let status: CodexUsageStatus;
|
|
246
|
+
if (httpStatus === 404 || limitReached) {
|
|
247
|
+
status = "limit_reached";
|
|
248
|
+
} else if (!fiveHour && !weekly) {
|
|
249
|
+
status = "no-data";
|
|
250
|
+
} else {
|
|
251
|
+
status = "ok";
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
...base,
|
|
256
|
+
status,
|
|
257
|
+
fiveHour,
|
|
258
|
+
weekly,
|
|
259
|
+
limitReached,
|
|
260
|
+
...(additionalLimits ? { additionalLimits } : {}),
|
|
261
|
+
...(credits ? { credits } : {}),
|
|
262
|
+
};
|
|
263
|
+
}
|