Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@

- 更新 `README_EN.md` 中过时的模型推荐说明以匹配最新的模型别名映射(`README_EN.md`)

### Fixed

- RT-only 导入兼容性:Refresh Token 兑换的 access token 缺少 `chatgpt_account_id` 时,优先从 `id_token` 回填账号元数据;若仍缺失但 token 未过期也允许导入继续(accountId 置 null)。新增 `extractCodexTokenMetadata()` 统一提取双 token 元数据。(#674)
- OAuth refresh 请求新增 `scope` 参数确保服务端返回 `id_token`。(#674)

### Added

- Web 前端测试接入 CI 门禁:`web/` 是独立 package(非 root workspace)、用自己的 vitest(jsdom),其组件测试此前不被 root `npm test` 收录、不在任何自动化里跑。新增 root `test:web` 脚本(`cd web && npx vitest run`)和 `ci-quality.yml` 独立 `frontend-tests` job(`cd web && npm ci` + vitest),把现存 7 个 web 测试文件 16 用例纳入 PR 门禁;并给 `web/vite.config.ts` 补 `preact/devtools` / `preact/debug` alias——`@preact/preset-vite` 会把这两个 import 注入 `shared/` 文件,干净 `npm ci`(CI)从 web/ 外解析不到会在 import 阶段直接炸(本地因根 node_modules 有 preact 兜底而测不出)(`package.json`、`.github/workflows/ci-quality.yml`、`web/vite.config.ts`)
Expand Down Expand Up @@ -89,6 +94,7 @@
- 隐式续链反向校验缺失导致客户端持续看到上游 `invalid_request_error: No tool output found for function call call_X`:`evaluateImplicitResume` 此前只做 forward 检查(新输入里的 `function_call_output.call_id` 必须命中上一轮 stored function_call),漏了反向(上一轮 stored function_call 必须在新输入里有 output)。当上一轮模型并发吐 N 个 tool_use、客户端只回 N-1 个 tool_result 时,proxy 仍然 resume + `previous_response_id` 发出去,上游存的 context 里那个未回复的 function_call 触发 400。新增反向检查 → 走完整重放(`reason: "unanswered_tool_calls"`),同时 `error-classification.ts` 加 `isUnansweredFunctionCallError`,proxy-handler catch 块兜底:strip `previous_response_id` + 完整历史重放 + 同账号重试一次(与 `previous_response_not_found` 同款),避免 ws/sse 路径上的 400 静默吞掉变成 502
- `codex-to-anthropic.ts` / `codex-to-openai.ts` / `codex-to-gemini.ts` 非流式 collect 路径里把上游错误事件抛成 `new Error(...)`,丢失 status 信息,handleNonStreaming 的 collectErr 再通过正则匹配 `HTTP/X.X NNN` 状态码必然失败 → 一律 502 兜底,客户端拿到的是模糊的 502 而不是上游真实的 400/429。改为统一 `codexApiErrorFromEvent(evt.error)` 抛 `CodexApiError(status, body)`,按 error code 映射到 400/401/402/403/429(默认 502);handleNonStreaming 的 collectErr 也加一条 `instanceof CodexApiError` 分支直接透传 status,不再走正则降级
- `streamResponse` 流式路径里上游错误此前只往 SSE 写一条 `stream_error` 事件、零日志,客户端能看到错误但 proxy `dev-YYYY-MM-DD.log` 里完全没记录,排查时无证据链。catch 块加 `console.warn` 打 `status / msg / body`,留下 call_id 等关键现场
- 修复 RT-only 导入兼容性:Refresh Token 换取的 access token 缺少 `chatgpt_account_id` 时,优先从 `id_token` 回填账号元数据;若仍缺失但 token 未过期,也允许导入继续完成,避免 RT-only 导入失败(`src/auth/jwt-utils.ts`、`src/services/account-import.ts`、`src/auth/account-pool.ts`、`src/auth/account-registry.ts`)

### Changed

Expand Down
9 changes: 7 additions & 2 deletions src/auth/account-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createFsPersistence } from "./account-persistence.js";
import { AccountRegistry } from "./account-registry.js";
import { AccountLifecycle } from "./account-lifecycle.js";
import type { AccountPersistence, PersistenceLoadHealth } from "./account-persistence.js";
import type { CodexTokenMetadata } from "./jwt-utils.js";
import type { RotationStrategyName } from "./rotation-strategy.js";
import type {
AccountEntry,
Expand Down Expand Up @@ -129,8 +130,12 @@ export class AccountPool {

// ── CRUD ──────────────────────────────────────────────────────────

addAccount(token: string, refreshToken?: string | null): string {
return this.registry.addAccount(token, refreshToken);
addAccount(
token: string,
refreshToken?: string | null,
metadata?: Partial<CodexTokenMetadata>,
): string {
return this.registry.addAccount(token, refreshToken, metadata);
}

removeAccount(id: string): boolean {
Expand Down
41 changes: 34 additions & 7 deletions src/auth/account-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getDataDir } from "../paths.js";
import { jitter } from "../utils/jitter.js";
import {
decodeJwtPayload,
type CodexTokenMetadata,
extractChatGptAccountId,
extractUserProfile,
isTokenExpired,
Expand All @@ -32,6 +33,17 @@ function safeEqual(a: string, b: string): boolean {
return timingSafeEqual(bufA, bufB);
}

function sameAccountWithoutAccountId(
existing: AccountEntry,
userId: string | null,
email: string | null,
): boolean {
if (existing.accountId !== null) return false;
if (userId && existing.userId === userId) return true;
if (email && existing.email === email) return true;
return false;
}

type ResettableQuotaWindow = {
used_percent: number | null;
reset_at: number | null;
Expand Down Expand Up @@ -88,10 +100,16 @@ export class AccountRegistry {

// ── CRUD ──────────────────────────────────────────────────────────

addAccount(token: string, refreshToken?: string | null): string {
const accountId = extractChatGptAccountId(token);
addAccount(
token: string,
refreshToken?: string | null,
metadata?: Partial<CodexTokenMetadata>,
): string {
const accountId = extractChatGptAccountId(token) ?? metadata?.accountId ?? null;
const profile = extractUserProfile(token);
const userId = profile?.chatgpt_user_id ?? null;
const userId = profile?.chatgpt_user_id ?? metadata?.userId ?? null;
const email = profile?.email ?? metadata?.email ?? null;
const planType = profile?.chatgpt_plan_type ?? metadata?.planType ?? null;

for (const existing of this.accounts.values()) {
if (accountId) {
Expand All @@ -101,12 +119,21 @@ export class AccountRegistry {
existing.refreshToken = refreshToken;
}
existing.email = profile?.email ?? existing.email;
existing.planType = profile?.chatgpt_plan_type ?? existing.planType;
existing.planType = profile?.chatgpt_plan_type ?? metadata?.planType ?? existing.planType;
existing.status = isTokenExpired(token) ? "expired" : "active";
this.persistNow();
return existing.id;
}
} else if (existing.token === token) {
} else if (sameAccountWithoutAccountId(existing, userId, email) || existing.token === token) {
existing.token = token;
if (typeof refreshToken === "string" && refreshToken.length > 0) {
existing.refreshToken = refreshToken;
}
existing.email = email ?? existing.email;
existing.userId = userId ?? existing.userId;
existing.planType = planType ?? existing.planType;
existing.status = isTokenExpired(token) ? "expired" : "active";
this.persistNow();
return existing.id;
}
}
Expand All @@ -116,11 +143,11 @@ export class AccountRegistry {
id,
token,
refreshToken: refreshToken ?? null,
email: profile?.email ?? null,
email,
accountId,
userId,
label: null,
planType: profile?.chatgpt_plan_type ?? null,
planType,
proxyApiKey: "codex-proxy-" + randomBytes(24).toString("hex"),
status: isTokenExpired(token) ? "expired" : "active",
usage: {
Expand Down
66 changes: 66 additions & 0 deletions src/auth/jwt-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,72 @@ export function extractChatGptAccountId(token: string): string | null {
return null;
}

export interface CodexTokenMetadata {
accountId: string | null;
userId: string | null;
email: string | null;
planType: string | null;
}

function getAuthClaims(payload: Record<string, unknown> | null): Record<string, unknown> {
const auth = payload?.["https://api.openai.com/auth"];
return auth && typeof auth === "object" && auth !== null
? auth as Record<string, unknown>
: {};
}

function getProfileClaims(payload: Record<string, unknown> | null): Record<string, unknown> {
const profile = payload?.["https://api.openai.com/profile"];
return profile && typeof profile === "object" && profile !== null
? profile as Record<string, unknown>
: {};
}

function stringClaim(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === "string" && value.length > 0) {
return value;
}
}
return null;
}

export function extractCodexTokenMetadata(
accessToken: string,
idToken?: string | null,
): CodexTokenMetadata {
const accessPayload = decodeJwtPayload(accessToken);
const idPayload = idToken ? decodeJwtPayload(idToken) : null;
const accessAuth = getAuthClaims(accessPayload);
const idAuth = getAuthClaims(idPayload);
const accessProfile = getProfileClaims(accessPayload);
const idProfile = getProfileClaims(idPayload);

return {
accountId: stringClaim(
accessAuth.chatgpt_account_id,
idAuth.chatgpt_account_id,
),
userId: stringClaim(
accessAuth.chatgpt_user_id,
accessProfile.chatgpt_user_id,
idAuth.chatgpt_user_id,
idProfile.chatgpt_user_id,
),
email: stringClaim(
accessProfile.email,
idProfile.email,
idPayload?.email,
),
planType: stringClaim(
accessAuth.chatgpt_plan_type,
accessProfile.chatgpt_plan_type,
idAuth.chatgpt_plan_type,
idProfile.chatgpt_plan_type,
),
};
}

export function extractUserProfile(
token: string,
): { email?: string; chatgpt_user_id?: string; chatgpt_plan_type?: string } | null {
Expand Down
5 changes: 5 additions & 0 deletions src/auth/oauth-pkce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ export async function refreshAccessToken(
grant_type: "refresh_token",
client_id: config.auth.oauth_client_id,
refresh_token: refreshToken,
// Explicitly request openid scope to ensure id_token is returned.
// Some OpenAI token responses omit chatgpt_account_id from the
// access_token but include it in the id_token — without this scope
// the server may skip the id_token entirely.
scope: "openid profile email offline_access",
});

const doRequest = (proxyUrl: string | null | undefined) =>
Expand Down
49 changes: 38 additions & 11 deletions src/services/account-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@

import type { AccountPool } from "../auth/account-pool.js";
import type { AccountInfo } from "../auth/types.js";
import { extractChatGptAccountId } from "../auth/jwt-utils.js";
import {
decodeJwtPayload,
extractChatGptAccountId,
extractCodexTokenMetadata,
isTokenExpired,
type CodexTokenMetadata,
} from "../auth/jwt-utils.js";

export interface ImportEntry {
token?: string;
Expand All @@ -30,7 +36,7 @@ export interface ImportDeps {
refreshToken(
rt: string,
proxyUrl: string | null,
): Promise<{ access_token: string; refresh_token?: string }>;
): Promise<{ access_token: string; refresh_token?: string; id_token?: string }>;
getProxyUrl(): string | null;
/** Optional warmup: establishes session cookies after import to avoid cold-start bans. */
warmup?(entryId: string, token: string, accountId: string | null): Promise<void>;
Expand Down Expand Up @@ -71,7 +77,7 @@ export class AccountImportService {
continue;
}

const entryId = this.pool.addAccount(resolved.token, resolved.rt);
const entryId = this.pool.addAccount(resolved.token, resolved.rt, resolved.metadata);
this.scheduler.scheduleOne(entryId, resolved.token);

if (entry.label) {
Expand Down Expand Up @@ -139,7 +145,7 @@ export class AccountImportService {
}
}

const entryId = this.pool.addAccount(resolved.token, resolved.rt);
const entryId = this.pool.addAccount(resolved.token, resolved.rt, resolved.metadata);
this.scheduler.scheduleOne(entryId, resolved.token);

// Cache quota from verification (so dashboard shows data immediately)
Expand All @@ -161,7 +167,7 @@ export class AccountImportService {
token: string | undefined,
rt: string | null,
): Promise<
| { ok: true; token: string; rt: string | null }
| { ok: true; token: string; rt: string | null; metadata?: Partial<CodexTokenMetadata> }
| { ok: false; error: string; kind: "validation" | "refresh_failed" }
> {
if (token) {
Expand All @@ -172,22 +178,31 @@ export class AccountImportService {
return { ok: true, token, rt };
}

if (!rt) {
return { ok: false, error: "Refresh token is required", kind: "validation" };
}

// Refresh-token-only path — check if this RT already belongs to an existing account
const existing = this.pool.getAllEntries().find((a) => a.refreshToken === rt);
if (existing) {
return { ok: true, token: existing.token, rt: existing.refreshToken };
}

// Prevent concurrent refresh of the same RT (e.g. duplicate entries in import file)
if (this.refreshingRTs.has(rt as string)) {
if (this.refreshingRTs.has(rt)) {
return { ok: false, error: "Duplicate RT in import batch (skipped to protect token)", kind: "refresh_failed" };
}
this.refreshingRTs.add(rt as string);
this.refreshingRTs.add(rt);

try {
const proxyUrl = this.deps.getProxyUrl();
const tokens = await this.deps.refreshToken(rt as string, proxyUrl);
const v = this.deps.validateToken(tokens.access_token);
const tokens = await this.deps.refreshToken(rt, proxyUrl);
const accessToken = tokens.access_token.trim();
const metadata = extractCodexTokenMetadata(accessToken, tokens.id_token);
// Bypass validateToken for RT exchange — the strict chatgpt_account_id check
// would reject tokens that OpenAI legitimately returns without that claim.
// Only verify the token is decodable and not expired.
const v = this.validateRtExchangeToken(accessToken);
if (!v.valid) {
return {
ok: false,
Expand All @@ -199,8 +214,9 @@ export class AccountImportService {
const newRT = tokens.refresh_token ?? null;
return {
ok: true,
token: tokens.access_token,
token: accessToken,
rt: newRT,
metadata,
};
} catch (err) {
return {
Expand All @@ -209,7 +225,18 @@ export class AccountImportService {
kind: "refresh_failed",
};
} finally {
this.refreshingRTs.delete(rt as string);
this.refreshingRTs.delete(rt);
}
}

private validateRtExchangeToken(token: string): { valid: boolean; error?: string } {
const payload = decodeJwtPayload(token);
if (!payload) {
return { valid: false, error: "Invalid JWT format — could not decode payload" };
}
if (isTokenExpired(token)) {
return { valid: false, error: "Token is expired" };
}
return { valid: true };
}
}
25 changes: 14 additions & 11 deletions tests/_helpers/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,25 @@ export function createJwt(claims: JwtClaims = {}): string {
}

/** Create a valid non-expired JWT with standard Codex claims. */
export function createValidJwt(overrides: {
accountId?: string;
export function createValidJwt(overrides: {
accountId?: string;
/** chatgpt_user_id — team members share accountId but differ by userId */
userId?: string;
email?: string;
planType?: string;
expInSeconds?: number;
} = {}): string {
const exp = Math.floor(Date.now() / 1000) + (overrides.expInSeconds ?? 3600);
return createJwt({
exp,
"https://api.openai.com/auth": {
chatgpt_account_id: overrides.accountId ?? "acct-test-123",
chatgpt_plan_type: overrides.planType ?? "free",
...(overrides.userId !== undefined ? { chatgpt_user_id: overrides.userId } : {}),
},
} = {}): string {
const exp = Math.floor(Date.now() / 1000) + (overrides.expInSeconds ?? 3600);
const accountId = Object.prototype.hasOwnProperty.call(overrides, "accountId")
? overrides.accountId
: "acct-test-123";
return createJwt({
exp,
"https://api.openai.com/auth": {
...(accountId !== undefined ? { chatgpt_account_id: accountId } : {}),
chatgpt_plan_type: overrides.planType ?? "free",
...(overrides.userId !== undefined ? { chatgpt_user_id: overrides.userId } : {}),
},
"https://api.openai.com/profile": {
email: overrides.email ?? "test@example.com",
},
Expand Down
Loading