Skip to content
Merged
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1438,3 +1438,10 @@ the experimental feature for production work.
## Streaming line endings

The shared SSE decoder accepts LF, CRLF and standalone CR line endings, even when a delimiter spans network chunks. This allows compatible providers to stream events without requiring LF-only framing.


### Account-qualified requests and credits

Choosing an account-qualified model does not enable credit spending. Stored accounts obey **Use credits after limit** during authentication, when credentials are prepared, and before Responses HTTP or WebSocket dispatch after pacing or retry waits. A held request reports the credit policy, not an authentication failure; wait for the reset, choose another account, or explicitly enable that account’s credit spending.

Stored-account vision and web-search helpers also recheck this policy before sending, including retries. If consent changes or a limit is reached after helper selection, the helper reports the policy refusal without sending that attempt. The standalone search relay returns a reset-bound 429. Caller-owned Direct credentials retain their existing behavior.
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/ko/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,10 @@ opencodex가 managed [background service](/ko/reference/cli/lifecycle/#백그라
## 스트리밍 줄바꿈

공유 SSE 디코더는 LF, CRLF 및 단독 CR 줄바꿈을 처리하며 네트워크 청크 사이에서 구분자가 나뉘어도 동작합니다. 호환 제공자는 LF 형식으로만 스트림을 만들 필요가 없습니다.


### 계정 직접 지정과 크레딧 사용

계정이 지정된 모델을 선택해도 크레딧 사용이 자동으로 허용되지는 않습니다. 저장된 계정은 인증 및 전송용 인증정보 준비 시에도 **한도 이후 크레딧 사용** 설정을 따릅니다. 페이싱이나 재시도 대기 후 Responses HTTP 또는 WebSocket 요청을 보내기 직전에도 현재 정책을 확인합니다. 제한된 요청은 인증 오류가 아니라 크레딧 정책을 안내합니다. 한도 초기화를 기다리거나 다른 계정을 선택하고, 크레딧 사용이 필요한 경우 해당 계정에서 명시적으로 허용하세요.

저장된 계정을 사용하는 이미지 설명과 웹 검색 도우미도 재시도를 포함한 매 전송 직전에 이 정책을 확인합니다. 도우미 선택 이후 허용 설정이 바뀌거나 한도에 도달하면 해당 요청을 보내지 않고 정책 제한을 안내합니다. 독립 검색 릴레이는 초기화 시점에 맞춘 429를 반환하며, 호출자가 직접 제공한 Direct 인증정보의 기존 동작은 유지합니다.
2 changes: 2 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@
"codex-credits-settings.test.ts": "codex-integration",
"codex-credits-probes.test.ts": "codex-integration",
"codex-credits-after-limit.test.ts": "codex-integration",
"codex-pool-credit-policy.test.ts": "codex-integration",
"codex-sidecar-credit-policy.test.ts": "codex-integration",
"codex-credits-after-limit-main.test.ts": "codex-integration",
"service-desktop-startup-health.test.ts": "service",
"service-desktop-startup.test.ts": "service",
Expand Down
10 changes: 8 additions & 2 deletions src/codex/account-credit-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
import { isThirtyDayOnlyCodexPlan } from "./plan";
import {
CODEX_EXHAUSTED_USAGE_PERCENT,
TERMINAL_SHORT_WINDOW_FRESHNESS_MS,
isTerminalShortWindow,
hasSpendableCodexCredits,
resetAtToMs,
Expand Down Expand Up @@ -73,8 +74,13 @@ export function codexUsageLimitResetAt(quota: StoredAccountQuota | null, plan: u
if (!quota) return undefined;
const full: number[] = [];
if (isTerminalShortWindow(quota, now)) {
// A reset-less burst reading counts while it is fresh, so there is no instant to report.
full.push(typeof quota.shortResetAt === "number" && quota.shortResetAt > 0 ? resetAtToMs(quota.shortResetAt) : now);
// A reset-less burst reading holds until its observation goes stale: the freshness horizon
// is the deadline a retried request still meets, so report it rather than `now` (which
// collapsed the refusal's Retry-After to 1s against an account blocked for minutes).
// `isTerminalShortWindow` guarantees `shortObservedAt` is finite on this branch.
full.push(typeof quota.shortResetAt === "number" && quota.shortResetAt > 0
? resetAtToMs(quota.shortResetAt)
: (quota.shortObservedAt ?? now) + TERMINAL_SHORT_WINDOW_FRESHNESS_MS);
}
const longWindows: Array<[number | undefined, number | undefined]> = isThirtyDayOnlyCodexPlan(plan)
? [[quota.monthlyPercent, quota.monthlyResetAt]]
Expand Down
48 changes: 45 additions & 3 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { bindPoolCreditPolicy, poolCreditHoldResetAt, poolContextCreditHoldResetAt } from "./pool-credit-policy";
import { hasSpendableCodexCredits } from "./quota-types";
import { noteMainAccountActivity } from "./main-account-external-usage";
import { codexAccountPriorityFailbackEnabled } from "./account-priority";
Expand Down Expand Up @@ -525,6 +526,23 @@ export class CodexMainAccountHardLockError extends CodexAccountCooldownError {
}
}

/** A stored pool account's spending policy is independent of account selection. */
export class CodexPoolAccountCreditsOffError extends CodexAccountCooldownError {
readonly resetAt: number;

constructor(accountId: string, resetAt: number) {
super(accountId, resetAt);
this.name = "CodexPoolAccountCreditsOffError";
this.resetAt = resetAt;
this.message = "The selected Codex account reached its usage limit, and spending credits is off or no fresh spendable balance is available."
+ " Wait for the limit to reset, choose another account, or explicitly allow credits for this account in Codex Auth.";
}
}

function assertPoolAccountCredits(accountId: string, resetAt: number | undefined): void {
if (resetAt !== undefined) throw new CodexPoolAccountCreditsOffError(accountId, resetAt);
}

/** The main login may not spend credits and one of its usage windows is full (#6334). */
export class CodexMainAccountCreditsOffError extends CodexAccountCooldownError {
readonly resetAt?: number;
Expand Down Expand Up @@ -597,7 +615,7 @@ export class CodexRecoveryWithheldError extends CodexAccountCooldownError {

export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
"codexMainAccountHardLock" | "codexMainAccountHardLockThresholds" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
| "creditCodexAccountIds"
| "creditCodexAccountIds" | "codexAccounts"
>>;

interface CodexAuthMaterializationOptions {
Expand Down Expand Up @@ -725,6 +743,22 @@ export function unwrapUpstreamRetryEvidenceError(error: unknown): unknown {
* Same evidence the hard lock reads, and no plan lookup: the plan can live in the physical auth
* file, which several callers are forbidden to open, so every long window counts instead.
*/
/** Recheck live account policy at the physical send, after pacing or retry backoff. */
export function createCodexAuthDispatchGuard(
ctx: CodexAuthContext,
config: CodexAuthPolicyConfig,
modelId: string,
admission?: Pick<DataPlaneAdmission, "source">,
terminalHelper = false,
): ((headers: Headers) => void) | undefined {
const reserveGuard = createCodexReserveDispatchGuard(ctx, config, modelId, admission, terminalHelper);
if (ctx.kind !== "pool") return reserveGuard;
return headers => {
assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, config));
reserveGuard?.(headers);
};
}

function mainCreditsHoldResetAt(config: Pick<OcxConfig, "creditCodexAccountIds">): number | undefined {
if (codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)
&& hasSpendableCodexCredits(getMainPolicyQuota())) return undefined;
Expand Down Expand Up @@ -868,6 +902,7 @@ export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSele
// A credits-off refusal is a configuration policy, not a cooldown: clearing a cooldown
// cannot lift it, so its own wording (wait for the reset or allow credits) is the remedy.
|| err instanceof CodexMainAccountCreditsOffError
|| err instanceof CodexPoolAccountCreditsOffError
|| err instanceof CodexReserveUnavailableError
// A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
// one, and the quota recovery advice below would send the operator after a cooldown that
Expand Down Expand Up @@ -917,6 +952,7 @@ export class CodexThreadAffinityExpiredError extends Error {
export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
return !(cause instanceof CodexMainAccountHardLockError)
&& !(cause instanceof CodexMainAccountCreditsOffError)
&& !(cause instanceof CodexPoolAccountCreditsOffError)
&& !(cause instanceof CodexAccountValidationPendingError)
&& !(cause instanceof CodexReserveUnavailableError)
&& !(cause instanceof CodexCredentialGenerationConflictError)
Expand Down Expand Up @@ -1408,6 +1444,7 @@ export async function resolveCodexAuthContext(
// deferred credential must never become request auth through that fallback.
try {
assertCodexAccountValidationReady(accountId);
assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
} catch (cause) {
// Nothing will reach upstream, so give the trial back instead of leaving the held account
// unprobeable until the lease deadline lapses (#4701).
Expand Down Expand Up @@ -1533,7 +1570,8 @@ export async function resolveCodexAuthContext(
try {
const token = await getValidCodexToken(accountId, { signal: options.signal });
assertCodexAccountValidationReady(accountId);
return {
assertPoolAccountCredits(accountId, poolCreditHoldResetAt(policy, accountId, config));
return bindPoolCreditPolicy({
kind: "pool",
accountId,
writerGeneration,
Expand All @@ -1548,11 +1586,12 @@ export async function resolveCodexAuthContext(
...(probeQuotaScope ? { probeQuotaScope } : {}),
...(affinityDecision ? { affinityDecision } : {}),
...(transientProbe ? { transientProbe } : {}),
};
}, config, policy);
} catch (cause) {
releaseTransientProbeGrant();
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
if (cause instanceof CodexPoolAccountCreditsOffError) throw cause;
if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
markAccountNeedsReauth(accountId, writerGeneration);
}
Expand All @@ -1562,6 +1601,7 @@ export async function resolveCodexAuthContext(

export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void {
if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx));
// A context holding the probe lease was deliberately admitted through the cooldown.
if (ctx.probeLeaseId) return;
const cooldown = getCodexQuotaHealthSnapshot(ctx.accountId, ctx.quotaScope);
Expand All @@ -1577,6 +1617,7 @@ export function applyCodexAuthContextToProvider(
): OcxRuntimeProviderConfig {
if (mode !== "pool" || (ctx.kind !== "pool" && ctx.kind !== "main-pool") || provider.authMode !== "forward") return provider;
assertCodexAccountValidationReady(ctx.accountId);
if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx));
return {
...provider,
_codexAccountOverride: {
Expand Down Expand Up @@ -1620,6 +1661,7 @@ export function materializeCodexUpstreamAuth(
}
if (ctx.kind === "pool" || ctx.kind === "main-pool") {
assertCodexAccountValidationReady(ctx.accountId);
if (ctx.kind === "pool") assertPoolAccountCredits(ctx.accountId, poolContextCreditHoldResetAt(ctx, options.config));
selected.set("authorization", `Bearer ${ctx.accessToken}`);
selected.set("chatgpt-account-id", ctx.chatgptAccountId);
if (ctx.kind === "main-pool") {
Expand Down
67 changes: 67 additions & 0 deletions src/codex/pool-credit-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { OcxConfig } from "../types";
import { codexUsageLimitResetAt, isCodexAccountHeldForCredits } from "./account-credit-use";
import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
import { isSelectableCodexPoolAccount } from "./account-id";
import { getAccountQuota } from "./quota";

export type PoolCreditPolicyConfig = Readonly<Pick<OcxConfig, "creditCodexAccountIds" | "codexAccounts">>;
type PoolContext = { kind: "pool"; accountId: string };

// Keep live policy references private: they must not enter DTOs or serialized auth context.
const policies = new WeakMap<object, {
config: PoolCreditPolicyConfig;
policy: PoolCreditPolicyConfig;
}>();

/** Read cached usage only. This helper never opens or refreshes a credential. */
export function poolCreditHoldResetAt(
policy: PoolCreditPolicyConfig,
accountId: string,
catalog: PoolCreditPolicyConfig = policy,
now = Date.now(),
): number | undefined {
// Native main has an independent identity-bound quota policy. Never read its physical plan.
if (accountId === MAIN_CODEX_ACCOUNT_ID) return undefined;
const quota = getAccountQuota(accountId);
const plan = catalog.codexAccounts
?.find(account => isSelectableCodexPoolAccount(account) && account.id === accountId)?.plan;
if (!isCodexAccountHeldForCredits(policy, accountId, quota, plan, now)) return undefined;
return codexUsageLimitResetAt(quota, plan, now);
}

/** Remember policy by reference so later credential materialization rechecks current values. */
export function bindPoolCreditPolicy<T extends PoolContext>(
context: T,
config: PoolCreditPolicyConfig,
policy: PoolCreditPolicyConfig = config,
): T {
policies.set(context, { config, policy });
return context;
}

/**
* Carry the resolver's live policy onto a context rebuilt by spread copy. The WeakMap keys on
* object identity, so `{ ...ctx }` alone loses the binding and every caller that omits an
* explicit override would skip credit-hold enforcement on the copy. A source with no binding
* leaves the target unbound rather than inventing a policy.
*/
export function rebindPoolCreditPolicy<S extends PoolContext, T extends object>(source: S, target: T): T {
const bound = policies.get(source);
if (bound) policies.set(target, bound);
return target;
}

/** Explicit policy wins; omitted materialization options retain the resolver's policy. */
export function poolContextCreditHoldResetAt(
context: PoolContext,
override?: PoolCreditPolicyConfig,
now = Date.now(),
): number | undefined {
const bound = policies.get(context);
const policy = override ?? bound?.policy;
// Unbound contexts skip credit-hold enforcement entirely: callers that require
// enforcement must supply `override` (or resolve through bindPoolCreditPolicy).
if (!policy) return undefined;
const catalog = override?.codexAccounts ? override : bound?.config ?? policy;
return poolCreditHoldResetAt(policy, context.accountId, catalog, now);
}
26 changes: 26 additions & 0 deletions src/providers/openai-sidecar-credit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
CodexPoolAccountCreditsOffError,
releaseCodexAuthContextProbeLease,
unwrapUpstreamRetryEvidenceError,
type CodexAuthContext,
type CodexAuthPolicyConfig,
} from "../codex/auth-context";
import { poolContextCreditHoldResetAt } from "../codex/pool-credit-policy";

export function createOpenAiSidecarCreditGuard(
context: CodexAuthContext,
policy: CodexAuthPolicyConfig,
): (() => void) | undefined {
if (context.kind !== "pool") return undefined;
return () => {
const resetAt = poolContextCreditHoldResetAt(context, policy);
if (resetAt === undefined) return;
releaseCodexAuthContextProbeLease(context);
throw new CodexPoolAccountCreditsOffError(context.accountId, resetAt);
};
}

export function openAiSidecarCreditRefusal(error: unknown): CodexPoolAccountCreditsOffError | undefined {
const cause = unwrapUpstreamRetryEvidenceError(error);
return cause instanceof CodexPoolAccountCreditsOffError ? cause : undefined;
}
4 changes: 4 additions & 0 deletions src/providers/openai-sidecar.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolveProviderApiKey } from "./key-store";
import { createOpenAiSidecarCreditGuard } from "./openai-sidecar-credit";
import {
CodexPoolAuthenticationError,
headersForCodexAuthContext,
Expand Down Expand Up @@ -31,6 +32,7 @@ export interface OpenAiForwardSidecarCandidate {
export interface ResolvedOpenAiForwardSidecar extends OpenAiForwardSidecarCandidate {
authContext: CodexAuthContext;
headers: Headers;
beforeDispatch?: () => void;
recordOutcome?: (outcome: CodexUpstreamOutcome) => void;
/** Hand back an acquired recovery probe when no sidecar request reached upstream. */
releaseProbeLease?: () => void;
Expand Down Expand Up @@ -176,6 +178,7 @@ export async function resolveFirstUsableOpenAiSidecar(
...candidate,
authContext,
headers: selectedHeaders,
beforeDispatch: createOpenAiSidecarCreditGuard(authContext, policy),
recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(
config,
authContext.accountId,
Expand Down Expand Up @@ -226,6 +229,7 @@ export async function resolveFirstUsableOpenAiSidecar(
...candidate,
authContext,
headers: selectedHeaders,
beforeDispatch: createOpenAiSidecarCreditGuard(authContext, policy),
...(authContext.kind === "pool" || authContext.kind === "main-pool"
? {
recordOutcome: (outcome: CodexUpstreamOutcome) => recordCodexUpstreamOutcome(
Expand Down
Loading
Loading