Skip to content
Merged
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
5 changes: 4 additions & 1 deletion docs-site/src/content/docs/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ The request then follows normal combo selection and failover.

Explicit provider/combo selectors and configured combo aliases take precedence over this recall.
Failed, incomplete, or cancelled responses do not replace the last successful selection. Recall is
process-local and bounded to 256 lanes for 30 minutes; it does not store account credentials.
process-local and bounded to 256 conversations for 30 minutes, and to 1 KiB per remembered model
name and 64 KiB in total; expired entries are also cleaned up in the background. A response whose
model name is too large to retain leaves the previous selection untouched rather than clearing it.
Recall does not store account credentials.
Without usable conversation identity or valid remembered state, normal compaction routing applies.
A restart clears the remembered state.

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ko/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ alias는 클라이언트가 요청하는 공개 이름만 바꿉니다. 콤보

클라이언트가 콤보를 바꾼 뒤 공급자 접두사 없는 모델 이름으로 압축을 요청하면, opencodex는 같은 대화에서 가장 최근에 응답을 성공적으로 마친 콤보를 기억해 사용할 수 있습니다. 모델 이름이 완료된 응답과 일치하고, 현재 설정에 해당 콤보와 대상이 남아 있어야 합니다. 압축 요청도 일반 콤보 선택과 페일오버를 따릅니다.

명시한 공급자·콤보 선택자와 설정된 콤보 별칭이 기억한 값보다 우선합니다. 실패·미완료·취소된 응답은 마지막 성공 기록을 덮어쓰지 않습니다. 기록은 프로세스 안에서 최대 256개 대화, 30분 동안 유지하며 계정 자격증명을 저장하지 않습니다. 유효한 대화 식별자나 기록이 없으면 일반 압축 라우팅을 사용합니다. 재시작하면 기록은 사라집니다.
명시한 공급자·콤보 선택자와 설정된 콤보 별칭이 기억한 값보다 우선합니다. 실패·미완료·취소된 응답은 마지막 성공 기록을 덮어쓰지 않습니다. 기록은 프로세스 안에서 최대 256개 대화, 30분 동안 유지하고, 모델 이름 하나당 1 KiB·전체 64 KiB로 제한하며, 만료된 기록은 배경에서도 정리합니다. 모델 이름이 너무 커서 보관할 수 없는 응답은 이전 선택을 지우지 않고 그대로 둡니다. 기록은 계정 자격증명을 저장하지 않습니다. 유효한 대화 식별자나 기록이 없으면 일반 압축 라우팅을 사용합니다. 재시작하면 기록은 사라집니다.

## 전략 선택

Expand Down
8 changes: 6 additions & 2 deletions src/lib/state-store-registrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from "../combos/failover";
import { reconcileComboWarningMemos } from "../combos/request";
import { reconcileComboRotationState } from "../combos/resolve";
import { reconcileComboRecall } from "../server/responses/combo-session-recall";
import { reconcileComboRecall, sweepExpiredComboRecall } from "../server/responses/combo-session-recall";
import { listLiveComboTargetKeys } from "../combos/types";
import {
listLiveConfigOwnershipRoots,
Expand Down Expand Up @@ -112,7 +112,11 @@ export const STATE_STORE_REGISTRATIONS = [
{ name: "model-cache-history", reconcileGeneration: reconcileModelCacheGeneration },
{ name: "pool-rotation", reconcileGeneration: reconcilePoolRotationState },
{ name: "combo-rotation", reconcileGeneration: reconcileComboRotationState },
{ name: "combo-session-recall", reconcileGeneration: reconcileComboRecall },
{
name: "combo-session-recall",
sweepExpired: sweepExpiredComboRecall,
reconcileGeneration: reconcileComboRecall,
},
{ name: "guardian-backoff", reconcileGeneration: reconcileGuardianBackoff },
{ name: "codex-reauth", reconcileGeneration: reconcileCodexReauthState },
{ name: "oauth-reauth", reconcileGeneration: reconcileOAuthReauthState },
Expand Down
76 changes: 68 additions & 8 deletions src/server/responses/combo-session-recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,46 @@ interface ComboRecallEntry {
target: Pick<OcxComboTarget, "provider" | "model">;
responseModel: string;
at: number;
/** UTF-8 size of `responseModel`, the only client-influenced field of unbounded length. */
bytes: number;
}

const RECALL_CAPACITY = 256;
const RECALL_TTL_MS = 30 * 60 * 1000;
/**
* A model id is provider-reported and arrives on the response, so nothing upstream of here
* bounds its length. Lane keys are already SHA-256 digests, so the model string is the only
* field that can grow, and 256 lanes alone do not bound the bytes they hold.
*/
const RECALL_MODEL_BYTES_MAX = 1024;
const RECALL_TOTAL_BYTES_MAX = 64 * 1024;
const recall = new Map<string, ComboRecallEntry>();
let recallBytes = 0;
let lastReconciledGeneration = 0;
let liveOwners: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames"> | undefined;

/** Every removal path goes through here so the byte counter can never drift from the map. */
function deleteEntry(lane: string): boolean {
const entry = recall.get(lane);
if (!entry) return false;
recall.delete(lane);
recallBytes -= entry.bytes;
return true;
}

/**
* UTF-8 size of a remembered model id, or null when it is too large to retain.
*
* The code-unit test runs first and is the part that matters: a UTF-8 encoding is never smaller
* than the code-unit count, so an oversized string is rejected without encoding it, and the
* bound cannot be defeated by paying the allocation it exists to prevent.
*/
function boundedModelBytes(responseModel: string): number | null {
if (responseModel.length > RECALL_MODEL_BYTES_MAX) return null;
const bytes = Buffer.byteLength(responseModel, "utf8");
return bytes > RECALL_MODEL_BYTES_MAX ? null : bytes;
}

function ownsEntry(context: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames">, entry: ComboRecallEntry): boolean {
return context.comboIds.has(entry.comboId)
&& context.providerNames.has(entry.target.provider)
Expand All @@ -32,14 +64,29 @@ export function rememberComboForLane(
if (!lane || !comboId || !responseModel.trim()) return;
// Reject even a same-named recreated owner: its previous in-flight turn is obsolete.
if (writerGeneration < Math.max(lastReconciledGeneration, captureConfigGeneration())) return;
const entry = { comboId, target: { provider: target.provider, model: target.model }, responseModel, at: Date.now() };
// An unretainable model id DECLINES the write; it must not clear the lane. Every other
// rejection above returns the same way, and clearing here would let a late completion erase
// a newer selection that this function has no ordering information to compare against.
const bytes = boundedModelBytes(responseModel);
if (bytes === null) return;
const entry = {
comboId,
target: { provider: target.provider, model: target.model },
responseModel,
at: Date.now(),
bytes,
};
if (liveOwners && !ownsEntry(liveOwners, entry)) return;
recall.delete(lane);
deleteEntry(lane);
recall.set(lane, entry);
while (recall.size > RECALL_CAPACITY) {
recallBytes += bytes;
// Insertion order is recency order, because every write re-inserts its lane at the back.
// Evicting from the front therefore drops the least recently written lane, never this one:
// a single entry is capped well below the aggregate budget, so it always fits.
while (recall.size > RECALL_CAPACITY || recallBytes > RECALL_TOTAL_BYTES_MAX) {
const oldest = recall.keys().next().value;
if (oldest === undefined) break;
recall.delete(oldest);
if (oldest === undefined || oldest === lane) break;
deleteEntry(oldest);
}
}

Expand All @@ -57,12 +104,25 @@ export function recallComboForLane(
|| !Object.hasOwn(config.providers, entry.target.provider)
|| !provider || provider.disabled === true
|| !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) {
recall.delete(lane);
deleteEntry(lane);
return undefined;
}
return entry.responseModel === model ? entry.comboId : undefined;
}

/**
* Periodic expiry. Without it a lane that is never read again and never touched by a config
* reconciliation holds its entry for the life of the process: the existing TTL is only
* evaluated on read or on generation change.
*/
export function sweepExpiredComboRecall(now: number): number {
let removed = 0;
for (const [lane, entry] of recall) {
if (now - entry.at >= RECALL_TTL_MS && deleteEntry(lane)) removed += 1;
}
return removed;
}

export function reconcileComboRecall(context: GenerationContext): number {
if (context.generation <= lastReconciledGeneration) return 0;
lastReconciledGeneration = context.generation;
Expand All @@ -74,8 +134,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
let removed = 0;
for (const [lane, entry] of recall) {
if (!ownsEntry(context, entry) || Date.now() - entry.at >= RECALL_TTL_MS) {
recall.delete(lane);
removed += 1;
if (deleteEntry(lane)) removed += 1;
}
}
return removed;
Expand All @@ -84,6 +143,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
/** Test-only reset, alongside the combo rotation/cooldown resets. */
export function clearComboRecallForTests(): void {
recall.clear();
recallBytes = 0;
lastReconciledGeneration = 0;
liveOwners = undefined;
}
132 changes: 126 additions & 6 deletions src/server/responses/input-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@
* catches the pathological case and stays out of the way otherwise. Every uncertainty
* resolves toward admitting.
*/
import { nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, type NativeContextLimitsInput } from "../../codex/catalog/metadata";
import {
nativeOpenAiContextWindow,
nativeOpenAiMaxInputTokens,
nativeOpenAiMaxOutputTokens,
type NativeContextLimitsInput,
} from "../../codex/catalog/metadata";
import { getModelMetadata } from "../../generated/model-metadata";
import { estimateTokens } from "../../lib/token-estimate";
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
import { modelRecordValue } from "../../reasoning-effort";
Expand Down Expand Up @@ -54,6 +60,8 @@ export interface InputAdmissionResult {
estimatedTokens: number;
/** Resolved ceiling, or null when nothing could be resolved (=> always admitted). */
ceiling: number | null;
/** Output space reserved by the combo preflight; absent on the loose direct gate. */
requiredOutputHeadroom?: number;
}

function positive(value: unknown): number | null {
Expand Down Expand Up @@ -135,14 +143,19 @@ export function estimateInputTokens(parsed: OcxParsedRequest, modelId: string):
* reject a user-defined provider that merely shares a built-in name using limits that
* belong to a different service.
*/
export function resolveInputCeiling(
interface ResolvedContextLimits {
/** The target's total context window: input and output share it. */
window: number | null;
/** Largest admissible input, which input-only caps may tighten below the window. */
ceiling: number | null;
}

function resolveContextLimits(
provider: OcxProviderConfig,
providerName: string,
modelId: string,
// Operator cap for the canonical native provider. Passed in rather than read from a
// config here so this stays pure: no filesystem, no catalog, no registry scan.
nativeContextCap?: NativeContextLimitsInput,
): number | null {
): ResolvedContextLimits {
// `modelRecordValue`, not a bare lookup: the catalog resolves these same two maps that
// way, so a `gpt-oss` entry covers `gpt-oss:120b`. Reading raw here made the gate fall
// back to the provider-wide window and refuse turns the model can plainly hold.
Expand All @@ -168,13 +181,120 @@ export function resolveInputCeiling(
: null;
const nativeMaxInput = canonicalNativeBare ? positive(nativeOpenAiMaxInputTokens(modelId, nativeLimits)) : null;

const window = canonicalNativeBare ? native : configured;
const window = canonicalNativeBare ? (native ?? generatedNativeWindow(modelId, configured, nativeContextCap)) : configured;
// modelMaxInputTokens is an input-only cap, so it can only tighten the window.
const configuredMaxInput = positive(modelRecordValue(provider.modelMaxInputTokens, modelId));
const limits = [window, configuredMaxInput, nativeMaxInput].filter((v): v is number => v !== null);
return { window, ceiling: limits.length === 0 ? null : Math.min(...limits) };
}

/**
* Generated-catalog keys, not routing provider names. `OPENAI_CODEX_PROVIDER_ID` is the string
* `"openai"` -- the canonical Codex forward route -- so using it to index the generated bundle
* would silently skip the native Codex rows and read the public API rows instead.
*/
const NATIVE_METADATA_CATALOGS = ["openai-codex", "openai"] as const;

/**
* Static in-tree metadata for a canonical native slug the narrower override and pinned-native
* tables do not carry. Falling through to null made input admission completely blind for
* exactly those models, which is how a 128k target accepted a turn it could not finish.
*
* This deliberately covers slugs that are no longer offered in the picker: a retired slug is
* still dispatchable when an operator names it explicitly in a combo target, and that is the
* configuration where the gate was inert. This is a generated bundle compiled into the binary,
* not a live catalog read, so it adds no I/O. Explicit provider and operator caps may only
* narrow the result, never widen it.
*/
function generatedNativeWindow(
modelId: string,
configured: number | null,
nativeContextCap: NativeContextLimitsInput | undefined,
): number | null {
let generated: number | null = null;
for (const catalog of NATIVE_METADATA_CATALOGS) {
generated = positive(getModelMetadata(catalog, modelId)?.contextWindow);
if (generated !== null) break;
}
if (generated === null) return null;
const cap = typeof nativeContextCap === "number"
? positive(nativeContextCap)
: positive(nativeContextCap?.cap);
return Math.min(generated, configured ?? generated, cap ?? generated);
}

export function resolveInputCeiling(
provider: OcxProviderConfig,
providerName: string,
modelId: string,
// Operator cap for the canonical native provider. Passed in rather than read from a
// config here so this stays pure: no filesystem, no catalog, no registry scan.
nativeContextCap?: NativeContextLimitsInput,
): number | null {
return resolveContextLimits(provider, providerName, modelId, nativeContextCap).ceiling;
}

/**
* Largest output the concrete target can emit. Used only to avoid reserving MORE than the
* target could ever produce when a client asks for a bigger allowance than the model has.
* Unknown stays unknown rather than inventing a capability.
*/
export function resolveOutputCeiling(
provider: OcxProviderConfig,
providerName: string,
modelId: string,
): number | null {
const configured = positive(modelRecordValue(provider.modelMaxOutputTokens, modelId))
?? positive(provider.defaultMaxOutputTokens);
Comment on lines +247 to +248

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop treating wire defaults as output ceilings

modelMaxOutputTokens and defaultMaxOutputTokens are documented wire defaults, not capability limits (structure/config.md:212-217), and adapters such as openai-chat.ts:49-52 and anthropic.ts:963-967 let an explicit request override them. Thus, for example, a 128k-window combo target with an 80k input, a configured 32k default, and an explicit 64k output request is admitted after reserving only 32k, but the adapter sends 64k and recreates the context-overflow/truncation this change is intended to prevent. Reserve the full explicit allowance unless a genuine per-model capability ceiling is obtained through the canonical catalog/derivation path.

AGENTS.md reference: src/AGENTS.md:L18-L18

Useful? React with 👍 / 👎.

const canonicalNativeBare = providerName === OPENAI_CODEX_PROVIDER_ID
&& isCanonicalOpenAiForwardProvider(provider)
&& !modelId.includes("/");
const native = canonicalNativeBare ? positive(nativeOpenAiMaxOutputTokens(modelId)) : null;
const limits = [configured, native].filter((v): v is number => v !== null);
return limits.length === 0 ? null : Math.min(...limits);
}

/**
* Combo-only admission. A fallback must be able to satisfy the caller's declared output
* allowance inside its OWN context window. Otherwise it returns 200, emits a few hundred
* tokens, and terminates on `finish_reason: length` — which the Anthropic surface renders as
* "response exceeded the output token maximum" even though the real cause was the total
* window. By then the next target cannot be tried, because output has already committed.
*
* Two budgets are checked separately so the reserve is counted exactly once. `ceiling` is an
* input-only budget once `modelMaxInputTokens` tightens it below the window, so the output
* reserve belongs against `window`, not against `ceiling`.
*
* Direct and single-target requests keep the deliberately loose 2.5x pathological-input gate.
* This stricter rule applies only to synthetic combo children, where skipping one known-small
* target is safe and the ladder continues before any upstream bytes are sent. Unknown context
* stays fail-open, and a caller that declared no output allowance is unaffected.
*/
export function checkComboTargetInputAdmission(
parsed: OcxParsedRequest,
provider: OcxProviderConfig,
providerName: string,
modelId: string,
nativeContextCap?: NativeContextLimitsInput,
): InputAdmissionResult {
const { window, ceiling } = resolveContextLimits(provider, providerName, modelId, nativeContextCap);
const requestedOutput = positive(parsed.options.maxOutputTokens);
if (window === null || ceiling === null || requestedOutput === null) {
return checkInputAdmission(parsed, provider, providerName, modelId, nativeContextCap);
}
const targetOutput = resolveOutputCeiling(provider, providerName, modelId);
const requiredOutputHeadroom = targetOutput === null
? requestedOutput
: Math.min(requestedOutput, targetOutput);
const estimatedTokens = estimateInputTokens(parsed, modelId);
return {
admitted: estimatedTokens <= ceiling && estimatedTokens + requiredOutputHeadroom <= window,
estimatedTokens,
ceiling,
requiredOutputHeadroom,
};
}

/**
* Fail-open when no ceiling is known; refuse only past `ceiling * ADMISSION_TOLERANCE`.
*
Expand Down
19 changes: 14 additions & 5 deletions src/server/responses/request-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import {
isCodexReserveHelperUnsupported,
CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE,
} from "../../codex/loopback-target";
import { checkInputAdmission } from "./input-admission";
import { checkComboTargetInputAdmission, checkInputAdmission } from "./input-admission";
import { nativeContextLimits } from "../../codex/catalog";
import { streamingContextOverflowResponse } from "./context-overflow";
import {
Expand Down Expand Up @@ -860,7 +860,12 @@ export async function prepareResponsesRequest(
// refusing the turn that shrinks the context would deadlock the client against the very
// limit this gate reports — it would be told to compact and then denied the compaction.
if (parsed._compactionRequest !== true) {
const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config));
// A combo child is the one caller that can afford a strict gate: skipping a target it
// cannot fit is safe before any upstream bytes are sent, and the ladder continues. A
// direct request has nowhere to go, so it keeps the loose pathological-input gate.
const inputAdmission = options.comboAttempt
? checkComboTargetInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config))
: checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config));
if (!inputAdmission.admitted) {
// #1524: this is a LOCAL preflight refusal, not an upstream verdict. A policy or combo
// fallback must be able to skip this candidate and try one whose context window fits,
Expand All @@ -876,9 +881,13 @@ export async function prepareResponsesRequest(
return formatErrorResponse(
413,
"input_admission_refused",
`Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window `
+ `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a `
+ `model with a larger context window.`,
inputAdmission.requiredOutputHeadroom !== undefined
? `Estimated input (~${inputAdmission.estimatedTokens} tokens) plus ${inputAdmission.requiredOutputHeadroom} `
+ `tokens of requested output headroom cannot fit the context window of ${parsed.modelId} `
+ `(${inputAdmission.ceiling} tokens).`
: `Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window `
+ `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a `
+ `model with a larger context window.`,
);
}
}
Expand Down
Loading
Loading