diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c59d6bb40..4e7e489afa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (parallel-cycle model; cycle opened at the v3.8.46 release freeze). Finalized at the v3.8.47 release._ +- **9router Codex import**: the Codex bulk-import endpoint (`POST /api/oauth/codex/import`) now accepts 9router's camelCase account export (`accessToken`/`refreshToken`/`idToken`/`expiresAt` + nested `providerSpecificData`), not just snake_case — `normalizeCodexImportRecord` maps the camelCase aliases onto the existing snake_case keys, filling each only when absent so snake_case/mixed exports keep working unchanged ([#6665](https://github.com/diegosouzapw/OmniRoute/issues/6665)) — thanks @deadcoder0904. Regression guard: `tests/unit/codexBulkImport.test.ts` (9router camelCase record, pre-supplied `providerSpecificData` without an id_token, snake_case-not-overridden, and a full `{accounts:[...]}` flatten). + ### ✨ New Features - **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625) diff --git a/src/lib/oauth/services/codexImport.ts b/src/lib/oauth/services/codexImport.ts index 955584e3c6..9f5b929d34 100644 --- a/src/lib/oauth/services/codexImport.ts +++ b/src/lib/oauth/services/codexImport.ts @@ -138,6 +138,36 @@ function unwrapCodexAuthJson(rec: Record): Record, +): Record { + const out: Record = { ...rec }; + const fillFrom = (snake: string, value: unknown) => { + if (out[snake] === undefined && typeof value === "string" && value) { + out[snake] = value; + } + }; + fillFrom("access_token", rec.accessToken); + fillFrom("refresh_token", rec.refreshToken); + fillFrom("id_token", rec.idToken); + fillFrom("expired", rec.expiresAt); + const psd = rec.providerSpecificData; + if (psd && typeof psd === "object" && !Array.isArray(psd)) { + const block = psd as Record; + fillFrom("account_id", block.chatgptAccountId); + fillFrom("chatgpt_plan_type", block.chatgptPlanType); + } + return out; +} + // ── Public API ──────────────────────────────────────────────────────────────── /** @@ -151,7 +181,9 @@ export function normalizeCodexImportRecord(input: unknown): NormalizeResult { return { ok: false, error: "Record is not an object" }; } - const rec = unwrapCodexAuthJson(input as Record); + const rec = applyCamelCaseAliases( + unwrapCodexAuthJson(input as Record), + ); // Allow type field to be missing or "codex"; reject anything else explicitly so // users don't accidentally import claude/gemini exports through this path. @@ -181,7 +213,10 @@ export function normalizeCodexImportRecord(input: unknown): NormalizeResult { fromJwt.chatgptAccountId, rec.account_id as string | undefined, ); - const chatgptPlanType = pickString(fromJwt.chatgptPlanType); + const chatgptPlanType = pickString( + fromJwt.chatgptPlanType, + rec.chatgpt_plan_type as string | undefined, + ); const expiresAt = parseExpiry(rec.expired) ?? parseAccessTokenExp(accessToken); diff --git a/tests/unit/codexBulkImport.test.ts b/tests/unit/codexBulkImport.test.ts index 7551fe330b..9576b83109 100644 --- a/tests/unit/codexBulkImport.test.ts +++ b/tests/unit/codexBulkImport.test.ts @@ -243,3 +243,86 @@ describe("flattenCodexImportPayload", () => { assert.equal(flattenCodexImportPayload(null).ok, false); }); }); + +// ── 9router camelCase export (#6665) ──────────────────────────────────────────── + +describe("9router camelCase Codex export (#6665)", () => { + // The exact shape a 9router Codex account export produces (camelCase fields + + // nested providerSpecificData), as pasted in issue #6665. + const NINEROUTER_RECORD = { + accessToken: "access-9r", + refreshToken: "refresh-9r", + idToken: makeIdToken({ + email: "jwt-9r@example.com", + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-jwt-9r", + chatgpt_plan_type: "plus", + }, + }), + email: "top-9r@example.com", + expiresAt: "2026-07-17T13:18:27.000Z", + expiresIn: 849516, + providerSpecificData: { + chatgptAccountId: "acct-psd-9r", + chatgptPlanType: "pro", + }, + testStatus: "active", + isActive: true, + lastRefreshAt: "2026-07-07T17:19:51.150Z", + }; + + test("normalizes a 9router camelCase record", () => { + const result = normalizeCodexImportRecord(NINEROUTER_RECORD); + assert.equal(result.ok, true); + if (!result.ok) return; + const { payload } = result; + assert.equal(payload.accessToken, "access-9r"); + assert.equal(payload.refreshToken, "refresh-9r"); + assert.equal(payload.idToken, NINEROUTER_RECORD.idToken); + // JWT-derived account info wins over the pre-supplied providerSpecificData. + assert.equal(payload.email, "jwt-9r@example.com"); + assert.deepEqual(payload.providerSpecificData, { + chatgptAccountId: "acct-jwt-9r", + chatgptPlanType: "plus", + }); + // camelCase `expiresAt` is honored as the expiry source. + assert.equal(Date.parse(payload.expiresAt), Date.parse(NINEROUTER_RECORD.expiresAt)); + }); + + test("uses pre-supplied providerSpecificData when there is no id_token", () => { + const result = normalizeCodexImportRecord({ + accessToken: "a", + refreshToken: "r", + email: "no-jwt-9r@example.com", + providerSpecificData: { chatgptAccountId: "acct-psd", chatgptPlanType: "team" }, + }); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.payload.email, "no-jwt-9r@example.com"); + assert.deepEqual(result.payload.providerSpecificData, { + chatgptAccountId: "acct-psd", + chatgptPlanType: "team", + }); + }); + + test("an explicit snake_case field is NOT overridden by a camelCase alias", () => { + const result = normalizeCodexImportRecord({ + access_token: "snake-wins", + accessToken: "camel-loses", + refresh_token: "r", + email: "mixed@example.com", + }); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.payload.accessToken, "snake-wins"); + }); + + test("a full 9router {accounts:[...]} export flattens to its records", () => { + const flat = flattenCodexImportPayload([NINEROUTER_RECORD]); + assert.equal(flat.ok, true); + if (!flat.ok) return; + assert.equal(flat.records.length, 1); + const norm = normalizeCodexImportRecord(flat.records[0]); + assert.equal(norm.ok, true); + }); +});