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
18 changes: 15 additions & 3 deletions src/server/responses/core-codex-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,9 @@ export function normalizeCodexUnsupportedModelDetail(value: string): string {
* that comparison fail for the one model that is still account-gated, which silently disabled
* both the alternate-account retry and the same-account ladder built for exactly that case.
*
* The envelope is unchanged and stays exact: a top-level `detail` string, whitespace-collapsed
* and case-folded, matching the whole sentence with nothing before or after it. No prose is
* Accept the HTTP `detail` envelope and the `error.message` envelope emitted by the
* WebSocket refused-create projection. Both must match the whole sentence, whitespace-collapsed
* and case-folded, with nothing before or after it. Competing envelopes are ambiguous. No prose is
* inferred and no other 400 shape is admitted, because a 400 is also what a malformed request
* earns and that must never read as an entitlement fact.
*/
Expand All @@ -186,7 +187,18 @@ export function codexUnsupportedModelFromDetail(
try {
const payload = JSON.parse(bodyText) as unknown;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
const detail = (payload as { detail?: unknown }).detail;
const record = payload as Record<string, unknown>;
const hasDetail = Object.hasOwn(record, "detail");
const hasError = Object.hasOwn(record, "error");
if (hasDetail === hasError) return undefined;
let detail: unknown = record.detail;
if (hasError) {
const error = record.error;
if (!error || typeof error !== "object" || Array.isArray(error)) return undefined;
const fields = error as Record<string, unknown>;
if ([fields.type, fields.code].some(value => value != null && typeof value !== "string")) return undefined;
detail = fields.message;
}
if (typeof detail !== "string") return undefined;
const matched = /^the '([^']{1,256})' model is not supported when using codex with a chatgpt account\.$/u
.exec(normalizeCodexUnsupportedModelDetail(detail));
Expand Down
32 changes: 21 additions & 11 deletions src/server/responses/request-spend.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import type { RequestSendObserver } from "../../lib/request-execution-budget";
import { sharedSpendLedger, type SpendReservationLedger } from "../../lib/spend-reservation-ledger";
import { SpendLedgerOwnerError } from "../../lib/spend-ledger-owner";
import { markLocalRequestLogRefusal, type RequestLogContext } from "../request-log";
import { recordWorkflowRefusalEvent, workflowDenialSummary } from "../../lib/workflow-budget";

Expand Down Expand Up @@ -137,19 +138,28 @@ export function createRequestSpendTracker(
if (resolved) return;
resolved = true;
const terminal = live.pop();
if (terminal !== undefined) {
const reported = typeof usage?.inputTokens === "number" || typeof usage?.outputTokens === "number";
if (reported) {
ledger().settle(terminal, {
inputTokens: usage?.inputTokens ?? 0,
outputTokens: usage?.outputTokens ?? 0,
});
} else {
// The response never reported usage. It may still have been billed.
ledger().markLost(terminal);
try {
if (terminal !== undefined) {
const reported = typeof usage?.inputTokens === "number" || typeof usage?.outputTokens === "number";
if (reported) {
ledger().settle(terminal, {
inputTokens: usage?.inputTokens ?? 0,
outputTokens: usage?.outputTokens ?? 0,
});
} else {
// The response never reported usage. It may still have been billed.
ledger().markLost(terminal);
}
}
for (const sendId of live.splice(0)) ledger().markLost(sendId);
} catch (error) {
// Settlement is the last thing a request does, and a deferred one can outlive the
// ledger's ownership window: a post-cancel drain that finishes after server.stop
// released the owner finds the journal already closed, leaving the outstanding
// sends nobody to book against and no caller alive to refuse. They die with the
// discarded ledger; anything that is not an ownership lapse still propagates.
if (!(error instanceof SpendLedgerOwnerError)) throw error;
}
for (const sendId of live.splice(0)) ledger().markLost(sendId);
},
get refusals(): number { return refusals; },
};
Expand Down
5 changes: 5 additions & 0 deletions structure/providers/openai-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,11 @@ Native Spark membership and its model-specific request/tool exceptions are remov
`gpt-5.6-sol` before dispatch; comparing against the route model alone never matched for the
one model that is still account-gated, which disabled both its alternate-account retry and its
same-account ladder.
Refusal detection accepts the HTTP `detail` envelope and the WebSocket refused-create
projection's `error.message` envelope. Both require HTTP 400 and the complete model-specific
refusal sentence; malformed or competing envelopes, unrelated errors, and postcommit stream
errors authorize no replay. The same evidence feeds the bounded alternate attempt and later
automatic selection without changing a manual pin or the threshold-zero quota policy.
`getEligiblePoolAccounts` is not the only door, so `preferModelEntitledAccount` applies the same
evidence to an already-active shared cursor: the replacement is drawn from the eligible list, the
active account is returned unchanged when no entitled alternative exists, and the correction is
Expand Down
11 changes: 10 additions & 1 deletion structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,12 @@ readable user text, and records `conversationStateScrub: "account-change"` on th
without account identifiers. Once the new account issues its own state, later turns carry it
normally. `canPortConversationState` is local until `src/routing/identity-domains.ts` lands.

Precommit Codex model refusals use the same bounded account recovery for HTTP `detail` and
WebSocket-projected `error.message` bodies. Only an exact HTTP 400 refusal naming the requested
or wire model establishes denial evidence; ordinary malformed requests and committed stream
errors do not authorize another send. Account selectors, uploaded files and send budgets retain
their existing restrictions.

### Uploaded files do not move between accounts

An uploaded `file_id` has always been classified as account-bound, and the scrub has always
Expand Down Expand Up @@ -1245,7 +1251,10 @@ during this process's lifetime can still be released for free.
Settlement follows what the request learned. The terminal usage belongs to the last send that
left, so that one settles with the real figure; every earlier send failed without reporting usage
of its own and may still have been billed, so it becomes unresolved spend rather than free. A
request that reports no usage at all leaves all of them unresolved.
request that reports no usage at all leaves all of them unresolved. A settlement deferred past
the ledger's ownership window — a post-cancel drain that outlives `server.stop` and its owner
release — drops the outstanding sends with the closed journal instead of throwing, because there
is nothing durable left to book against and no caller alive to refuse.

Replay resolves what nobody is left to settle, and resolves it as unresolved spend whatever state
it was in. Giving an undispatched one its tokens back would assume the journal is complete up to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@ import {
CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS,
clearCodexUpstreamHealth,
clearThreadAccountMap,
previewCodexAccountForRequest,
resolveCodexAccountForThread,
resolveCodexAccountForThreadDetailed,
} from "../../src/codex/routing";
import { clearPoolRotationState } from "../../src/codex/pool-rotation";
import { saveCodexAccountCredential } from "../../src/codex/account-store";
import { readCodexAccountRecord, saveCodexAccountCredential } from "../../src/codex/account-store";
import { getEffectiveActiveCodexAccountId, rememberActiveCodexAccount } from "../../src/codex/routing/active-account";
import {
cachedDeniedCodexAccountIdsForModel,
recordCodexModelDenialEvidence,
resetCodexModelEntitlementCacheForTests,
} from "../../src/codex/model-entitlements";
import {
clearAccountNeedsReauth,
clearAccountQuota,
Expand Down Expand Up @@ -102,6 +109,8 @@ function installScratchState(): void {
clearCodexUpstreamHealth();
clearAccountQuota();
clearPoolRotationState();
resetCodexModelEntitlementCacheForTests();
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
clearAccountNeedsReauth("a");
clearAccountNeedsReauth("b");
saveTestCredential("a");
Expand All @@ -114,6 +123,8 @@ async function removeScratchState(): Promise<void> {
clearCodexUpstreamHealth();
clearThreadAccountMap();
clearPoolRotationState();
resetCodexModelEntitlementCacheForTests();
clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
clearAccountNeedsReauth("a");
clearAccountNeedsReauth("b");
} finally {
Expand All @@ -125,6 +136,44 @@ describe("model entitlement ordering (#4768)", () => {
beforeEach(installScratchState);
afterEach(removeScratchState);

test.each([
["observed Astra denial selects available main", true, MAIN_CODEX_ACCOUNT_ID],
["unknown Astra access preserves the exhausted automatic cursor", false, "a"],
] as const)("fill-first with threshold zero: %s", (_name, observedDenial, expectedAccountId) => {
const now = Date.now();
const modelId = "gpt-6-astra";
const config = makeConfig({
accountPoolStrategy: "fill-first",
autoSwitchThreshold: 0,
activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID,
codexAccounts: [{ id: "a", email: "a@test", isMain: false, plan: "free" }],
});
updateAccountQuota("a", undefined, undefined, 100); // Free's governing monthly window.
updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 6); // 94% remaining.
// An automatic cursor is not a manual pin, including when persisted active is main.
rememberActiveCodexAccount(config, "a");
expect(config.activeCodexAccountPinned).toBeUndefined();
expect(cachedDeniedCodexAccountIdsForModel(modelId, now)).toBeUndefined();
if (observedDenial) {
recordCodexModelDenialEvidence("a", modelId, readCodexAccountRecord("a")!.generation, now);
}
const deniedModelAccountIds = cachedDeniedCodexAccountIdsForModel(modelId, now);
if (observedDenial) expect([...(deniedModelAccountIds ?? [])]).toEqual(["a"]);
else expect(deniedModelAccountIds).toBeUndefined();
const selectionOptions = { isMainAccountTokenLive: () => true, deniedModelAccountIds };

// Preview must neither advance the automatic cursor nor manufacture a user selection.
expect(previewCodexAccountForRequest("initial-astra", config, now, "shared", selectionOptions, modelId))
.toBe(expectedAccountId);
expect(getEffectiveActiveCodexAccountId(config)).toBe("a");
expect(resolveCodexAccountForThreadDetailed(
"initial-astra", config, now, "shared", selectionOptions, modelId,
)).toMatchObject({ status: "selected", accountId: expectedAccountId });
expect(getEffectiveActiveCodexAccountId(config)).toBe(expectedAccountId);
expect(config.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID);
expect(config.activeCodexAccountPinned).toBeUndefined();
});

/** `a` is ordered above `b`; the persisted operator selection is the lower tier. */
function orderedConfig(overrides: Partial<OcxConfig> = {}): OcxConfig {
return makeConfig({
Expand Down
25 changes: 24 additions & 1 deletion tests/codex-integration/codex-model-denial-evidence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ describe("upstream refusal as per-account model denial evidence", () => {
* same-account ladder that exists specifically for it.
*/
describe("unsupported-model refusal detection", () => {
test("recognizes the exact refusal in the WebSocket HTTP error envelope", async () => {
const body = JSON.stringify({ error: {
type: "invalid_request_error",
code: "invalid_request_error",
message: `The '${ASTRA}' model is not supported when using Codex with a ChatGPT account.`,
} });
expect(codexUnsupportedModelFromDetail(400, body)).toBe(ASTRA);
expect(await shouldRetryCodexPoolAccountModel400(new Response(body, { status: 400 }), ASTRA)).toBe(true);
expect(isAllowListedCodexAccountModel400(400, body, SOL)).toBe(false);
expect(codexUnsupportedModelFromDetail(403, body)).toBeUndefined();
});

test("extracts the model upstream named", () => {
expect(codexUnsupportedModelFromDetail(400, refusalBody(SOL))).toBe(SOL);
// Case and whitespace are normalized exactly as before.
Expand All @@ -143,7 +155,18 @@ describe("unsupported-model refusal detection", () => {
}))).toBe(SOL);
});

test("admits nothing but that exact envelope", () => {
test("rejects malformed, competing, and non-refusal error envelopes", () => {
const message = `The '${ASTRA}' model is not supported when using Codex with a ChatGPT account.`;
for (const payload of [
{ error: message }, { error: [message] }, { error: null },
{ error: { message: 400 } }, { error: { message: { detail: message } } },
{ error: { message, code: 42 } }, { error: { message, type: [] } },
{ error: { message: `note: ${message}` } }, { error: { message: "Invalid tool schema" } },
{ detail: message, error: { message } }, { detail: null, error: { message } },
]) expect(codexUnsupportedModelFromDetail(400, JSON.stringify(payload))).toBeUndefined();
});

test("admits nothing but an exact refusal envelope", () => {
expect(codexUnsupportedModelFromDetail(400, JSON.stringify({ detail: "Bad request" })))
.toBeUndefined();
// Prose around the sentence is not the sentence.
Expand Down
Loading
Loading