Merge pull request #6095 from will-bogusz/fix/codex-workspace-scoped-credentials

fix(ai): scope OpenAI Codex credential identity by ChatGPT workspace
This commit is contained in:
Can Bölük
2026-07-20 22:19:26 +02:00
committed by GitHub
6 changed files with 379 additions and 41 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ When a provider needs an API key, `omp` resolves it in this order (first match w
1. **Runtime override** — a key supplied for the current process, e.g. CLI `--api-key`. Never persisted.
2. **`models.yml` config key** — an `apiKey` pinned on a custom provider, registered as a config-sourced bearer. This deliberately beats stored OAuth, so a key supplied for a custom `baseUrl`/gateway is honored instead of forwarding an upstream OAuth token the proxy would reject.
3. **Stored API key** — an API-key credential saved in the auth store.
4. **Stored OAuth credential** — refreshed when needed; multiple accounts are ranked/rotated automatically. For Anthropic, each organization counts as its own account: one email holding both a Team seat and a personal plan can log in once per subscription (pick the workspace on the browser consent page) and rotation treats them as two accounts.
4. **Stored OAuth credential** — refreshed when needed; multiple accounts are ranked/rotated automatically. For Anthropic and ChatGPT (Codex), each organization/workspace counts as its own account: one email holding both a Team/Enterprise seat and a personal plan can log in once per subscription (pick the workspace on the browser consent page) and rotation treats them as two accounts.
5. **Provider environment variable** — including values loaded from `.env` files (see [the env-var table](#environment-variables-and-env-files)).
6. **`models.yml` fallback resolver** — keys for custom providers not otherwise registered.
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed OpenAI Codex credentials limited to one ChatGPT workspace per email: a personal Plus/Pro plan and a Team/Enterprise seat under the same email now coexist in the auth store — with separate rotation and usage pools — instead of the second login silently replacing the first. The workspace (`chatgpt_account_id`) is captured as the credential's org at login with the plan type as its display label, and two members of one workspace keep separate rows ([#2966](https://github.com/can1357/oh-my-pi/issues/2966)).
## [17.0.5] - 2026-07-18
### Changed
+44 -36
View File
@@ -186,7 +186,7 @@ export interface CredentialHealthResult {
email?: string;
/** OAuth account id if known. */
accountId?: string;
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
/** `true` when the refresh token lives on a remote broker (sentinel was present). */
@@ -735,7 +735,7 @@ export interface OAuthAccess {
projectId?: string;
enterpriseUrl?: string;
apiEndpoint?: string;
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
}
@@ -760,7 +760,7 @@ export interface OAuthAccessFailure {
projectId?: string;
enterpriseUrl?: string;
apiEndpoint?: string;
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
error: string;
@@ -776,7 +776,7 @@ export interface OAuthAccountIdentity {
accountId?: string;
email?: string;
projectId?: string;
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
}
@@ -795,7 +795,7 @@ export interface OAuthAccountSummary {
email?: string;
projectId?: string;
enterpriseUrl?: string;
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
orgId?: string;
orgName?: string;
}
@@ -3283,15 +3283,14 @@ export class AuthStorage {
const identifiers: string[] = [];
const email = this.#getUsageReportMetadataValue(report, "email");
if (email) identifiers.push(`email:${email.toLowerCase()}`);
if (report.provider === "anthropic") {
// Anthropic: one account email can hold several organizations
// (Team seat + personal Max). Reports from different orgs must not
// merge — scope every identifier by org when the report carries one.
// When the email could not be recovered, fall back to the account
// (identical across orgs, hence the org qualifier is what keeps two
// subscriptions apart) so no-email reports still merge per org.
// Org-less reports (pre-upgrade caches) keep their bare identifiers
// and only merge among themselves.
if (report.provider === "anthropic" || report.provider === "openai-codex") {
// One account email can hold several org-scoped subscriptions
// (Anthropic organizations, ChatGPT workspaces). Reports from
// different orgs must not merge — scope every identifier by org
// when the report carries one; fall back to the account when the
// email could not be recovered so no-email reports still merge
// per org. Org-less reports (pre-upgrade caches) keep their bare
// identifiers and only merge among themselves.
if (identifiers.length === 0) {
const accountId =
this.#getUsageReportMetadataValue(report, "accountId") ?? this.#getUsageReportScopeAccountId(report);
@@ -3299,12 +3298,11 @@ export class AuthStorage {
}
const orgId = this.#getUsageReportMetadataValue(report, "orgId");
if (orgId) {
if (identifiers.length === 0) return [`anthropic:org:${orgId.toLowerCase()}`];
return identifiers.map(identifier => `anthropic:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`);
if (identifiers.length === 0) return [`${report.provider}:org:${orgId.toLowerCase()}`];
return identifiers.map(
identifier => `${report.provider}:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`,
);
}
return identifiers.map(identifier => `anthropic:${identifier.toLowerCase()}`);
}
if (report.provider === "openai-codex") {
return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
}
const projectId =
@@ -5294,9 +5292,15 @@ export class AuthStorage {
if (credential.type !== "oauth") continue;
const credentialEmail = credential.email?.trim().toLowerCase();
const credentialAccountId = credential.accountId?.trim().toLowerCase();
if ((email && credentialEmail === email) || (accountId && credentialAccountId === accountId)) {
matches.push(entry.id);
}
// Every identity dimension present on BOTH sides must agree — the
// account id is shared workspace-wide and one email can span
// workspaces, so a single-dimension match can cross-link siblings.
const emailComparable = Boolean(email && credentialEmail);
const accountComparable = Boolean(accountId && credentialAccountId);
if (!emailComparable && !accountComparable) continue;
if (emailComparable && credentialEmail !== email) continue;
if (accountComparable && credentialAccountId !== accountId) continue;
matches.push(entry.id);
}
return matches;
}
@@ -5906,16 +5910,15 @@ function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): Store
function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:"));
if (provider === "anthropic") {
// One Anthropic account email can hold several organizations (e.g. a
// Team seat plus a personal Max plan), each with its own org-scoped
// token and limit pools. Scope identity by org so both subscriptions
// can be stored side by side. The qualifier rides on whichever base
// identity is available — the account UUID is IDENTICAL across the
// orgs of one login account, so an unqualified account/project
// fallback would still collapse two subscriptions whenever the email
// could not be recovered. Org-less credentials (rows written before
// org capture existed) keep their bare key.
if (provider === "anthropic" || provider === "openai-codex") {
// One account email can hold several organizations/workspaces (e.g. a
// Team seat plus a personal plan), each with its own org-scoped token
// and limit pools. Scope identity by org so both subscriptions can be
// stored side by side. The qualifier rides on whichever base identity
// is available, so an unqualified account/project fallback would
// still collapse two subscriptions whenever the email could not be
// recovered. Org-less credentials (rows written before org capture
// existed) keep their bare key.
const base =
emailIdentifier ??
identifiers.find(identifier => identifier.startsWith("account:")) ??
@@ -5925,7 +5928,6 @@ function resolveProviderCredentialIdentityKey(provider: string, identifiers: str
// No base identity at all: the org alone still distinguishes the row.
return orgIdentifier ?? null;
}
if (provider === "openai-codex" && emailIdentifier) return emailIdentifier;
const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
if (accountIdentifier) return accountIdentifier;
if (emailIdentifier) return emailIdentifier;
@@ -5962,9 +5964,9 @@ function matchesReplacementCredential(
if (incomingIdentityKey === existingIdentityKey) return true;
if (existingIdentityKey === null) return false;
// One-way upgrade, applied only when the INCOMING identity key carries the
// org qualifier (only anthropic keys do, so other providers never reach the
// checks below). An org-scoped login `org:<o>` claims (and re-keys) any
// existing row that denotes the same subscription:
// org qualifier (only anthropic and openai-codex keys do, so other
// providers never reach the checks below). An org-scoped login `org:<o>`
// claims (and re-keys) any existing row that denotes the same subscription:
// - `org:<o>` — org-only row stored when identity recovery failed, claimed
// once a later same-org login recovers a base identity;
// - `<b>` for any base identity `<b>` (email/account/project) the incoming
@@ -5988,10 +5990,16 @@ function matchesReplacementCredential(
existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`)
? extractOAuthCredentialIdentifiers(existing)
: null;
// A base identifier that merely repeats the org qualifier's id carries no
// per-user identity (openai-codex stores the ChatGPT workspace id as both
// accountId and orgId, shared by every member) — letting it act as a
// claimable base would re-key another member's same-org row.
const orgQualifierId = orgIdentifier.slice("org:".length);
for (const identifier of incomingIdentifiers) {
const isBase =
identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:");
if (!isBase) continue;
if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue;
if (existingIdentityKey === identifier) return true;
if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true;
if (existingIdentifiers?.includes(identifier)) return true;
+22 -2
View File
@@ -31,6 +31,7 @@ const DEVICE_MAX_POLLS = 120;
type JwtPayload = {
[JWT_CLAIM_PATH]?: {
chatgpt_account_id?: string;
chatgpt_plan_type?: string;
};
[JWT_PROFILE_CLAIM]?: {
email?: string;
@@ -50,14 +51,27 @@ export function decodeJwt<T = Record<string, unknown>>(token: string): T | null
}
}
function getTokenProfile(accessToken: string): { accountId?: string; email?: string } {
/**
* Identity slice decoded from the token claims. The ChatGPT workspace
* (`chatgpt_account_id`) is the subscription pool the token draws limits
* from — one account email can hold several (e.g. a personal Pro plan plus a
* Team seat). `chatgpt_plan_type` may only be present on the `id_token`.
*/
function getTokenProfile(
accessToken: string,
idToken?: string,
): { accountId?: string; email?: string; planType?: string } {
const payload = decodeJwt<JwtPayload>(accessToken);
const idPayload = idToken ? decodeJwt<JwtPayload>(idToken) : null;
const auth = payload?.[JWT_CLAIM_PATH];
const idAuth = idPayload?.[JWT_CLAIM_PATH];
const accountId = auth?.chatgpt_account_id;
const email = payload?.[JWT_PROFILE_CLAIM]?.email?.trim().toLowerCase();
const planType = (auth?.chatgpt_plan_type ?? idAuth?.chatgpt_plan_type)?.trim().toLowerCase();
return {
accountId: typeof accountId === "string" && accountId.length > 0 ? accountId : undefined,
email: typeof email === "string" && email.length > 0 ? email : undefined,
planType: typeof planType === "string" && planType.length > 0 ? planType : undefined,
};
}
@@ -185,6 +199,7 @@ async function exchangeCodeForToken(
const tokenData = (await tokenResponse.json()) as {
access_token?: string;
refresh_token?: string;
id_token?: string;
expires_in?: number;
};
@@ -192,7 +207,7 @@ async function exchangeCodeForToken(
throw new AIError.OAuthError("Token response missing required fields", { kind: "validation" });
}
const { accountId, email } = getTokenProfile(tokenData.access_token);
const { accountId, email, planType } = getTokenProfile(tokenData.access_token, tokenData.id_token);
if (!accountId) {
throw new AIError.OAuthError("Failed to extract accountId from token", { kind: "validation" });
}
@@ -203,6 +218,8 @@ async function exchangeCodeForToken(
expires: Date.now() + tokenData.expires_in * 1000,
accountId,
email,
orgId: accountId,
orgName: planType,
};
}
@@ -354,6 +371,9 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise<OAu
const { accountId, email } = getTokenProfile(tokenData.access_token);
// Deliberately no org fields on the result: the workspace a credential is
// scoped to is fixed at login. Callers merge refresh results over the
// stored credential, so omitting org here preserves it verbatim.
return {
access: tokenData.access_token,
refresh: tokenData.refresh_token || refreshToken,
+3 -2
View File
@@ -12,8 +12,9 @@ export type OAuthCredentials = {
apiEndpoint?: string;
/**
* Organization/workspace the token is scoped to (e.g. an Anthropic org
* UUID). Captured once at login; token refreshes never rewrite it. Lets
* one account email hold credentials for multiple subscriptions.
* UUID or a ChatGPT workspace id). Captured once at login; token refreshes
* never rewrite it. Lets one account email hold credentials for multiple
* subscriptions.
*/
orgId?: string;
/** Human-readable organization name for display (may embed the email). */
@@ -0,0 +1,305 @@
/**
* OpenAI Codex workspace-scoped credential identity.
*
* The ChatGPT workspace (`chatgpt_account_id`, captured as `orgId` at login)
* is the subscription pool a Codex token draws limits from. One email can
* hold a personal Plus/Pro plan plus Team/Enterprise seats — different
* workspaces with independent pools — while every member of one workspace
* shares the workspace id. Identity therefore composes `email|org:<ws>`:
* - same email + same workspace => replace in place (re-login);
* - same email + diff workspace => coexist (personal + enterprise seat);
* - diff email + same workspace => coexist (two Team members, #197);
* - workspace-less legacy rows keep their bare email key and are claimed
* in place by the first workspace-scoped login with the same email.
*/
import { Database } from "bun:sqlite";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
type AuthCredential,
type AuthCredentialStore,
AuthStorage,
SqliteAuthCredentialStore,
type StoredAuthCredential,
} from "@oh-my-pi/pi-ai/auth-storage";
import type { UsageReport } from "@oh-my-pi/pi-ai/usage";
import * as codexUsage from "@oh-my-pi/pi-ai/usage/openai-codex";
import { removeWithRetries } from "../../utils/src/temp";
const EMAIL = "shared@example.com";
const PERSONAL_WS = "ws-personal-1111";
const TEAM_WS = "ws-team-2222";
function codexCredential(args: {
suffix: string;
accountId: string;
/** Workspace qualifier; omitted for legacy rows written before workspace capture. */
orgId?: string;
orgName?: string;
email?: string;
}): AuthCredential {
return {
type: "oauth",
access: `access-${args.suffix}`,
refresh: `refresh-${args.suffix}`,
expires: Date.now() + 3_600_000,
accountId: args.accountId,
email: args.email ?? EMAIL,
orgId: args.orgId,
orgName: args.orgName,
};
}
function readIdentityRows(dbPath: string): Array<{ identity_key: string | null; disabled_cause: string | null }> {
const db = new Database(dbPath, { readonly: true });
try {
return db
.prepare(
"SELECT identity_key, disabled_cause FROM auth_credentials WHERE provider = 'openai-codex' ORDER BY id ASC",
)
.all() as Array<{ identity_key: string | null; disabled_cause: string | null }>;
} finally {
db.close();
}
}
describe("openai-codex workspace-scoped credential identity", () => {
let tempDir = "";
let dbPath = "";
let store: SqliteAuthCredentialStore | null = null;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-codex-ws-identity-"));
dbPath = path.join(tempDir, "agent.db");
store = await SqliteAuthCredentialStore.open(dbPath);
});
afterEach(async () => {
store?.close();
store = null;
if (tempDir) await removeWithRetries(tempDir);
});
it("stores a personal plan and an enterprise seat of one email side by side and updates same-workspace logins in place", () => {
if (!store) throw new Error("test setup failed");
store.upsertAuthCredentialForProvider(
"openai-codex",
codexCredential({ suffix: "personal", accountId: PERSONAL_WS, orgId: PERSONAL_WS, orgName: "plus" }),
);
store.upsertAuthCredentialForProvider(
"openai-codex",
codexCredential({ suffix: "team", accountId: TEAM_WS, orgId: TEAM_WS, orgName: "enterprise" }),
);
expect(readIdentityRows(dbPath)).toEqual([
{ identity_key: `email:${EMAIL}|org:${PERSONAL_WS}`, disabled_cause: null },
{ identity_key: `email:${EMAIL}|org:${TEAM_WS}`, disabled_cause: null },
]);
// Same-workspace re-login: replaces the matching row instead of adding a third.
const rows = store.upsertAuthCredentialForProvider(
"openai-codex",
codexCredential({ suffix: "team-renewed", accountId: TEAM_WS, orgId: TEAM_WS, orgName: "enterprise" }),
);
expect(readIdentityRows(dbPath)).toEqual([
{ identity_key: `email:${EMAIL}|org:${PERSONAL_WS}`, disabled_cause: null },
{ identity_key: `email:${EMAIL}|org:${TEAM_WS}`, disabled_cause: null },
]);
const teamRow = rows.find(row => row.credential.type === "oauth" && row.credential.orgId === TEAM_WS);
expect(teamRow?.credential.type).toBe("oauth");
if (teamRow?.credential.type === "oauth") {
expect(teamRow.credential.access).toBe("access-team-renewed");
}
});
it("keeps two members of one workspace separate even though they share the workspace id", () => {
if (!store) throw new Error("test setup failed");
store.upsertAuthCredentialForProvider(
"openai-codex",
codexCredential({ suffix: "alice", accountId: TEAM_WS, orgId: TEAM_WS, email: "alice@example.com" }),
);
store.upsertAuthCredentialForProvider(
"openai-codex",
codexCredential({ suffix: "bob", accountId: TEAM_WS, orgId: TEAM_WS, email: "bob@example.com" }),
);
expect(readIdentityRows(dbPath)).toEqual([
{ identity_key: `email:alice@example.com|org:${TEAM_WS}`, disabled_cause: null },
{ identity_key: `email:bob@example.com|org:${TEAM_WS}`, disabled_cause: null },
]);
});
it("upgrades a legacy email-keyed row on the first workspace-scoped login with the same email", () => {
if (!store) throw new Error("test setup failed");
store.upsertAuthCredentialForProvider(
"openai-codex",
codexCredential({ suffix: "legacy", accountId: PERSONAL_WS }),
);
expect(readIdentityRows(dbPath)).toEqual([{ identity_key: `email:${EMAIL}`, disabled_cause: null }]);
store.upsertAuthCredentialForProvider(
"openai-codex",
codexCredential({ suffix: "team", accountId: TEAM_WS, orgId: TEAM_WS, orgName: "team" }),
);
expect(readIdentityRows(dbPath)).toEqual([
{ identity_key: `email:${EMAIL}|org:${TEAM_WS}`, disabled_cause: null },
]);
});
it("never clobbers workspace-scoped rows with a workspace-less credential", () => {
if (!store) throw new Error("test setup failed");
store.upsertAuthCredentialForProvider(
"openai-codex",
codexCredential({ suffix: "personal", accountId: PERSONAL_WS, orgId: PERSONAL_WS, orgName: "plus" }),
);
store.upsertAuthCredentialForProvider(
"openai-codex",
codexCredential({ suffix: "team", accountId: TEAM_WS, orgId: TEAM_WS, orgName: "enterprise" }),
);
store.upsertAuthCredentialForProvider(
"openai-codex",
codexCredential({ suffix: "orgless", accountId: PERSONAL_WS }),
);
expect(readIdentityRows(dbPath)).toEqual([
{ identity_key: `email:${EMAIL}|org:${PERSONAL_WS}`, disabled_cause: null },
{ identity_key: `email:${EMAIL}|org:${TEAM_WS}`, disabled_cause: null },
{ identity_key: `email:${EMAIL}`, disabled_cause: null },
]);
});
});
// ─── Usage report dedupe partitioning ───────────────────────────────────────
interface CacheEntry {
value: string;
expiresAtSec: number;
}
function makeStore(rows: StoredAuthCredential[]): AuthCredentialStore {
const cache = new Map<string, CacheEntry>();
return {
close() {},
listAuthCredentials() {
return rows;
},
updateAuthCredential() {},
deleteAuthCredential() {},
tryDisableAuthCredentialIfMatches() {
return false;
},
replaceAuthCredentialsForProvider() {
return rows;
},
upsertAuthCredentialForProvider() {
return rows;
},
deleteAuthCredentialsForProvider() {},
getCache(key) {
const entry = cache.get(key);
if (!entry) return null;
if (entry.expiresAtSec * 1000 <= Date.now()) return null;
return entry.value;
},
setCache(key, value, expiresAtSec) {
cache.set(key, { value, expiresAtSec });
},
cleanExpiredCache() {},
};
}
function codexRow(
id: number,
args?: { orgId?: string; orgName?: string; accountId?: string; email?: string },
): StoredAuthCredential {
return {
id,
provider: "openai-codex",
credential: {
type: "oauth",
access: `oat-${id}`,
refresh: `refresh-${id}`,
expires: Date.now() + 3_600_000,
accountId: args?.accountId ?? args?.orgId ?? "ws-legacy",
email: args?.email ?? EMAIL,
orgId: args?.orgId,
orgName: args?.orgName,
},
disabledCause: null,
};
}
/** Report carrying ONLY email identity — workspace attribution must come from the credential. */
function emailOnlyReport(email: string): UsageReport {
return {
provider: "openai-codex",
fetchedAt: Date.now(),
limits: [
{
id: "openai-codex:primary",
label: "5 hours",
scope: { provider: "openai-codex", windowId: "5h" },
window: { id: "5h", label: "5 hours" },
amount: { used: 42, limit: 100, unit: "percent" },
status: "ok",
},
],
metadata: { email },
};
}
describe("openai-codex usage report dedupe partitions by workspace", () => {
let storage: AuthStorage | null = null;
afterEach(() => {
storage?.close();
storage = null;
vi.restoreAllMocks();
});
it("keeps reports from two workspaces on one email separate and attributes each to its workspace", async () => {
storage = new AuthStorage(
makeStore([
codexRow(1, { orgId: PERSONAL_WS, orgName: "plus" }),
codexRow(2, { orgId: TEAM_WS, orgName: "enterprise" }),
]),
{
usageProviderResolver: provider =>
provider === "openai-codex" ? codexUsage.openaiCodexUsageProvider : undefined,
},
);
await storage.reload();
vi.spyOn(codexUsage.openaiCodexUsageProvider, "fetchUsage").mockImplementation(async () =>
emailOnlyReport(EMAIL),
);
const reports = ((await storage.fetchUsageReports()) ?? []).filter(r => r.provider === "openai-codex");
expect(reports).toHaveLength(2);
const orgIds = reports.map(report => report.metadata?.orgId).sort();
expect(orgIds).toEqual([PERSONAL_WS, TEAM_WS].sort());
const orgNames = reports.map(report => report.metadata?.orgName).sort();
expect(orgNames).toEqual(["enterprise", "plus"].sort());
});
it("still merges workspace-less reports with the same email into one row", async () => {
storage = new AuthStorage(makeStore([codexRow(1), codexRow(2)]), {
usageProviderResolver: provider =>
provider === "openai-codex" ? codexUsage.openaiCodexUsageProvider : undefined,
});
await storage.reload();
vi.spyOn(codexUsage.openaiCodexUsageProvider, "fetchUsage").mockImplementation(async () =>
emailOnlyReport(EMAIL),
);
const reports = ((await storage.fetchUsageReports()) ?? []).filter(r => r.provider === "openai-codex");
expect(reports).toHaveLength(1);
});
});