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
1 change: 1 addition & 0 deletions docs-site/src/content/docs/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ Combo failures are divided into **hop** failures and **terminal** failures.
| HTTP 410 with an explicit model end-of-life, retired, deprecated, sunset, decommissioned, or no-longer-available signal | Cool that target and hop. Unrelated 410 responses remain terminal. |
| Classified authentication, subscription, quota, rate-limit, overload, or upstream-server error | Cool the target and hop, even when the status alone is not sufficient. |
| Client cancellation (499), `origin_rejected`, cyber-policy refusal, context overflow, or other invalid request | Stop and return the error; another target would not make the request valid. |
| Exact HTTP 400 saying a model is not supported when using Codex with a ChatGPT account | Try the next declared target before output commitment. The message must be the whole refusal in `detail`, `error.message`, or bare text; competing `detail` and `error` envelopes stay terminal. This does not quarantine the account or cool every model on the provider. |
Comment thread
lidge-jun marked this conversation as resolved.
| Structured HTTP 400 rejecting optional `user`, an unsupported reasoning effort, or model-scoped image input | Hop before output commitment without cooling; see request-local target compatibility below. |
| First tool call of a Responses turn run by an in-process adapter (`runTurn`) that the current request did not declare, before any output or replay-unsafe side effect | Cool the target and hop with the same tool catalog. After visible output or a replay-unsafe side effect the refusal is final. Chat Completions and Anthropic Messages requests are unchanged. |
| Any other unclassified error | Stop and return the error. |
Expand Down
1 change: 1 addition & 0 deletions docs-site/src/content/docs/ja/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ ocx combo set balanced \
|モデルのサポート終了、retired、deprecated、sunset、decommissioned、または利用不可を明示する HTTP 410 |そのターゲットをクールダウンし、次へ進みます。無関係な 410 はターミナル エラーのままです。 |
|機密認証、サブスクリプション、クォータ、レート制限、過負荷、またはアップストリーム サーバー エラー |ステータスだけでは物足りない場合でもターゲットを冷やしてホップさせましょう。 |
|クライアントのキャンセル (499)、`origin_rejected`、サイバー ポリシーの拒否、コンテキスト オーバーフロー、またはその他の無効なリクエスト |停止してエラーを返します。別のターゲットではリクエストは有効になりません。 |
| ChatGPT アカウントで Codex を使う際に、そのモデルをサポートしないことを示す正確な HTTP 400 の拒否 | 出力を確定する前に、次の設定済みターゲットを試します。拒否文全体が `detail`、`error.message`、または本文そのものにある場合に限ります。`detail` と `error` が競合する応答は終了扱いです。この拒否だけでアカウントを再認証待ちにしたり、プロバイダーの全モデルをクールダウンしたりしません。 |
|`user` の明示的な拒否、`reasoning.effort`/`reasoning_effort` の非対応値、またはモデル固有の画像入力拒否(`param: input`)を示す構造化 HTTP 400 |出力開始前に次の適格なターゲットへ進み、クールダウンを記録しません。任意パラメーターの互換性を参照してください。 |
|インプロセスアダプター(`runTurn`)が実行する Responses ターンで、現在のリクエストが宣言していない最初のツール呼び出し(出力やリプレイ不可の副作用より前) |ターゲットをクールダウンし、同じツールカタログで次へ進みます。可視出力やリプレイ不可の副作用の後は拒否が確定します。Chat Completions と Anthropic Messages のリクエストは変わりません。 |
|その他の未分類のエラー |停止してエラーを返します。 |
Expand Down
1 change: 1 addition & 0 deletions docs-site/src/content/docs/ko/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ ocx combo set balanced \
| 모델 수명 종료, retired, deprecated, sunset, decommissioned, 또는 더 이상 사용할 수 없다는 신호가 명시된 HTTP 410 | 해당 대상만 쿨다운으로 보내고 다음 대상으로 넘어갑니다. 관련 없는 410은 종결 오류로 유지합니다. |
| 인증, 구독, 쿼터, 속도 제한, 과부하, 또는 상위 서버 오류로 분류됨 | 상태 코드만으로는 충분하지 않더라도 대상을 쿨다운으로 보내고 넘어갑니다. |
| 클라이언트 취소(499), `origin_rejected`, cyber-policy refusal, context overflow, 또는 기타 invalid request | 멈추고 오류를 반환합니다. 다른 대상을 써도 요청이 유효해지지 않기 때문입니다. |
| ChatGPT 계정으로 Codex를 사용할 때 해당 모델을 지원하지 않는다는 정확한 HTTP 400 거부 | 출력이 시작되기 전에 다음 설정 대상으로 넘어갑니다. 거부 문장 전체가 `detail`, `error.message` 또는 일반 텍스트에 있어야 하며, `detail`과 `error`가 충돌하면 중단합니다. 이 사유만으로 계정을 재인증 상태로 바꾸거나 공급자의 모든 모델에 쿨다운을 걸지는 않습니다. |
| `user`를 명시적으로 거부하거나, `reasoning.effort`/`reasoning_effort`의 지원되지 않는 값 또는 모델별 이미지 입력 거부(`param: input`)를 나타내는 구조화된 HTTP 400 | 출력 시작 전에 쿨다운 기록 없이 다음 적격 대상으로 넘어갑니다. 선택적 매개변수 호환성을 참조하세요. |
| 인프로세스 어댑터(`runTurn`)가 실행하는 Responses 턴에서 현재 요청이 선언하지 않은 첫 도구 호출(출력이나 재전송 불가 부작용 이전) | 대상을 쿨다운하고 같은 도구 카탈로그로 다음 대상으로 넘어갑니다. 출력이 보였거나 재전송 불가 부작용이 생긴 뒤에는 거부가 그대로 확정됩니다. Chat Completions와 Anthropic Messages 요청은 바뀌지 않습니다. |
| 그 밖의 분류되지 않은 오류 | 멈추고 오류를 반환합니다. |
Expand Down
1 change: 1 addition & 0 deletions docs-site/src/content/docs/ru/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ ocx combo set balanced \
| HTTP 410 с явным признаком окончания срока службы модели, retirement, deprecated, sunset, decommissioned или недоступности | Перевести только эту цель в cooldown и перейти дальше. Несвязанные ответы 410 остаются terminal-ошибками. |
| Классифицированная ошибка аутентификации, подписки, квоты, rate-limit, перегрузки или upstream-server | Перевести цель в cooldown и переключиться, даже если одного статуса недостаточно. |
| Отмена клиентом (499), `origin_rejected`, отказ из-за cyber-policy, переполнение контекста или иной некорректный запрос | Остановиться и вернуть ошибку; другая цель не сделает такой запрос корректным. |
| Точный отказ HTTP 400 о том, что модель не поддерживается в Codex с аккаунтом ChatGPT | До начала вывода пробует следующую настроенную цель. Полная фраза отказа должна находиться в `detail`, `error.message` или обычном тексте; смешанные объекты `detail` и `error` завершают запрос. Сам этот отказ не переводит аккаунт в состояние повторного входа и не включает период ожидания для всех моделей провайдера. |
| Структурированный HTTP 400, отклоняющий необязательный `user`, неподдерживаемое значение `reasoning.effort`/`reasoning_effort` или специфичный для модели отказ входного изображения (`param: input`) | До начала вывода переходит к следующей допустимой цели без охлаждения; см. «Совместимость необязательных параметров» ниже. |
| Первый вызов инструмента в Responses-ходе внутрипроцессного адаптера (`runTurn`), который текущий запрос не объявлял, до любого вывода и до побочного эффекта, небезопасного для повтора | Охлаждает цель и переходит к следующей с тем же каталогом инструментов. После видимого вывода или небезопасного для повтора побочного эффекта отказ окончателен. Запросы Chat Completions и Anthropic Messages не меняются. |
| Любая другая неклассифицированная ошибка | Остановиться и вернуть ошибку. |
Expand Down
1 change: 1 addition & 0 deletions docs-site/src/content/docs/zh-cn/guides/combos.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。
| HTTP 410,并明确表明模型已到生命周期终点、retired、deprecated、sunset、decommissioned 或不再可用 | 仅冷却该目标并继续跳转。无关的 410 仍然是终止错误。 |
| 被分类为认证、订阅、配额、速率限制、过载或上游服务器错误 | 即使仅凭状态码不足以判断,也会使该目标进入冷却并跳转。 |
| 客户端取消(499)、`origin_rejected`、cyber-policy 拒绝、上下文溢出,或其他无效请求 | 停止并返回错误;换其他目标也无法让请求变得有效。 |
| HTTP 400 明确表示使用 ChatGPT 账户运行 Codex 时不支持该模型 | 在输出提交前尝试下一个已配置目标。完整拒绝句必须位于 `detail`、`error.message` 或纯文本中;同时包含 `detail` 与 `error` 的混合封装仍会终止请求。此原因本身不会将账户标为需要重新登录,也不会让供应商的所有模型进入冷却。 |
| 结构化 HTTP 400,明确拒绝 `user`、对 `reasoning.effort`/`reasoning_effort` 返回不支持值,或返回模型特定图像输入拒绝(`param: input`) | 在输出开始前跳转到下一个符合条件的目标,且不记录冷却时间;参见下方可选参数兼容性。 |
| 由进程内适配器(`runTurn`)执行的 Responses 回合中,当前请求未声明的第一个工具调用(在任何输出和不可重放的副作用之前) | 让该目标进入冷却,并以相同的工具目录跳转到下一个目标。出现可见输出或不可重放的副作用之后,拒绝即为最终结果。Chat Completions 和 Anthropic Messages 请求不受影响。 |
| 任何其他未分类错误 | 停止并返回错误。 |
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"compaction-event-ownership.test.ts": "responses",
"zed-retained-budget.test.ts": "providers",
"jev-decision-scope.test.ts": "server",
"server-combo-plan-model-refusal.test.ts": "server",
"codex-main-token-invalidated.test.ts": "codex-integration",
"codex-main-grant-refusal.test.ts": "codex-integration",
"responses-alternate-main-cancellation.test.ts": "responses",
Expand Down
45 changes: 44 additions & 1 deletion src/combos/failover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,10 +670,48 @@ function isDefiniteContextOverflow(status: number, message: string): boolean {
return false;
}

const CODEX_ACCOUNT_MODEL_REFUSAL = /^The '[^']{1,256}' model is not supported when using Codex with a ChatGPT account\.$/;

export type CodexAccountModelRefusal = "other" | "refusal" | "ambiguous";

/** Inspect only the original root and its own response record, before carrier selection. */
export function hasConflictingCodexModelRefusalEnvelopes(status: number, payload: unknown): boolean {
if (status !== 400 || !payload || typeof payload !== "object" || Array.isArray(payload)) return false;
const response = Object.hasOwn(payload, "response") ? (payload as Record<string, unknown>).response : undefined;
const nested = response && typeof response === "object" && !Array.isArray(response) ? response : undefined;
return (Object.hasOwn(payload, "detail") || !!nested && Object.hasOwn(nested, "detail"))
&& (Object.hasOwn(payload, "error") || !!nested && Object.hasOwn(nested, "error"));
}

/** Inspect an already parsed, bounded error frame without copying or walking its body. */
export function codexAccountModelRefusalPayload(status: number, payload: unknown): CodexAccountModelRefusal {
if (status !== 400 || !payload || typeof payload !== "object" || Array.isArray(payload)) return "other";
if (Object.hasOwn(payload, "detail") && Object.hasOwn(payload, "error")) return "ambiguous";
const { detail, error } = payload as { detail?: unknown; error?: unknown };
const field = typeof detail === "string" ? detail
: error && typeof error === "object" && !Array.isArray(error)
&& typeof (error as Record<string, unknown>).message === "string"
? (error as { message: string }).message : undefined;
return field !== undefined && CODEX_ACCOUNT_MODEL_REFUSAL.test(field) ? "refusal" : "other";
}

/** A bounded fallback signal, never credential or account-entitlement evidence. */
export function codexAccountModelRefusal(status: number, message: string): CodexAccountModelRefusal {
if (status !== 400 || message.length > 16_384) return "other";
let text = message.trim();
if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length);
if (CODEX_ACCOUNT_MODEL_REFUSAL.test(text)) return "refusal";
try {
const payload: unknown = JSON.parse(text);
return hasConflictingCodexModelRefusalEnvelopes(status, payload)
? "ambiguous" : codexAccountModelRefusalPayload(status, payload);
Comment thread
lidge-jun marked this conversation as resolved.
} catch { return "other"; }
}

export function comboFailureDecision(
status: number,
message: string,
options?: { code?: string | null },
options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal },
): ComboFailureDecision {
if (status === 499) return "stop";
if (message.toLowerCase().includes("origin_rejected")) return "stop";
Expand All @@ -688,6 +726,10 @@ export function comboFailureDecision(
// Cyber policy is a hard non-retryable refusal — honor structured code even when
// classificationText was truncated before the JSON code field.
if (isCyberPolicyCode(options?.code)) return "stop";
const modelRefusal = status === 400
? options?.codexModelRefusal ?? codexAccountModelRefusal(status, message) : "other";
// Competing envelopes cannot grant a hop through an earlier structured-code rule either.
if (modelRefusal === "ambiguous") return "stop";
// HTTP 410 is normally terminal. A model-specific lifecycle verdict is target-local,
// however: another provider/model in the declared combo can still serve the request.
// Require structured lifecycle code or explicit model+lifecycle prose so unrelated
Expand Down Expand Up @@ -738,6 +780,7 @@ export function comboFailureDecision(
if (["model_not_found", "model_unavailable", "unsupported_model"].includes(failureCode)) {
return "hop";
}
if (modelRefusal === "refusal") return "hop";
// `free_rate_limited` no longer routes through `isProviderScopedQuotaCap` (it is a
// per-request cap, not provider-wide evidence), so keep its hop verdict explicit here.
if (failureCode === "free_rate_limited") return "hop";
Expand Down
32 changes: 29 additions & 3 deletions src/server/responses/combo-stream-preflight.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { codexAccountModelRefusal, codexAccountModelRefusalPayload, hasConflictingCodexModelRefusalEnvelopes, type CodexAccountModelRefusal } from "../../combos/failover";
import type { ResponsesTerminalStatus } from "../../bridge";
import { comboFailureDecision } from "../../combos";
import { httpStatusFromTerminalError } from "../../lib/errors";
Expand Down Expand Up @@ -68,6 +69,26 @@ function bareErrorStatus(payload: unknown): number | undefined {
});
}

/** Match the selected SSE error envelope before projection drops competing fields. */
function terminalModelRefusal(status: number, event: Record<string, unknown>): CodexAccountModelRefusal {
if (hasConflictingCodexModelRefusalEnvelopes(status, event)) return "ambiguous";
const outer = codexAccountModelRefusalPayload(status, event);
if (outer === "ambiguous") return outer;
const nested = event.response;
if (event.type !== "error" && nested && typeof nested === "object" && !Array.isArray(nested)) {
const nestedKind = codexAccountModelRefusalPayload(status, nested);
if (nestedKind === "ambiguous") return nestedKind;
const nestedError = (nested as Record<string, unknown>).error;
if (nestedError && typeof nestedError === "object" && !Array.isArray(nestedError)) {
return nestedKind;
}
}
if (event.type === "error" && !Object.hasOwn(event, "error") && typeof event.message === "string") {
return codexAccountModelRefusal(status, event.message);
}
return outer;
}
Comment thread
lidge-jun marked this conversation as resolved.

function bareErrorIsRetryable(payload: unknown): boolean {
const status = bareErrorStatus(payload);
if (status === undefined || !payload || typeof payload !== "object" || Array.isArray(payload)) {
Expand All @@ -84,7 +105,9 @@ function bareErrorIsRetryable(payload: unknown): boolean {
const message = typeof error.message === "string"
? error.message
: typeof event.message === "string" ? event.message : "";
return comboFailureDecision(status, message, { code }) === "hop";
return comboFailureDecision(status, message, {
code, codexModelRefusal: terminalModelRefusal(status, event),
}) === "hop";
}

function retryableZeroOutputTerminal(payload: unknown): boolean {
Expand Down Expand Up @@ -226,7 +249,7 @@ function failedTerminalResponse(

export type ComboStreamPreflightResult =
| { kind: "accepted"; response: Response }
| { kind: "failed"; response: Response }
| { kind: "failed"; response: Response; codexModelRefusal?: CodexAccountModelRefusal }
/**
* The body errored mid-stream and `replayReadErrors` asked for the prefix back rather than
* a rethrow. `stage` is how far the inspection actually got; whether that permits a
Expand Down Expand Up @@ -322,7 +345,10 @@ export async function preflightComboStreamResponse(
|| retryableTerminalPayload?.type === "error")
&& !outputCommitted && retryableTerminalPayload) {
await reader.cancel("retrying zero-output combo stream terminal").catch(() => undefined);
return { kind: "failed", response: failedTerminalResponse(response, retryableTerminalPayload, logCtx) };
const failed = failedTerminalResponse(response, retryableTerminalPayload, logCtx);
return { kind: "failed", response: failed,
...(failed.status === 400
? { codexModelRefusal: terminalModelRefusal(failed.status, retryableTerminalPayload) } : {}) };
}
if (next.done || terminalStatus !== undefined || outputCommitted
|| bufferedBytes >= COMBO_STREAM_PREFLIGHT_MAX_BYTES
Expand Down
Loading
Loading