feat(oauth): accept 9router camelCase Codex export in bulk import (#6665) (#6697)

* feat(oauth): accept 9router camelCase Codex export in bulk import (#6665)

* fix(changelog): restore CHANGELOG bullets eaten by release sync

* fix(changelog): re-restore CHANGELOG bullet after further release sync

* fix(changelog): re-restore CHANGELOG bullet after further release sync

* fix(changelog): re-restore #6697 bullet after release sync (#6678 landed)

* fix(changelog): re-restore CHANGELOG bullet after further release sync

* fix(changelog): re-restore #6697 bullet after #6700 release sync

* fix(changelog): re-restore #6697 bullet after release sync

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(changelog): restore #6126 bullet eaten by ancestry merge; re-insert only #6697's own

* chore(sync): merge release tip + restore own CHANGELOG bullet

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* chore(sync): merge release tip + restore own CHANGELOG bullet

* chore(changelog): re-sync after release merge — preserve sibling bullets

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-10 07:28:37 -03:00
committed by GitHub
parent e310aa7e41
commit 33786137d1
3 changed files with 122 additions and 2 deletions

View File

@@ -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)

View File

@@ -138,6 +138,36 @@ function unwrapCodexAuthJson(rec: Record<string, unknown>): Record<string, unkno
return { ...rec, ...tokens };
}
/**
* Map the camelCase field names used by 9router's Codex account export
* (`accessToken`, `refreshToken`, `idToken`, `expiresAt`, and a nested
* `providerSpecificData` block) onto the snake_case keys the rest of the
* normalizer already understands (#6665). A snake_case key is only filled from
* its camelCase alias when it is absent, so a snake_case or mixed export keeps
* working unchanged.
*/
function applyCamelCaseAliases(
rec: Record<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...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<string, unknown>;
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<string, unknown>);
const rec = applyCamelCaseAliases(
unwrapCodexAuthJson(input as Record<string, unknown>),
);
// 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);

View File

@@ -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);
});
});