Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,12 @@ We read undocumented endpoints as guests:
exponential backoff on failures — your adapter doesn't need to implement
backoff, just report honest statuses.
- Use `ctx.fetch(url, { credentials: 'include', headers: { Accept:
'application/json' } })`. Do not spoof another client's identity, add
fake client headers, or construct `Authorization` headers. Cookie-riding
Tier A adapters must never read credential values at all.
'application/json' } })`. Do not spoof another client's identity or add
fake client headers. Never read cookie values. If the provider's own web
app authenticates with a session-minted bearer token (see the Codex
adapter), obtain it from that provider's own session endpoint at fetch
time, keep it in function scope, and never persist it — not in
`ctx.cache`, not in storage, not in logs.
- **Anthropic-specific:** PRs that read, store, or transmit Claude Code /
claude.ai OAuth tokens will be declined — Anthropic's terms restrict
those tokens to their own products (see README).
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,14 @@ itself.

Privacy and safety commitments, in order of importance:

- **No credentials are ever read, stored, or transmitted.** The extension
never requests the `cookies` permission, never sees cookie values, and
never constructs an `Authorization` header (there's a test asserting
this). It cannot leak what it cannot see.
- **No credentials are ever read or stored.** The extension never requests
the `cookies` permission and never sees cookie values. Where a provider's
own web app authenticates with a short-lived session token (Codex), the
adapter asks that provider's own session endpoint for the token at
refresh time — exactly what the page itself does — uses it in-memory for
the single request, and never persists it (there's a test asserting
this). For Claude, no `Authorization` header is ever constructed at all
(also tested).
- **Zero telemetry.** No analytics, no error reporting service, no remote
config, no runtime dependencies at all. What you see in this repo is the
entire behavior.
Expand Down
191 changes: 149 additions & 42 deletions src/adapters/codex.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,124 @@
/**
* Codex / ChatGPT adapter (Tier A).
*
* Rides the user's existing chatgpt.com browser session via
* `credentials: 'include'` — no tokens are read, stored, or constructed.
* Auth: the ChatGPT web app authenticates its backend calls with a
* short-lived bearer token minted from the browser session. This adapter
* does the same thing the page itself does: it asks the provider's own
* session endpoint (`/api/auth/session`, cookie-authed) for that token,
* uses it in-memory for the single usage request, and never stores it —
* nothing is written to storage or logs.
*
* The endpoint is undocumented and has been observed with more than one
* field-name convention (`primary_window`/`secondary_window` vs.
* `five_hour_limit`/`weekly_limit`). Field names are matched literally per
* variant — never inferred from display labels — because mislabeling which
* window is which produces a confidently wrong display, which is worse than
* no display. An unknown shape is an error state, never a zeroed lane.
* The endpoint is undocumented and has been observed with several shapes.
* Field names are matched literally per named variant — never inferred from
* display labels — because mislabeling which window is which produces a
* confidently wrong display, worse than no display. An unknown shape is an
* error state, never a zeroed lane. The primary variant (`wham_rate_limit`,
* windows nested under `rate_limit`) is verified against a live capture
* (2026-08); the flat shapes remain as fallbacks.
*/
import type { FetchContext, ProviderAdapter, ProviderSnapshot, QuotaLane } from '../types';
import { clampPct } from '../lib/headroom';
import { arr, matchVariant, nullable, num, obj, optional, str, type Schema } from '../lib/validate';

export const CODEX_ADAPTER_VERSION = 1;
export const CODEX_ADAPTER_VERSION = 2;
const SESSION_URL = 'https://chatgpt.com/api/auth/session';
const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';

interface UsageWindow {
used_percent: number;
limit_window_seconds?: number | null;
reset_at?: number | null;
reset_after_seconds?: number | null;
resets_at?: number | null;
resets_in_seconds?: number | null;
}

const windowSchema: Schema<UsageWindow> = obj({
used_percent: num,
limit_window_seconds: optional(nullable(num)),
reset_at: optional(nullable(num)),
reset_after_seconds: optional(nullable(num)),
resets_at: optional(nullable(num)),
resets_in_seconds: optional(nullable(num)),
});

interface AdditionalLimit extends UsageWindow {
name?: string;
interface RateLimitBlock {
primary_window?: UsageWindow | null;
secondary_window?: UsageWindow | null;
}

const additionalLimitSchema: Schema<AdditionalLimit> = obj({
name: optional(str),
used_percent: num,
resets_at: optional(nullable(num)),
resets_in_seconds: optional(nullable(num)),
const rateLimitBlockSchema: Schema<RateLimitBlock> = obj({
primary_window: optional(nullable(windowSchema)),
secondary_window: optional(nullable(windowSchema)),
});

interface AdditionalRateLimit {
limit_name?: string | null;
rate_limit?: RateLimitBlock | null;
}

/** Live shape: windows nested under rate_limit. */
interface RateLimitShape {
rate_limit: RateLimitBlock;
additional_rate_limits?: AdditionalRateLimit[] | null;
}

/** Legacy/fallback shapes: windows at the top level. */
interface WindowsShape {
primary_window: UsageWindow;
secondary_window?: UsageWindow;
additional_rate_limits?: AdditionalLimit[];
secondary_window?: UsageWindow | null;
}

interface NamedShape {
five_hour_limit: UsageWindow;
weekly_limit?: UsageWindow;
additional_rate_limits?: AdditionalLimit[];
weekly_limit?: UsageWindow | null;
}

type UsageShape = RateLimitShape | WindowsShape | NamedShape;

const additionalSchema: Schema<AdditionalRateLimit> = obj({
limit_name: optional(nullable(str)),
rate_limit: optional(nullable(rateLimitBlockSchema)),
});

const variants = {
wham_rate_limit: obj<RateLimitShape>({
rate_limit: rateLimitBlockSchema,
additional_rate_limits: optional(nullable(arr(additionalSchema))),
}) as Schema<UsageShape>,
wham_windows: obj<WindowsShape>({
primary_window: windowSchema,
secondary_window: optional(windowSchema),
additional_rate_limits: optional(arr(additionalLimitSchema)),
}) as Schema<WindowsShape | NamedShape>,
secondary_window: optional(nullable(windowSchema)),
}) as Schema<UsageShape>,
wham_named: obj<NamedShape>({
five_hour_limit: windowSchema,
weekly_limit: optional(windowSchema),
additional_rate_limits: optional(arr(additionalLimitSchema)),
}) as Schema<WindowsShape | NamedShape>,
weekly_limit: optional(nullable(windowSchema)),
}) as Schema<UsageShape>,
};

const sessionSchema = obj<{ accessToken?: string }>({
accessToken: optional(str),
});

function windowLabel(w: UsageWindow, slot: 'session' | 'weekly'): string {
const secs = w.limit_window_seconds;
if (typeof secs === 'number' && secs > 0) {
if (secs === 18_000) return 'Session (5h)';
if (secs === 604_800) return 'Weekly';
const hours = Math.round(secs / 3600);
return hours >= 48 ? `${Math.round(hours / 24)}-day window` : `${hours}h window`;
}
return slot === 'session' ? 'Session' : 'Weekly';
}

function windowToLane(id: string, label: string, w: UsageWindow, now: Date): QuotaLane {
let resetsAt: string | null = null;
if (typeof w.resets_at === 'number') {
resetsAt = new Date(w.resets_at * 1000).toISOString();
} else if (typeof w.resets_in_seconds === 'number') {
resetsAt = new Date(now.getTime() + w.resets_in_seconds * 1000).toISOString();
const epoch = w.reset_at ?? w.resets_at;
const relative = w.reset_after_seconds ?? w.resets_in_seconds;
if (typeof epoch === 'number') {
resetsAt = new Date(epoch * 1000).toISOString();
} else if (typeof relative === 'number') {
resetsAt = new Date(now.getTime() + relative * 1000).toISOString();
}
return {
id,
Expand All @@ -84,23 +131,43 @@ function windowToLane(id: string, label: string, w: UsageWindow, now: Date): Quo
};
}

function toLanes(value: WindowsShape | NamedShape, now: Date): QuotaLane[] {
function blockToLanes(block: RateLimitBlock, now: Date): QuotaLane[] {
const lanes: QuotaLane[] = [];
if (block.primary_window) {
lanes.push(
windowToLane('session', windowLabel(block.primary_window, 'session'), block.primary_window, now),
);
}
if (block.secondary_window) {
lanes.push(
windowToLane('weekly', windowLabel(block.secondary_window, 'weekly'), block.secondary_window, now),
);
}
return lanes;
}

function toLanes(value: UsageShape, now: Date): QuotaLane[] {
if ('rate_limit' in value) {
const lanes = blockToLanes(value.rate_limit, now);
(value.additional_rate_limits ?? []).forEach((extra, i) => {
const w = extra.rate_limit?.primary_window;
if (!w) return;
const name = extra.limit_name ?? `limit ${i + 1}`;
lanes.push(windowToLane(`extra:${extra.limit_name ?? i}`, name, w, now));
});
return lanes;
}
if ('primary_window' in value) {
lanes.push(windowToLane('session', 'Session', value.primary_window, now));
const lanes = [windowToLane('session', 'Session', value.primary_window, now)];
if (value.secondary_window) {
lanes.push(windowToLane('weekly', 'Weekly', value.secondary_window, now));
}
} else {
lanes.push(windowToLane('session', 'Session (5h)', value.five_hour_limit, now));
if (value.weekly_limit) {
lanes.push(windowToLane('weekly', 'Weekly', value.weekly_limit, now));
}
return lanes;
}
const lanes = [windowToLane('session', 'Session (5h)', value.five_hour_limit, now)];
if (value.weekly_limit) {
lanes.push(windowToLane('weekly', 'Weekly', value.weekly_limit, now));
}
(value.additional_rate_limits ?? []).forEach((extra, i) => {
const name = extra.name ?? `limit ${i + 1}`;
lanes.push(windowToLane(`extra:${extra.name ?? i}`, name, extra, now));
});
return lanes;
}

Expand All @@ -125,11 +192,51 @@ export const codexAdapter: ProviderAdapter = {
message: string,
): ProviderSnapshot => ({ ...base, status, lanes: [], error: { code, message } });

// Step 1: mint the short-lived bearer the same way the ChatGPT page does.
// The token lives only in this function scope; it is never persisted.
let sessionResponse: Response;
try {
sessionResponse = await ctx.fetch(SESSION_URL, {
credentials: 'include',
headers: { Accept: 'application/json' },
});
} catch (err) {
return failed('error', 'network', err instanceof Error ? err.message : String(err));
}
if (sessionResponse.status === 401 || sessionResponse.status === 403) {
return failed('unauthenticated', 'not_logged_in', 'Log in at chatgpt.com');
}
if (sessionResponse.status === 429) {
return failed('rate_limited', 'rate_limited', 'chatgpt.com rate-limited us; backing off');
}
if (!sessionResponse.ok) {
return failed(
'error',
`http_${sessionResponse.status}`,
`chatgpt.com session endpoint returned ${sessionResponse.status}`,
);
}
let sessionBody: unknown;
try {
sessionBody = await sessionResponse.json();
} catch {
return failed('unauthenticated', 'not_logged_in', 'Log in at chatgpt.com');
}
const session = sessionSchema(sessionBody, 'session');
// A logged-out session returns 200 with an empty object.
if (!session.ok || !session.value.accessToken) {
return failed('unauthenticated', 'not_logged_in', 'Log in at chatgpt.com');
}

// Step 2: the usage call, authenticated exactly like the page's own.
let response: Response;
try {
response = await ctx.fetch(USAGE_URL, {
credentials: 'include',
headers: { Accept: 'application/json' },
headers: {
Accept: 'application/json',
Authorization: `Bearer ${session.value.accessToken}`,
},
});
} catch (err) {
return failed('error', 'network', err instanceof Error ? err.message : String(err));
Expand Down
Loading
Loading