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
13 changes: 13 additions & 0 deletions docs-site/src/content/docs/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ Aliases change the public name clients request; they do not change the combo's s
concrete provider/model selectors behind it.
:::

## Compaction after switching combos

When a client compacts using a bare model name after switching combos, opencodex can recall the
combo that most recently completed successfully on that conversation lane. The model must match
the completed response, and the combo and its target must still exist in the current configuration.
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.
Without usable conversation identity or valid remembered state, normal compaction routing applies.
A restart clears the remembered state.

## Codex Desktop native-allowlist compatibility

Some Codex Desktop releases apply a remote native-only `available_models` allowlist after the
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/ko/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ alias를 설정해도 정식 `combo/<id>` 형식은 계속 해석됩니다. 정
alias는 클라이언트가 요청하는 공개 이름만 바꿉니다. 콤보에 저장된 ID나 그 뒤의 실제 공급자/모델 선택자는 바꾸지 않습니다.
:::

## 콤보를 바꾼 뒤 대화 압축

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

명시한 공급자·콤보 선택자와 설정된 콤보 별칭이 기억한 값보다 우선합니다. 실패·미완료·취소된 응답은 마지막 성공 기록을 덮어쓰지 않습니다. 기록은 프로세스 안에서 최대 256개 대화, 30분 동안 유지하며 계정 자격증명을 저장하지 않습니다. 유효한 대화 식별자나 기록이 없으면 일반 압축 라우팅을 사용합니다. 재시작하면 기록은 사라집니다.

## 전략 선택

### 페일오버: 순서가 있는 기본값과 예비값
Expand Down
2 changes: 2 additions & 0 deletions src/lib/state-store-registrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +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 { listLiveComboTargetKeys } from "../combos/types";
import {
listLiveConfigOwnershipRoots,
Expand Down Expand Up @@ -111,6 +112,7 @@ 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: "guardian-backoff", reconcileGeneration: reconcileGuardianBackoff },
{ name: "codex-reauth", reconcileGeneration: reconcileCodexReauthState },
{ name: "oauth-reauth", reconcileGeneration: reconcileOAuthReauthState },
Expand Down
89 changes: 89 additions & 0 deletions src/server/responses/combo-session-recall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/** Process-local recall of the last completed combo response on an explicit session lane. */
import { getCombo, targetKey } from "../../combos/types";
import { captureConfigGeneration, type GenerationContext } from "../../lib/state-store-sweeper";
import type { OcxConfig, OcxComboTarget } from "../../types";

interface ComboRecallEntry {
comboId: string;
target: Pick<OcxComboTarget, "provider" | "model">;
responseModel: string;
at: number;
}

const RECALL_CAPACITY = 256;
const RECALL_TTL_MS = 30 * 60 * 1000;
const recall = new Map<string, ComboRecallEntry>();
let lastReconciledGeneration = 0;
let liveOwners: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames"> | undefined;

function ownsEntry(context: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames">, entry: ComboRecallEntry): boolean {
return context.comboIds.has(entry.comboId)
&& context.providerNames.has(entry.target.provider)
&& context.comboTargets.has(`${entry.comboId}::${targetKey(entry.target)}`);
}

export function rememberComboForLane(
lane: string | undefined,
comboId: string,
target: Pick<OcxComboTarget, "provider" | "model">,
responseModel: string,
writerGeneration: number,
): void {
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() };
if (liveOwners && !ownsEntry(liveOwners, entry)) return;
recall.delete(lane);
recall.set(lane, entry);
while (recall.size > RECALL_CAPACITY) {
const oldest = recall.keys().next().value;
if (oldest === undefined) break;
recall.delete(oldest);
}
}

export function recallComboForLane(
config: OcxConfig,
lane: string | undefined,
model: string,
): string | undefined {
if (!lane || !model || model.includes("/")) return undefined;
const entry = recall.get(lane);
if (!entry) return undefined;
const combo = getCombo(config, entry.comboId);
const provider = config.providers[entry.target.provider];
if (Date.now() - entry.at >= RECALL_TTL_MS
|| !Object.hasOwn(config.providers, entry.target.provider)
|| !provider || provider.disabled === true
|| !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) {
recall.delete(lane);
return undefined;
}
return entry.responseModel === model ? entry.comboId : undefined;
}

export function reconcileComboRecall(context: GenerationContext): number {
if (context.generation <= lastReconciledGeneration) return 0;
lastReconciledGeneration = context.generation;
liveOwners = {
comboIds: new Set(context.comboIds),
comboTargets: new Set(context.comboTargets),
providerNames: new Set(context.providerNames),
};
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;
}
}
return removed;
}

/** Test-only reset, alongside the combo rotation/cooldown resets. */
export function clearComboRecallForTests(): void {
recall.clear();
lastReconciledGeneration = 0;
liveOwners = undefined;
}
20 changes: 19 additions & 1 deletion src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
comboIdFromRawBody,
concreteComboRequestBody,
getCombo,
resolveComboId,
isComboTargetInCooldown,
NoAvailableComboTargetsError,
noteComboSuccess,
Expand Down Expand Up @@ -152,6 +153,7 @@ import {
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error";
import { sessionLaneIdFromRequest } from "../request-log-conversation";
import { recallComboForLane } from "./combo-session-recall";

export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024;

Expand Down Expand Up @@ -536,13 +538,29 @@ export async function handleResponsesCompact(
// a local rather than written back to `raw.model`: assigning to the property widens it out
// of the `string` narrowing the guard above just established.
const compactFastRow = parseFastOnlyRowId(config, () => raw.model as string);
const compactModel = compactFastRow ? compactFastRow.baseId : raw.model;
let compactModel = compactFastRow ? compactFastRow.baseId : raw.model;
if (compactFastRow) (raw as Record<string, unknown>).model = compactModel;
// The client's own selector, kept for the request log: `raw.model` is rewritten to the
// base id above, and logCtx.requestedModel is assigned from it further down, so without
// this the log would lose which id the client actually asked for.
const compactRequestedModel = compactFastRow ? compactFastRow.baseId + "--fast" : raw.model;

// Recall the last completed client-visible bare model after a combo switch (#3891).
// Configured selectors take precedence over this implicit session hint.
if (typeof compactModel === "string" && !compactModel.includes("/") && !compactFastRow
&& !resolveComboId(config, compactModel)) {
const recalledComboId = recallComboForLane(config, sessionLaneIdFromRequest(req.headers), compactModel);
if (recalledComboId) {
(raw as Record<string, unknown>).model = `combo/${recalledComboId}`;
// Keep the routed identity in sync: the bare model can 404 outright (no
// canonical openai provider) or resolve straight onto a native-compact
// provider, both bypassing combo failover. The combo selector resolves
// through tryPickComboModel, whose route.combo skips the native compact
// endpoint.
compactModel = `combo/${recalledComboId}`;
}
}

let route;
try {
// Compact requests route through the same policy evaluation as normal
Expand Down
Loading
Loading