feat(providers): add GPT-5.6 model family (#6862)

* feat(providers): add GPT-5.6 model family

* fix(chatgpt-web): resume temporary chat handoffs

* fix(codex): auto-merge discovery, filter denylist, revalidate on lifecycle

Restore live/GitHub auto-merge for Codex catalogs, drop models via
explicit denylist (GPT-5.4 family), and run scrub+live re-sync once on
first-start, app upgrade, or setup completion. Success log:
kill deprecated models complete.

* fix(codex): preserve live catalog reconciliation

Expose remote-only Codex models without dropping user custom entries, and complete lifecycle revalidation only after a successful internal sync. Keep credentialed self-fetches pinned to the active dashboard listener.

---------

Co-authored-by: backryun <backryun@daonlab.local>
This commit is contained in:
backryun
2026-07-12 15:30:47 +09:00
committed by GitHub
parent e7c0d93141
commit 67a0b99240
118 changed files with 3525 additions and 791 deletions

View File

@@ -981,7 +981,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup.
# Update these when providers release new CLI versions to avoid blocks.
CLAUDE_USER_AGENT="claude-cli/2.1.195 (external, cli)"
CLAUDE_USER_AGENT="claude-cli/2.1.207 (external, cli)"
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
@@ -990,7 +990,7 @@ CLAUDE_USER_AGENT="claude-cli/2.1.195 (external, cli)"
# stream with a misleading 400 out-of-extra-usage placeholder. Set to true to
# forward the original names verbatim (debugging only).
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
CODEX_USER_AGENT="codex-cli/0.142.0 (Windows 10.0.26200; x64)"
CODEX_USER_AGENT="codex-cli/0.144.1 (Windows 10.0.26200; x64)"
GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0"
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
@@ -1011,7 +1011,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
# Override Codex client version sent in headers independently of the
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
# CODEX_CLIENT_VERSION=0.142.0
# CODEX_CLIENT_VERSION=0.144.1
# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
# from the Codex Responses stream. These frames break the OpenAI SDK's
@@ -1898,7 +1898,7 @@ APP_LOG_TO_FILE=true
# OMNIROUTE_TRANSLATION_API_URL=
# Bearer token for the translation backend (NEVER commit a real key here).
# OMNIROUTE_TRANSLATION_API_KEY=
# Model id, e.g. gpt-4o-mini or cx/gpt-5.4-mini.
# Model id, e.g. gpt-4o-mini or cx/gpt-5.6-sol.
# OMNIROUTE_TRANSLATION_MODEL=gpt-4o-mini
# Per-request timeout in milliseconds (default 60000).
# OMNIROUTE_TRANSLATION_TIMEOUT_MS=60000

View File

@@ -0,0 +1,2 @@
- **feat(providers):** Add GPT-5.6 support across OpenAI API, Codex, and ChatGPT Web, including Codex Max/Ultra efforts, VS Code metadata, Fast-tier credit accounting, curated live discovery, the Codex 0.144.1 client identity, and correct chat routing for models that also support image generation ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun
- **chore(providers):** Align emitted Claude Code identity headers, bridge fingerprints, provider profiles, and documented defaults with claude-cli 2.1.207 ([#6862](https://github.com/diegosouzapw/OmniRoute/pull/6862)) - thanks @backryun

View File

@@ -2304,7 +2304,7 @@
},
"tests/unit/vscode-token-routes.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 76
"count": 65
}
},
"tests/unit/web-runtime-env.test.ts": {

View File

@@ -480,7 +480,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`]
| 变量 | 默认值 | 何时更新 |
| --- | --- | --- |
| `CLAUDE_USER_AGENT` | `claude-cli/2.1.195 (external, cli)` | Anthropic 发布新的 CLI 版本时 |
| `CLAUDE_USER_AGENT` | `claude-cli/2.1.207 (external, cli)` | Anthropic 发布新的 CLI 版本时 |
| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | 设为 `1`/`true` 可将第三方测试工具的工具名称原封不动地转发到 Anthropic 的两条绑定路径上(原生 OAuth 和 CLIProxyAPI。默认情况下 executor 会将非 Claude Code 的工具名称确定性别名化Claude Code 存在规范映射的用规范映射,否则用 PascalCase并通过 `_toolNameMap` 在响应中还原,从而确保带 snake_case 工具的测试工具不会被视为指纹化第三方客户端而被拒绝。仅供调试。 |
| `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | OpenAI 更新 Codex CLI 时 |
| `CODEX_CLIENT_VERSION` | `0.131.0` | 独立于完整 UA 字符串覆盖 Codex 客户端版本 |
@@ -1112,4 +1112,4 @@ CLI_COMPAT_ALL=1
| 变量 | 默认值 | 源文件 | 说明 |
| --- | --- | --- | --- |
| `OMNIROUTE_EVAL_CREDENTIALS` | `{}`(空) | `scripts/compression-eval/index.ts` | 运维人员提供的 JSON 凭证,供离线压缩评估 CLI 使用的服务商使用(通过 `JSON.parse` 解析)。未设置时进行试运行。 |
| `OMNIROUTE_EVAL_CREDENTIALS` | `{}`(空) | `scripts/compression-eval/index.ts` | 运维人员提供的 JSON 凭证,供离线压缩评估 CLI 使用的服务商使用(通过 `JSON.parse` 解析)。未设置时进行试运行。 |

View File

@@ -130,7 +130,7 @@ request (see `open-sse/services/claudeCodeCompatible.ts`):
| Header | Value |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `Authorization` | `Bearer <api-key>` |
| `User-Agent` | `claude-cli/2.1.195 (external, sdk-cli)` |
| `User-Agent` | `claude-cli/2.1.207 (external, sdk-cli)` |
| `anthropic-version` | `2023-06-01` |
| `anthropic-beta` | `claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24` |
| Per-connection redact-thinking beta toggle | Adds `redact-thinking-2026-02-12` for upstreams that specifically require redacted thinking streams |

View File

@@ -512,7 +512,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`]
| Variable | Default Value | When to Update |
| -------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `CLAUDE_USER_AGENT` | `claude-cli/2.1.195 (external, cli)` | When Anthropic releases a new CLI version |
| `CLAUDE_USER_AGENT` | `claude-cli/2.1.207 (external, cli)` | When Anthropic releases a new CLI version |
| `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. |
| `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI |
| `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string |

View File

@@ -88,7 +88,7 @@ Applied to: `system` blocks, all `messages[].content`, and `tools[].description`
For third-party Anthropic relays that only accept "real Claude Code" traffic:
- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.195 (external, sdk-cli)"`
- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.207 (external, sdk-cli)"`
- `CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0"`
- `CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0"`
- `anthropic-beta = "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24"` by default
@@ -214,7 +214,7 @@ All MITM endpoints require management auth (`requireCliToolsAuth`). The sudo pas
| Variable | Default |
| ------------------------ | --------------------------------------------------------------- |
| `CLAUDE_USER_AGENT` | `claude-cli/2.1.195 (external, cli)` |
| `CLAUDE_USER_AGENT` | `claude-cli/2.1.207 (external, cli)` |
| `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` |
| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` |
| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0` |

View File

@@ -121,7 +121,7 @@ export function normalizeAnthropicHeaderVariants(headers: Record<string, string>
}
}
export const CLAUDE_CLI_VERSION = "2.1.195";
export const CLAUDE_CLI_VERSION = "2.1.207";
export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`;
export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.94.0";
export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = "v24.3.0";

View File

@@ -1,4 +1,4 @@
const DEFAULT_CODEX_CLIENT_VERSION = "0.144.0";
const DEFAULT_CODEX_CLIENT_VERSION = "0.144.1";
const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200";
const DEFAULT_CODEX_USER_AGENT_ARCH = "x64";
const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION";

View File

@@ -150,7 +150,7 @@ export const GLMT_REQUEST_DEFAULTS = Object.freeze({
});
export const GLM_COUNT_TOKENS_TIMEOUT_MS = 3_000;
export const GLM_CLAUDE_CODE_USER_AGENT = "claude-cli/2.1.195 (external, sdk-cli)";
export const GLM_CLAUDE_CODE_USER_AGENT = "claude-cli/2.1.207 (external, sdk-cli)";
export const GLM_ANTHROPIC_BETA = [
"claude-code-20250219",
"interleaved-thinking-2025-05-14",

View File

@@ -149,7 +149,11 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
authType: "oauth",
authHeader: "bearer",
format: "codex-responses",
models: [{ id: "gpt-5.5", name: "GPT 5.5 (Codex Image)" }],
models: [
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol (Codex Image)" },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra (Codex Image)" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna (Codex Image)" },
],
supportedSizes: ["1024x1024", "1024x1536", "1536x1024"],
},
@@ -160,7 +164,7 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
authType: "apikey",
authHeader: "cookie",
format: "chatgpt-web",
models: [{ id: "gpt-5.3-instant", name: "GPT-5.3 Instant (ChatGPT Web Image)" }],
models: [{ id: "gpt-5.5", name: "GPT-5.5 Instant (ChatGPT Web Image)" }],
supportedSizes: ["1024x1024", "1024x1536", "1536x1024"],
},

View File

@@ -17,7 +17,6 @@ import {
ALIBABA_DASHSCOPE_MODELS,
GPT_5_5_CONTEXT_LENGTH,
GPT_5_5_CODEX_CAPABILITIES,
GPT_5_4_CODEX_CAPABILITIES,
CHAT_OPENAI_COMPAT_MODELS,
mapStainlessOs,
mapStainlessArch,

View File

@@ -9,15 +9,12 @@ export const chatgpt_webProvider: RegistryEntry = {
authType: "apikey",
authHeader: "cookie",
models: [
{ id: "gpt-5.5-pro", name: "GPT-5.5 Pro" }, // pro tier only, standard effort
{ id: "gpt-5.6-pro", name: "GPT-5.6 Pro" }, // pro tier only, standard effort
{ id: "gpt-5.6-thinking", name: "GPT-5.6 Thinking" }, // plus, pro tier
{ id: "gpt-5.5-pro-extended", name: "GPT-5.5 Pro Extended" }, // pro tier only, extended effort
{ id: "gpt-5.5-pro", name: "GPT-5.5 Pro" }, // pro tier only, standard effort
{ id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking" }, // plus, pro tier
{ id: "gpt-5.5", name: "GPT-5.5 Instant" }, // free, plus, pro tier
{ id: "gpt-5.4-pro", name: "GPT-5.4 Pro" }, // pro tier only
{ id: "gpt-5.4-thinking", name: "GPT-5.4 Thinking" }, // plus, pro tier
{ id: "gpt-5.4-thinking-mini", name: "GPT-5.4 Thinking Mini" }, // free-login only
{ id: "gpt-5.3", name: "GPT-5.3 Instant" }, // free, free-login, plus, pro tier
{ id: "gpt-5.3-mini", name: "GPT-5.3 Mini" }, // limit fallback
{ id: "o3", name: "o3" }, // plus ~ tier
],
};

View File

@@ -1,7 +1,7 @@
import type { RegistryEntry } from "../../shared.ts";
import {
GPT_5_6_CODEX_CAPABILITIES,
GPT_5_5_CODEX_CAPABILITIES,
GPT_5_4_CODEX_CAPABILITIES,
getCodexDefaultHeaders,
resolvePublicCred,
} from "../../shared.ts";
@@ -24,6 +24,106 @@ export const codexProvider: RegistryEntry = {
tokenUrl: "https://auth.openai.com/oauth/token",
},
models: [
{
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-sol-ultra",
name: "GPT 5.6 Sol (Ultra)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-sol-max",
name: "GPT 5.6 Sol (Max)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-sol-xhigh",
name: "GPT 5.6 Sol (xHigh)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-sol-high",
name: "GPT 5.6 Sol (High)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-sol-medium",
name: "GPT 5.6 Sol (Medium)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-sol-low",
name: "GPT 5.6 Sol (Low)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-terra",
name: "GPT 5.6 Terra",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-terra-ultra",
name: "GPT 5.6 Terra (Ultra)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-terra-max",
name: "GPT 5.6 Terra (Max)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-terra-xhigh",
name: "GPT 5.6 Terra (xHigh)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-terra-high",
name: "GPT 5.6 Terra (High)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-terra-medium",
name: "GPT 5.6 Terra (Medium)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-terra-low",
name: "GPT 5.6 Terra (Low)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-luna",
name: "GPT 5.6 Luna",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-luna-max",
name: "GPT 5.6 Luna (Max)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-luna-xhigh",
name: "GPT 5.6 Luna (xHigh)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-luna-high",
name: "GPT 5.6 Luna (High)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-luna-medium",
name: "GPT 5.6 Luna (Medium)",
...GPT_5_6_CODEX_CAPABILITIES,
},
{
id: "gpt-5.6-luna-low",
name: "GPT 5.6 Luna (Low)",
...GPT_5_6_CODEX_CAPABILITIES,
},
// gpt-5.5 codex OAuth backend caps context at 400K (not the public-API
// 1.05M). Public refs : openai/codex#19208, #19319, #19464 ;
// opencode#24171. max_output_tokens is stripped server-side
@@ -77,39 +177,6 @@ export const codexProvider: RegistryEntry = {
maxInputTokens: 272000,
maxOutputTokens: 128000,
},
{
id: "gpt-5.4",
name: "GPT 5.4",
...GPT_5_4_CODEX_CAPABILITIES,
},
{
id: "gpt-5.4-xhigh",
name: "GPT 5.4 (xHigh)",
...GPT_5_4_CODEX_CAPABILITIES,
},
{
id: "gpt-5.4-high",
name: "GPT 5.4 (High)",
...GPT_5_4_CODEX_CAPABILITIES,
},
{
id: "gpt-5.4-medium",
name: "GPT 5.4 (Medium)",
...GPT_5_4_CODEX_CAPABILITIES,
},
{
id: "gpt-5.4-low",
name: "GPT 5.4 (Low)",
...GPT_5_4_CODEX_CAPABILITIES,
},
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses" },
{ id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" },
{
id: "gpt-5.3-codex",
name: "GPT 5.3 Codex",
targetFormat: "openai-responses",
supportsReasoning: true,
supportsXHighEffort: true,
},
],
};

View File

@@ -1,5 +1,5 @@
import type { RegistryEntry } from "../../shared.ts";
import { REASONING_UNSUPPORTED } from "../../shared.ts";
import { GPT_5_6_API_CAPABILITIES, REASONING_UNSUPPORTED } from "../../shared.ts";
export const openaiProvider: RegistryEntry = {
id: "openai",
@@ -11,6 +11,10 @@ export const openaiProvider: RegistryEntry = {
authHeader: "bearer",
defaultContextLength: 128000,
models: [
{ id: "gpt-5.6", name: "GPT-5.6", ...GPT_5_6_API_CAPABILITIES },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", ...GPT_5_6_API_CAPABILITIES },
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", ...GPT_5_6_API_CAPABILITIES },
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", ...GPT_5_6_API_CAPABILITIES },
{ id: "gpt-5.5", name: "GPT-5.5", contextLength: 1050000 },
// #5842: *-pro reasoning models are responses-only upstream — /v1/chat/completions
// 404s ("only supported in v1/responses"). targetFormat routes them natively.
@@ -37,7 +41,17 @@ export const openaiProvider: RegistryEntry = {
{ id: "gpt-4o", name: "GPT-4o", contextLength: 128000 },
{ id: "gpt-4o-mini", name: "GPT-4o Mini", contextLength: 128000 },
{ id: "o3", name: "O3", contextLength: 200000, unsupportedParams: REASONING_UNSUPPORTED },
{ id: "o3-mini", name: "O3 Mini", contextLength: 200000, unsupportedParams: REASONING_UNSUPPORTED },
{ id: "o4-mini", name: "O4 Mini", contextLength: 200000, unsupportedParams: REASONING_UNSUPPORTED },
{
id: "o3-mini",
name: "O3 Mini",
contextLength: 200000,
unsupportedParams: REASONING_UNSUPPORTED,
},
{
id: "o4-mini",
name: "O4 Mini",
contextLength: 200000,
unsupportedParams: REASONING_UNSUPPORTED,
},
],
};

View File

@@ -284,13 +284,27 @@ export const GPT_5_5_CODEX_CAPABILITIES = {
contextLength: GPT_5_5_CONTEXT_LENGTH,
} as const;
export const GPT_5_4_CODEX_CAPABILITIES = {
// Public OpenAI API limits. These differ from the Codex OAuth catalog limits below.
export const GPT_5_6_API_CAPABILITIES = {
toolCalling: true,
supportsReasoning: true,
supportsVision: true,
supportsXHighEffort: true,
contextLength: 1050000,
maxInputTokens: 922000,
maxOutputTokens: 128000,
} as const;
// Codex's live catalog reports a 372K usable input budget for GPT-5.6.
// Keep the reserved 128K output budget explicit, matching the GPT-5.5 catalog contract.
export const GPT_5_6_CODEX_CAPABILITIES = {
targetFormat: "openai-responses",
toolCalling: true,
supportsReasoning: true,
supportsVision: true,
supportsXHighEffort: true,
contextLength: 200000,
contextLength: 500000,
maxInputTokens: 372000,
maxOutputTokens: 128000,
} as const;

View File

@@ -34,6 +34,7 @@ import {
} from "../services/chatgptImageCache.ts";
import { isThinkingCapableModel, resolveChatGptModel } from "./chatgpt-web/models.ts";
import { cleanChatGptText } from "./chatgpt-web/citations.ts";
import { resumeChatGptHandoff, type FinalAssistantAnswer } from "./chatgpt-web/handoff.ts";
// ─── Constants ──────────────────────────────────────────────────────────────
@@ -84,8 +85,7 @@ function deviceIdFor(cookie: string): string {
// OmniRoute model ID → ChatGPT internal slug. The public ChatGPT Web catalog
// keeps OmniRoute's historical dot-form IDs (e.g. "gpt-5.5-pro"), while
// ChatGPT's backend routes use dash-form slugs (e.g. "gpt-5-5-pro"). The slug
// catalog comes from /backend-api/models on a logged-in account;
// "gpt-5-4-t-mini" is ChatGPT's abbreviated slug for "GPT-5.4 Thinking Mini".
// catalog comes from /backend-api/models on a logged-in account.
// ─── Browser-like default headers ──────────────────────────────────────────
@@ -1069,6 +1069,7 @@ interface ChatGptStreamEvent {
conversation_id?: string;
error?: string | { message?: string; code?: string };
type?: string;
token?: string;
v?: unknown;
}
@@ -1180,6 +1181,8 @@ interface ContentChunk {
imageGenAsync?: boolean;
/** True when ChatGPT handed the turn off to a long-running worker. */
handoff?: boolean;
/** Short-lived conduit token used to resume a Temporary Chat handoff. */
resumeToken?: string;
}
interface ImagePointerRef {
@@ -1238,6 +1241,7 @@ async function* extractContent(
// WebSocket / polling — caller handles that.
let imageGenAsync = false;
let handoff = false;
let resumeToken: string | null = null;
for await (const event of readChatGptSseEvents(eventStream, signal)) {
if (event.error) {
@@ -1251,11 +1255,17 @@ async function* extractContent(
if (event.conversation_id) conversationId = event.conversation_id;
if (event.type === "resume_conversation_token") {
if (typeof event.token === "string" && event.token) resumeToken = event.token;
continue;
}
if (event.type === "stream_handoff") {
handoff = true;
yield {
conversationId: conversationId ?? undefined,
handoff: true,
resumeToken: resumeToken ?? undefined,
};
continue;
}
@@ -1273,8 +1283,7 @@ async function* extractContent(
// on a tool-role message (handled below).
if (event.type === "server_ste_metadata") {
const meta = (event as Record<string, unknown>).metadata as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
if (meta && meta.turn_use_case === "image gen") {
imageGenAsync = true;
}
@@ -1373,6 +1382,7 @@ async function* extractContent(
imagePointers: imagePointers.size > 0 ? Array.from(imagePointers.values()) : undefined,
imageGenAsync,
handoff,
resumeToken: resumeToken ?? undefined,
done: true,
};
}
@@ -1398,13 +1408,6 @@ interface ChatGptConversationDetail {
mapping?: Record<string, { message?: ChatGptDetailMessage | null }>;
}
interface FinalAssistantAnswer {
text: string;
messageId?: string;
metadata?: Record<string, unknown>;
finished: boolean;
}
function textFromContentPart(part: unknown): string {
if (typeof part === "string") return part;
if (!part || typeof part !== "object") return "";
@@ -1655,10 +1658,11 @@ function buildStreamingResponse(
// stream finishes without an image_asset_pointer. The executor passes a
// closure here that knows how to poll the conversation endpoint.
pollAsyncImage: ((conversationId: string) => Promise<ImagePointerRef[]>) | null,
// Optional poller for GPT-5.5 Pro's stream_handoff path. Inline text keeps
// streaming as-is; once ChatGPT hands off, we append the final assistant
// answer fetched from the conversation detail endpoint. Text requests stay
// in Temporary Chat, so these polls should not create sidebar/history items.
// Native Temporary Chat handoff continuation. ChatGPT provides a short-lived
// conduit token, which resumes the turn without saving it to chat history.
resumeFinalAnswer:
((conversationId: string, resumeToken: string) => Promise<FinalAssistantAnswer | null>) | null,
// Legacy fallback for handoffs that omit the conduit token.
pollFinalAnswer: ((conversationId: string) => Promise<FinalAssistantAnswer | null>) | null,
log: { warn?: (tag: string, msg: string) => void } | null,
signal?: AbortSignal | null
@@ -1688,6 +1692,7 @@ function buildStreamingResponse(
let imagePointers: ImagePointerRef[] | undefined;
let imageGenAsync = false;
let handoff = false;
let resumeToken: string | null = null;
let emittedText = "";
let polledFinalAnswer: FinalAssistantAnswer | null = null;
let parentCandidateMessageId: string | null = null;
@@ -1774,6 +1779,7 @@ function buildStreamingResponse(
if (chunk.conversationId) conversationId = chunk.conversationId;
if (chunk.messageId) parentCandidateMessageId = chunk.messageId;
if (chunk.handoff) handoff = true;
if (chunk.resumeToken) resumeToken = chunk.resumeToken;
if (chunk.error) {
controller.enqueue(
encoder.encode(
@@ -1801,6 +1807,7 @@ function buildStreamingResponse(
imagePointers = chunk.imagePointers;
imageGenAsync = chunk.imageGenAsync ?? false;
handoff = handoff || (chunk.handoff ?? false);
if (chunk.resumeToken) resumeToken = chunk.resumeToken;
if (chunk.messageId) parentCandidateMessageId = chunk.messageId;
break;
}
@@ -1810,7 +1817,20 @@ function buildStreamingResponse(
}
}
if (pollFinalAnswer && conversationId && handoff) {
if (resumeFinalAnswer && conversationId && handoff && resumeToken) {
const stopHb = startHeartbeat();
try {
const resumed = await resumeFinalAnswer(conversationId, resumeToken);
if (resumed?.text) {
polledFinalAnswer = resumed;
if (resumed.messageId) parentCandidateMessageId = resumed.messageId;
}
} finally {
stopHb();
}
}
if (!polledFinalAnswer && pollFinalAnswer && conversationId && handoff) {
const stopHb = startHeartbeat();
try {
const polled = await pollFinalAnswer(conversationId);
@@ -1991,6 +2011,8 @@ async function buildNonStreamingResponse(
currentMsg: string,
resolver: ImageResolver | null,
pollAsyncImage: ((conversationId: string) => Promise<ImagePointerRef[]>) | null,
resumeFinalAnswer:
((conversationId: string, resumeToken: string) => Promise<FinalAssistantAnswer | null>) | null,
pollFinalAnswer: ((conversationId: string) => Promise<FinalAssistantAnswer | null>) | null,
log: { warn?: (tag: string, msg: string) => void } | null,
signal?: AbortSignal | null
@@ -2000,6 +2022,7 @@ async function buildNonStreamingResponse(
let imagePointers: ImagePointerRef[] | undefined;
let imageGenAsync = false;
let handoff = false;
let resumeToken: string | null = null;
let answerMetadata: Record<string, unknown> | undefined;
let parentCandidateMessageId: string | null = null;
@@ -2007,6 +2030,7 @@ async function buildNonStreamingResponse(
if (chunk.conversationId) conversationId = chunk.conversationId;
if (chunk.messageId) parentCandidateMessageId = chunk.messageId;
if (chunk.handoff) handoff = true;
if (chunk.resumeToken) resumeToken = chunk.resumeToken;
if (chunk.error) {
return new Response(
JSON.stringify({
@@ -2021,6 +2045,7 @@ async function buildNonStreamingResponse(
imagePointers = chunk.imagePointers;
imageGenAsync = chunk.imageGenAsync ?? false;
handoff = handoff || (chunk.handoff ?? false);
if (chunk.resumeToken) resumeToken = chunk.resumeToken;
if (chunk.messageId) parentCandidateMessageId = chunk.messageId;
break;
}
@@ -2030,7 +2055,22 @@ async function buildNonStreamingResponse(
}
}
if (pollFinalAnswer && conversationId && (handoff || !fullAnswer.trim())) {
let resumedAnswer: FinalAssistantAnswer | null = null;
if (resumeFinalAnswer && conversationId && handoff && resumeToken) {
resumedAnswer = await resumeFinalAnswer(conversationId, resumeToken);
if (resumedAnswer?.text) {
fullAnswer = resumedAnswer.text;
answerMetadata = resumedAnswer.metadata ?? answerMetadata;
if (resumedAnswer.messageId) parentCandidateMessageId = resumedAnswer.messageId;
}
}
if (
!resumedAnswer?.text &&
pollFinalAnswer &&
conversationId &&
(handoff || !fullAnswer.trim())
) {
const polled = await pollFinalAnswer(conversationId);
if (polled?.text) {
fullAnswer = polled.text;
@@ -2718,8 +2758,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
clientHeaders,
}: ExecuteInput) {
const messages = (body as Record<string, unknown> | null)?.messages as
| Array<Record<string, unknown>>
| undefined;
Array<Record<string, unknown>> | undefined;
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return {
response: errorResponse(400, "Missing or empty messages array"),
@@ -2819,6 +2858,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
// browser does on page load. Failures here are non-fatal; the worst case
// is Sentinel still escalates to Turnstile.
const sessionId = randomUUID();
const turnTraceId = randomUUID();
const deviceId = deviceIdFor(cookie);
await runSessionWarmup(
tokenEntry.accessToken,
@@ -2963,6 +3003,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
Accept: "text/event-stream",
Authorization: `Bearer ${tokenEntry.accessToken}`,
Cookie: buildSessionCookieHeader(cookie),
"x-oai-turn-trace-id": turnTraceId,
};
if (tokenEntry.accountId) headers["chatgpt-account-id"] = tokenEntry.accountId;
if (reqs.token) headers["openai-sentinel-chat-requirements-token"] = reqs.token;
@@ -3055,6 +3096,16 @@ export class ChatGptWebExecutor extends BaseExecutor {
const imageResolver = makeImageResolver(resolverCtx);
const pollAsyncImage = (conversationId: string) =>
pollForAsyncImage(conversationId, resolverCtx);
const resumeFinalAnswer = (conversationId: string, resumeToken: string) =>
resumeChatGptHandoff({
conversationId,
resumeToken,
headers,
timeoutMs: configuredProPollTimeoutMs(),
signal,
log,
readContent: extractContent,
});
const pollFinalAnswer = resolvedModel.isPro
? (conversationId: string) => pollForFinalAssistantAnswer(conversationId, resolverCtx)
: null;
@@ -3071,6 +3122,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
created,
imageResolver,
pollAsyncImage,
resumeFinalAnswer,
pollFinalAnswer,
log,
signal
@@ -3092,6 +3144,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
parsed.currentMsg,
imageResolver,
pollAsyncImage,
resumeFinalAnswer,
pollFinalAnswer,
log,
signal

View File

@@ -0,0 +1,154 @@
import { tlsFetchChatGpt } from "../../services/chatgptTlsClient.ts";
const CONVERSATION_RESUME_URL = "https://chatgpt.com/backend-api/f/conversation/resume";
const RESUME_OFFSETS = [0, 1, 2] as const;
export interface FinalAssistantAnswer {
text: string;
messageId?: string;
metadata?: Record<string, unknown>;
finished: boolean;
}
interface HandoffContentChunk {
answer?: string;
messageId?: string;
metadata?: Record<string, unknown>;
error?: string;
}
type HandoffContentReader = (
eventStream: ReadableStream<Uint8Array>,
signal?: AbortSignal | null
) => AsyncIterable<HandoffContentChunk>;
interface ResumeHandoffOptions {
conversationId: string;
resumeToken: string;
headers: Record<string, string>;
timeoutMs: number;
signal?: AbortSignal | null;
log?: { warn?: (tag: string, message: string) => void } | null;
readContent: HandoffContentReader;
}
interface ResumeAttemptOptions extends Pick<
ResumeHandoffOptions,
"conversationId" | "timeoutMs" | "signal" | "log" | "readContent"
> {
offset: (typeof RESUME_OFFSETS)[number];
resumeHeaders: Record<string, string>;
}
interface ResumeAttemptResult {
answer: FinalAssistantAnswer | null;
shouldRetry: boolean;
}
function stringToStream(text: string): ReadableStream<Uint8Array> {
const bytes = new TextEncoder().encode(text);
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
async function readFinalAssistantAnswer(
eventStream: ReadableStream<Uint8Array>,
signal: AbortSignal | null | undefined,
readContent: HandoffContentReader
): Promise<FinalAssistantAnswer | null> {
let text = "";
let messageId: string | undefined;
let metadata: Record<string, unknown> | undefined;
for await (const chunk of readContent(eventStream, signal)) {
if (chunk.error) return null;
if (chunk.answer) text = chunk.answer;
if (chunk.messageId) messageId = chunk.messageId;
if (chunk.metadata) metadata = chunk.metadata;
}
if (!text.trim()) return null;
return { text, messageId, metadata, finished: true };
}
async function attemptResumeOffset({
conversationId,
offset,
resumeHeaders,
timeoutMs,
signal,
log,
readContent,
}: ResumeAttemptOptions): Promise<ResumeAttemptResult> {
try {
const response = await tlsFetchChatGpt(CONVERSATION_RESUME_URL, {
method: "POST",
headers: resumeHeaders,
body: JSON.stringify({ conversation_id: conversationId, offset }),
timeoutMs,
signal,
stream: true,
});
if (response.status === 404) return { answer: null, shouldRetry: true };
if (response.status >= 400) {
log?.warn?.(
"CGPT-WEB",
`conversation resume ${response.status}: ${(response.text || "").slice(0, 300)}`
);
return { answer: null, shouldRetry: false };
}
const eventStream = response.body ?? (response.text ? stringToStream(response.text) : null);
if (!eventStream) return { answer: null, shouldRetry: true };
const answer = await readFinalAssistantAnswer(eventStream, signal, readContent);
return { answer, shouldRetry: !answer };
} catch (error) {
log?.warn?.(
"CGPT-WEB",
`conversation resume failed: ${error instanceof Error ? error.message : String(error)}`
);
return { answer: null, shouldRetry: false };
}
}
export async function resumeChatGptHandoff({
conversationId,
resumeToken,
headers,
timeoutMs,
signal,
log,
readContent,
}: ResumeHandoffOptions): Promise<FinalAssistantAnswer | null> {
const resumeHeaders = {
...headers,
Accept: "text/event-stream",
"Content-Type": "application/json",
"x-conduit-token": resumeToken,
"X-OpenAI-Target-Path": "/backend-api/f/conversation/resume",
"X-OpenAI-Target-Route": "/backend-api/f/conversation/resume",
};
for (const offset of RESUME_OFFSETS) {
const attempt = await attemptResumeOffset({
conversationId,
resumeHeaders,
offset,
timeoutMs,
signal,
log,
readContent,
});
if (attempt.answer) return attempt.answer;
if (!attempt.shouldRetry) return null;
}
log?.warn?.("CGPT-WEB", `conversation resume returned no assistant text for ${conversationId}`);
return null;
}

View File

@@ -3,24 +3,22 @@
export const MODEL_MAP: Record<string, string> = {
// ChatGPT backend slugs are also accepted directly for power users / tests.
"gpt-5-6-pro": "gpt-5-6-pro",
"gpt-5-6-thinking": "gpt-5-6-thinking",
"gpt-5-5-pro": "gpt-5-5-pro",
"gpt-5-5-pro-extended": "gpt-5-5-pro",
"gpt-5-5-thinking": "gpt-5-5-thinking",
"gpt-5-5": "gpt-5-5",
"gpt-5-4-pro": "gpt-5-4-pro",
"gpt-5-4-thinking": "gpt-5-4-thinking",
"gpt-5-4-t-mini": "gpt-5-4-t-mini",
"gpt-5-3": "gpt-5-3",
"gpt-5-3-mini": "gpt-5-3-mini",
// Public OmniRoute dot-form ids exposed by the provider catalog.
"gpt-5.6-pro": "gpt-5-6-pro",
"gpt-5.6-thinking": "gpt-5-6-thinking",
"gpt-5.5-pro": "gpt-5-5-pro",
"gpt-5.5-pro-extended": "gpt-5-5-pro",
"gpt-5.5-thinking": "gpt-5-5-thinking",
"gpt-5.5": "gpt-5-5",
"gpt-5.4-pro": "gpt-5-4-pro",
"gpt-5.4-thinking": "gpt-5-4-thinking",
"gpt-5.4-thinking-mini": "gpt-5-4-t-mini",
"gpt-5.3-instant": "gpt-5-3-instant",
"gpt-5.3": "gpt-5-3",
"gpt-5.3-mini": "gpt-5-3-mini",
@@ -28,6 +26,8 @@ export const MODEL_MAP: Record<string, string> = {
};
export const MODEL_FORCED_EFFORT: Record<string, "standard" | "extended"> = {
"gpt-5-6-pro": "standard",
"gpt-5.6-pro": "standard",
"gpt-5-5-pro": "standard",
"gpt-5-5-pro-extended": "extended",
"gpt-5.5-pro": "standard",
@@ -36,9 +36,7 @@ export const MODEL_FORCED_EFFORT: Record<string, "standard" | "extended"> = {
/** Set of chatgpt.com slugs that the user_last_used_model_config endpoint
* accepts a `thinking_effort` value for, derived from MODEL_MAP so adding a
* new thinking entry there automatically extends this set. Includes the
* abbreviated slug `gpt-5-4-t-mini` (no literal "thinking" substring) — the
* reason this set exists at all rather than a substring match.
* new thinking entry there automatically extends this set.
*
* Derived from MODEL_MAP keys (always dot-form) that contain "thinking" or
* are the `o3` reasoning model; the values are the chatgpt.com-side slugs. */
@@ -52,18 +50,8 @@ export const THINKING_CAPABLE_SLUGS: ReadonlySet<string> = new Set(
* models and the o-series. PATCHing for a non-thinking surface is a no-op
* (the server accepts it but the routing-time read picks the wrong knob).
*
* Three branches because the input can arrive in three shapes:
* 1. OmniRoute dot-form id (`gpt-5.4-thinking-mini`) — every thinking
* variant carries the literal "thinking" substring here.
* 2. Resolved chatgpt.com slug containing "thinking" (`gpt-5-5-thinking`).
* 3. Resolved chatgpt.com slug that drops the substring under abbreviation
* (`gpt-5-4-t-mini`). Looked up via THINKING_CAPABLE_SLUGS, which is
* derived from MODEL_MAP itself so adding a new abbreviated thinking
* mapping automatically extends the check.
*
* Branch 3 also catches the case where a caller passes the chatgpt.com slug
* directly as the `model` field (no MODEL_MAP translation needed), which
* would otherwise silently bypass the PATCH. */
* The lookup also catches callers that pass a chatgpt.com slug directly as
* the `model` field without MODEL_MAP translation. */
export function isThinkingCapableModel(modelId: string, slug: string): boolean {
return (
modelId.includes("thinking") ||
@@ -128,6 +116,6 @@ export function resolveChatGptModel(
const slug = MODEL_MAP[model] ?? model;
const forcedEffort = MODEL_FORCED_EFFORT[model] ?? null;
const effort = forcedEffort ?? resolveThinkingEffort(body, providerSpecificData);
const isPro = slug === "gpt-5-5-pro";
const isPro = slug === "gpt-5-6-pro" || slug === "gpt-5-5-pro";
return { slug, effort, isPro };
}

View File

@@ -12,7 +12,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
// ---------- Versions ------------------------------------------------------
export const CLAUDE_CODE_VERSION = "2.1.195";
export const CLAUDE_CODE_VERSION = "2.1.207";
/** Bundled @anthropic-ai/sdk version for the pinned CLI release. */
export const CLAUDE_CODE_STAINLESS_VERSION = "0.94.0";

View File

@@ -117,8 +117,11 @@ function codexWebSocketUnavailableResponse(): Response {
export { getCodexModelScope, getCodexRateLimitKey, type CodexQuotaScope };
// Ordered list of effort levels from lowest to highest
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh", "max", "ultra"] as const;
type EffortLevel = (typeof EFFORT_ORDER)[number];
const STANDARD_EFFORT_SUFFIXES = ["none", "low", "medium", "high", "xhigh"] as const;
const GPT_5_6_MAX_ALIAS_MODELS = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
const GPT_5_6_ULTRA_ALIAS_MODELS = new Set(["gpt-5.6-sol", "gpt-5.6-terra"]);
const CODEX_FAST_WIRE_VALUE = "priority";
const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
@@ -127,7 +130,17 @@ function splitCodexReasoningSuffix(model: unknown): {
effort: EffortLevel | null;
} {
const modelId = typeof model === "string" ? model : "";
for (const level of EFFORT_ORDER) {
const gpt56AliasMatch = /^(gpt-5\.6-(?:sol|terra|luna))-(max|ultra)$/.exec(modelId);
if (gpt56AliasMatch) {
const [, baseModel, alias] = gpt56AliasMatch;
const supportedModels =
alias === "ultra" ? GPT_5_6_ULTRA_ALIAS_MODELS : GPT_5_6_MAX_ALIAS_MODELS;
if (supportedModels.has(baseModel)) {
return { baseModel, effort: alias as EffortLevel };
}
}
for (const level of STANDARD_EFFORT_SUFFIXES) {
if (modelId.endsWith(`-${level}`)) {
return {
baseModel: modelId.slice(0, -`-${level}`.length),
@@ -338,10 +351,13 @@ function normalizeServiceTierValue(value: unknown): string | undefined {
/**
* Maximum reasoning effort allowed per Codex model.
* Models not listed here default to "xhigh" (unrestricted).
* Models not listed here retain the legacy xhigh cap.
* Update this table when Codex releases new models with different caps.
*/
const MAX_EFFORT_BY_MODEL: Record<string, EffortLevel> = {
"gpt-5.6-sol": "ultra",
"gpt-5.6-terra": "ultra",
"gpt-5.6-luna": "max",
"gpt-5.3-codex": "xhigh",
"gpt-5.1-codex-max": "xhigh",
"gpt-5-mini": "high",
@@ -370,7 +386,6 @@ const CODEX_DEFAULT_REASONING_SUMMARY = "auto";
function normalizeEffortValue(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === "max") return "xhigh";
return normalized || undefined;
}
@@ -587,8 +602,7 @@ function extractCodexSseErrorMessage(text: string, fallback: string): string {
const parsed = JSON.parse(data) as Record<string, unknown>;
const directError = parsed.error as Record<string, unknown> | undefined;
const nestedError = (parsed.response as Record<string, unknown> | undefined)?.error as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const message =
(typeof directError?.message === "string" && directError.message) ||
(typeof nestedError?.message === "string" && nestedError.message) ||
@@ -1054,9 +1068,7 @@ export class CodexExecutor extends BaseExecutor {
headers["chatgpt-account-id"] = workspaceId;
}
const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity as
| CodexClientIdentity
| null
| undefined;
CodexClientIdentity | null | undefined;
// Originator header — identifies the client type to the Codex backend.
// Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs"
@@ -1295,8 +1307,7 @@ export class CodexExecutor extends BaseExecutor {
// gpt-5.3-codex-spark (and other Spark-scope models) reject image_generation
// upstream even on paid-plan accounts, so drop it independent of plan (#6651).
dropImageGeneration:
isCodexFreePlan(credentials?.providerSpecificData) ||
getCodexModelScope(model) === "spark",
isCodexFreePlan(credentials?.providerSpecificData) || getCodexModelScope(model) === "spark",
preserveCustomTools: nativeCodexPassthrough,
});
@@ -1335,9 +1346,11 @@ export class CodexExecutor extends BaseExecutor {
modelEffort || explicitReasoning || requestReasoningEffort || fallbackReasoningEffort;
if (rawEffort) {
const clampedEffort = clampEffort(cleanModel, rawEffort);
body.reasoning = {
...(reasoningRecord || {}),
effort: clampEffort(cleanModel, rawEffort),
// Ultra coordinates delegation in Codex clients; the upstream wire effort is Max.
effort: clampedEffort === "ultra" ? "max" : clampedEffort,
};
}
ensureCodexReasoningSummary(body);
@@ -1384,9 +1397,7 @@ export class CodexExecutor extends BaseExecutor {
applyCodexClientMetadata(
body,
credentials?.providerSpecificData?.codexClientIdentity as
| CodexClientIdentity
| null
| undefined
CodexClientIdentity | null | undefined
);
}

View File

@@ -61,7 +61,7 @@ function normalizeContentText(content: unknown): string {
* capability per the official CC model registry, but are NOT caught
* by the shared {@link isVisionModelId} heuristic. Kept as a local
* set because these are CC-specific model IDs (vendor-prefix shapes
* like "moonshotai/Kimi-K2.6" or CC aliases like "gpt-5.4-mini").
* like "moonshotai/Kimi-K2.6" or CC aliases like "gpt-5.6-luna").
*
* Source: Command Code /alpha/generate model registry (docs).
*/
@@ -73,7 +73,7 @@ const CC_VISION_MODEL_PATTERNS: readonly RegExp[] = [
// Anthropic
/claude-fable/i, // claude-fable-5 (not covered by claude-opus/sonnet/haiku-4)
// OpenAI
/gpt-5/i, // gpt-5.5, gpt-5.4, gpt-5.3-codex, gpt-5.4-mini
/gpt-5/i, // gpt-5.6, gpt-5.5, gpt-5.3-codex
// Sakana
/fugu/i, // sakana/fugu-ultra
];

View File

@@ -96,7 +96,7 @@ export interface InjectBillingHeaderOp {
* - static-zero: emit "00000" (relay endpoints don't validate)
*/
cchAlgo: "sha256-first-user" | "xxhash64-body" | "static-zero";
/** Override the embedded `cc_version=` value. Defaults to `2.1.195`. */
/** Override the embedded `cc_version=` value. Defaults to `2.1.207`. */
version?: string;
}
@@ -114,7 +114,7 @@ export const CCH_SALT = "59cf53e54c78";
/** Character positions sampled from the first user message text. */
export const CCH_POSITIONS = [4, 7, 20] as const;
/** Default `cc_version=` value embedded in the billing header. */
export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.195";
export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.207";
/** Identity sentinel prepended for Claude Agent SDK callers. */
export const CLAUDE_AGENT_SDK_IDENTITY =
"You are a Claude agent, built on Anthropic's Claude Agent SDK.";

View File

@@ -42,8 +42,8 @@ export {
CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA,
resolveClaudeCodeCompatibleAnthropicBeta,
} from "./claudeCodeCompatibleBeta.ts";
export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.195";
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.195 (external, sdk-cli)";
export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.207";
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.207 (external, sdk-cli)";
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0";
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0";
export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07";

View File

@@ -148,6 +148,9 @@ const IGNORE_FROM_CODE = new Set([
// write-build-sha.mjs to stamp dist/BUILD_SHA — injected by the build, never
// configured by users in .env.
"OMNIROUTE_BUILD_SHA",
// Listener-owned self-fetch transport signal. The HTTP/HTTPS launchers set
// this before application imports; it is not user-configurable product env.
"OMNIROUTE_INTERNAL_SCHEME",
// Source typo / placeholder.
"OMNIROUT",
// Static config alias path (the canonical var is OMNIROUTE_PAYLOAD_RULES_PATH).

View File

@@ -12,10 +12,7 @@ import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs";
import methodGuard from "./http-method-guard.cjs";
import headResponseGuard from "./head-response-guard.cjs";
import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs";
import {
isTurbopackCacheCorruption,
purgeAllTurbopackCaches,
} from "./turbopackCacheHeal.mjs";
import { isTurbopackCacheCorruption, purgeAllTurbopackCaches } from "./turbopackCacheHeal.mjs";
import { randomUUID } from "node:crypto";
const { maybeHandleDisallowedMethod } = methodGuard;
@@ -71,6 +68,7 @@ for (const [key, value] of Object.entries(mergedEnv)) {
// '@'` on the `@import "tailwindcss"` line. Force NODE_ENV to track the run
// mode, exactly like the `next` CLI does.
process.env.NODE_ENV = dev ? "development" : "production";
process.env.OMNIROUTE_INTERNAL_SCHEME = "http";
const { dashboardPort } = runtimePorts;
const hostname = process.env.HOST || "0.0.0.0";
@@ -119,7 +117,8 @@ async function prepareWithHeal() {
try {
await nextApp.prepare();
} catch (error) {
const detail = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
const detail =
error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
if (!useTurbopack || !isTurbopackCacheCorruption(detail)) throw error;
console.warn(
"[Next] Turbopack dev cache looks corrupted (Windows mmap / os error 1455 — known upstream bug). Purging and retrying once…"

View File

@@ -18,6 +18,7 @@ const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard;
// listener Next binds to (so WS `upgrade` / request wrappers keep working over
// TLS). Absent or misconfigured → null → identical plain-HTTP behavior as before.
const tlsOptions = resolveTlsOptions(process.env);
process.env.OMNIROUTE_INTERNAL_SCHEME = tlsOptions ? "https" : "http";
if (tlsOptions) {
console.log(`[omniroute][tls] HTTPS enabled — terminating TLS with cert=${tlsOptions.certPath}`);
}

View File

@@ -25,7 +25,7 @@
* Backend (configured via env, never committed):
* OMNIROUTE_TRANSLATION_API_URL e.g. https://cloud.omniroute.dev/v1
* OMNIROUTE_TRANSLATION_API_KEY bearer token (kept out of logs)
* OMNIROUTE_TRANSLATION_MODEL e.g. cx/gpt-5.4-mini
* OMNIROUTE_TRANSLATION_MODEL e.g. cx/gpt-5.6-sol
* OMNIROUTE_TRANSLATION_TIMEOUT_MS optional, default 60000
* OMNIROUTE_TRANSLATION_CONCURRENCY optional, default 4
*/

View File

@@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { normalizeCodexBaseUrl } from "@/shared/utils/codexBaseUrl";
import { isApplyDisabled, isResetDisabled } from "./codexButtonState";
import { CODEX_DEFAULT_MODELS } from "./codexToolOptions";
export default function CodexToolCard({
tool,
@@ -28,14 +29,7 @@ export default function CodexToolCard({
const [message, setMessage] = useState(null);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("gpt-5.5");
const CODEX_DEFAULT_MODELS = [
"gpt-5.5",
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
];
const [selectedModel, setSelectedModel] = useState("gpt-5.6-sol");
const [modelMappings, setModelMappings] = useState<Record<string, string>>({});
const [reasoningEffort, setReasoningEffort] = useState("xhigh");
const [wireApi, setWireApi] = useState("chat");
@@ -595,7 +589,7 @@ openai_base_url = "${getEffectiveBaseUrl()}"
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="gpt-5.5"
placeholder="gpt-5.6-sol"
className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
{selectedModel && (
@@ -627,6 +621,8 @@ openai_base_url = "${getEffectiveBaseUrl()}"
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">XHigh</option>
<option value="max">Max</option>
<option value="ultra">Ultra</option>
</select>
</div>

View File

@@ -0,0 +1,9 @@
export const CODEX_DEFAULT_MODELS = [
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"gpt-5.3-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
] as const;

View File

@@ -4090,7 +4090,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
<input
type="text"
value={config.handoffModel ?? ""}
placeholder="codex/gpt-5.4"
placeholder="codex/gpt-5.6-sol"
onChange={(e) =>
setConfig({
...config,

View File

@@ -698,6 +698,7 @@ export const CODEX_REASONING_STRENGTH_OPTIONS = [
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "XHigh" },
{ value: "max", label: "Max" },
];
export const CODEX_ACCOUNT_SERVICE_TIER_VALUES: CodexServiceTier[] = [

View File

@@ -13,7 +13,12 @@ type TierValue = "default" | "priority" | "flex";
// Fast-eligible Codex models per OpenAI ~/.codex/models_cache.json (service_tiers: priority).
// Other future Fast-eligible slugs can be added here without code changes once the user
// opts them in via the checkbox UI.
const CODEX_FAST_TIER_CATALOG: readonly string[] = ["gpt-5.5", "gpt-5.4"];
const CODEX_FAST_TIER_CATALOG: readonly string[] = [
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
];
export default function CodexFastTierTab() {
const [enabled, setEnabled] = useState(false);

View File

@@ -636,7 +636,7 @@ export default function ComboDefaultsTab() {
label={translateOrFallback(t, "contextRelaySummaryModel", "Summary Model")}
type="text"
value={comboDefaults.handoffModel ?? ""}
placeholder="codex/gpt-5.4"
placeholder="codex/gpt-5.6-sol"
onChange={(e) =>
setComboDefaults((prev) => ({
...prev,

View File

@@ -260,7 +260,7 @@ export async function POST(request: Request) {
parsed._root.model = model;
if (reasoningEffort && reasoningEffort !== "none") {
// Optional: low, medium, high
// Optional Codex reasoning effort.
parsed._root.model_reasoning_effort = reasoningEffort;
} else {
delete parsed._root.model_reasoning_effort;

View File

@@ -2,6 +2,13 @@ import {
getCodexClientVersion,
getCodexDefaultHeaders,
} from "@omniroute/open-sse/config/codexClient.ts";
import { isCodexDiscoveryModelExcluded } from "@/shared/services/codexDiscoveryPolicy";
export {
CODEX_DISCOVERY_EXCLUDED_IDS,
CODEX_DISCOVERY_EXCLUDED_ID_PREFIXES,
isCodexDiscoveryModelExcluded,
} from "@/shared/services/codexDiscoveryPolicy";
export const CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models";
export const CODEX_GITHUB_MODELS_URL =
@@ -213,8 +220,9 @@ function getFreshCodexGithubCatalogCache(
now: number,
cacheTtlMs: number
): CodexDiscoveryModel[] | null {
if (cacheTtlMs > 0 && codexGithubCatalogCache?.expiresAt > now) {
return codexGithubCatalogCache.models;
const cache = codexGithubCatalogCache;
if (cacheTtlMs > 0 && cache && cache.expiresAt > now) {
return cache.models;
}
return null;
}
@@ -284,6 +292,11 @@ function localCatalogModelToCodexDiscoveryModel(
};
}
/**
* Live/GitHub discovery is the source of truth for "what exists".
* Explicit filters (denylist / predicates) are the policy layer for "what we show".
* Do NOT reintroduce curated-only allowlisting as the default path (#6862 / #6859).
*/
export function mergeCodexLiveModelsWithLocalCatalog(
liveModels: CodexDiscoveryModel[],
localCatalogModels: CodexLocalCatalogModel[]
@@ -291,6 +304,7 @@ export function mergeCodexLiveModelsWithLocalCatalog(
const merged = new Map<string, CodexDiscoveryModel>();
for (const liveModel of liveModels) {
if (!liveModel?.id) continue;
merged.set(liveModel.id, liveModel);
}
@@ -304,6 +318,64 @@ export function mergeCodexLiveModelsWithLocalCatalog(
return Array.from(merged.values());
}
/** Return true to KEEP the model. */
export type CodexDiscoveryModelFilter = (model: CodexDiscoveryModel) => boolean;
/**
* Apply policy filters after discovery merge. Default denylist runs first;
* extraFilters are additional keep-predicates (all must pass).
*/
export function applyCodexDiscoveryFilters(
models: CodexDiscoveryModel[],
extraFilters: readonly CodexDiscoveryModelFilter[] = []
): CodexDiscoveryModel[] {
return models.filter((model) => {
if (isCodexDiscoveryModelExcluded(model)) return false;
return extraFilters.every((keep) => keep(model));
});
}
/** Convenience: merge live/local then apply default (+ optional) filters. */
export function buildCodexDiscoveryCatalog(
remoteModels: CodexDiscoveryModel[],
localCatalogModels: CodexLocalCatalogModel[],
extraFilters: readonly CodexDiscoveryModelFilter[] = []
): CodexDiscoveryModel[] {
return applyCodexDiscoveryFilters(
mergeCodexLiveModelsWithLocalCatalog(remoteModels, localCatalogModels),
extraFilters
);
}
export type CuratedCodexCatalogResult = {
models: CodexDiscoveryModel[];
candidateModels: CodexDiscoveryModel[];
};
/**
* Optional curated-only view (allowlist). NOT used by the default Codex
* discovery route — kept for diagnostics / explicit call sites only.
*/
export function reconcileCuratedCodexCatalog(
remoteModels: CodexDiscoveryModel[],
curatedModels: CodexLocalCatalogModel[]
): CuratedCodexCatalogResult {
const remoteById = new Map(remoteModels.map((model) => [model.id, model]));
const curatedIds = new Set<string>();
const models: CodexDiscoveryModel[] = [];
for (const localModel of curatedModels) {
if (!localModel.id) continue;
curatedIds.add(localModel.id);
const normalizedLocal = localCatalogModelToCodexDiscoveryModel(localModel);
const remoteModel = remoteById.get(localModel.id);
models.push(remoteModel ? { ...remoteModel, ...normalizedLocal } : normalizedLocal);
}
const candidateModels = remoteModels.filter((model) => !curatedIds.has(model.id));
return { models, candidateModels };
}
export function enrichCodexModelsFromGithubCatalog(
models: CodexDiscoveryModel[],
githubCatalogModels: CodexDiscoveryModel[]

View File

@@ -98,10 +98,10 @@ import {
PROVIDER_MODELS_CONFIG,
} from "./discovery/providerModelsConfig";
import {
buildCodexDiscoveryCatalog,
enrichCodexModelsFromGithubCatalog,
fetchCodexDiscoveryModels,
fetchCodexGithubCatalogModels,
mergeCodexLiveModelsWithLocalCatalog,
} from "./discovery/codex";
/**
@@ -118,6 +118,7 @@ export async function GET(
// Check if we should exclude hidden models (used by MCP tools to prevent hidden model leaks)
const { searchParams } = new URL(request.url);
const excludeHidden = searchParams.get("excludeHidden") === "true";
const excludeCustom = searchParams.get("excludeCustom") === "true";
const refresh = searchParams.get("refresh") === "true";
const connection = await getProviderConnectionById(id);
@@ -223,15 +224,19 @@ export async function GET(
// per-connection route (used by MCP list_models_catalog + the dashboard
// import view) never did, so custom models were dropped on both the
// discovery-success and local_catalog paths. Read them once here and fold
// them into every models response via buildResponse below (dedup by id).
// them into every user-facing models response via buildResponse below
// (dedup by id). Internal model-sync discovery opts out because these rows
// are a response projection, not provider-discovered models.
let customModelsForProvider: Array<{ id: string; name?: string }> = [];
try {
const custom = await getCustomModels(provider);
if (Array.isArray(custom)) {
customModelsForProvider = custom as Array<{ id: string; name?: string }>;
if (!excludeCustom) {
try {
const custom = await getCustomModels(provider);
if (Array.isArray(custom)) {
customModelsForProvider = custom as Array<{ id: string; name?: string }>;
}
} catch {
// DB unavailable — proceed without custom models.
}
} catch {
// DB unavailable — proceed without custom models.
}
const mergeCustomModels = (models: any[]) => {
@@ -393,7 +398,11 @@ export async function GET(
});
};
const buildApiDiscoveryResponse = async (models: any[], warning?: string) => {
const buildApiDiscoveryResponse = async (
models: any[],
warning?: string,
extraPayload: Record<string, unknown> = {}
) => {
const discoveredModels = await persistDiscoveredModels(provider, connectionId, models);
if (discoveredModels.length > 0) {
return buildResponse({
@@ -402,6 +411,7 @@ export async function GET(
models,
source: "api",
...(warning ? { warning } : {}),
...extraPayload,
});
}
@@ -1721,11 +1731,42 @@ export async function GET(
}
if (provider === "codex") {
const cachedResponse = maybeReturnCachedDiscovery();
if (cachedResponse) return cachedResponse;
// Auto-merge live/GitHub/local (future-proof discovery), then apply explicit
// denylist filters (e.g. drop GPT-5.4 family). Do not gate remote-only IDs.
const staticCodexCatalog = mergeLocalCatalogModels(
getModelsByProviderId("codex") || [],
getStaticModelsForProvider("codex") || []
);
const finalizeCodexCatalog = (remoteModels: typeof cachedDiscoveryModels) =>
buildCodexDiscoveryCatalog(remoteModels, staticCodexCatalog);
const cachedCatalogModels = finalizeCodexCatalog(cachedDiscoveryModels);
const cachedIdsMatchFinalCatalog =
cachedDiscoveryModels.length === cachedCatalogModels.length &&
cachedDiscoveryModels.every((model, index) => model.id === cachedCatalogModels[index]?.id);
const persistFilteredCacheIfNeeded = async () => {
if (cachedIdsMatchFinalCatalog) return;
await persistDiscoveredModels(provider, connectionId, cachedCatalogModels);
};
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
if (!refresh && cachedDiscoveryModels.length > 0) {
await persistFilteredCacheIfNeeded();
return buildResponse({
provider,
connectionId,
models: cachedCatalogModels,
source: "cache",
});
}
if (!refresh && !autoFetchModels) {
return buildResponse({
provider,
connectionId,
models: finalizeCodexCatalog([]),
source: "local_catalog",
warning: "Auto-fetch disabled — using local catalog",
});
}
const liveModels = await fetchCodexDiscoveryModels({
accessToken: accessToken || null,
@@ -1747,38 +1788,35 @@ export async function GET(
...init,
}),
});
const staticCodexCatalog = mergeLocalCatalogModels(
getModelsByProviderId("codex") || [],
getStaticModelsForProvider("codex") || []
);
if (liveModels && liveModels.length > 0) {
const enrichedLiveModels =
githubCatalogModels && githubCatalogModels.length > 0
? enrichCodexModelsFromGithubCatalog(liveModels, githubCatalogModels)
: liveModels;
return buildApiDiscoveryResponse(
mergeCodexLiveModelsWithLocalCatalog(enrichedLiveModels, staticCodexCatalog)
);
return buildApiDiscoveryResponse(finalizeCodexCatalog(enrichedLiveModels));
}
if (githubCatalogModels && githubCatalogModels.length > 0) {
return buildApiDiscoveryResponse(
mergeCodexLiveModelsWithLocalCatalog(githubCatalogModels, staticCodexCatalog),
finalizeCodexCatalog(githubCatalogModels),
"Codex live catalog unavailable — using GitHub model catalog"
);
}
const fallback = buildDiscoveryFallbackResponse({
cacheWarning: "Codex live catalog unavailable — using cached catalog",
localWarning: "Codex live and GitHub catalogs unavailable — using local catalog",
localIntentional: true,
});
if (fallback) return fallback;
if (cachedDiscoveryModels.length > 0) {
await persistFilteredCacheIfNeeded();
return buildResponse({
provider,
connectionId,
models: cachedCatalogModels,
source: "cache",
warning: "Codex live catalog unavailable — using cached catalog",
});
}
return buildResponse({
provider,
connectionId,
models: [],
models: finalizeCodexCatalog([]),
source: "local_catalog",
intentional: true,
warning: "Codex live and GitHub catalogs unavailable — using local catalog",

View File

@@ -10,6 +10,8 @@ import { saveCallLog } from "@/lib/usage/callLogs";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
buildModelSyncInternalHeaders,
fetchModelSyncInternal,
getModelSyncInternalBaseUrl,
isModelSyncInternalRequest,
} from "@/shared/services/modelSyncScheduler";
import { autoSyncCodexProfilesFromLiveCatalog } from "@/lib/cli-helper/codexProfileAutoSync";
@@ -180,7 +182,7 @@ export type EnsureReadyOptions = {
export async function ensureLoopbackServerReady(opts: EnsureReadyOptions = {}): Promise<void> {
if (__loopbackReadyPromise != null) return __loopbackReadyPromise;
__loopbackReadyPromise = (async () => {
const f = opts.fetch ?? fetch;
const f = opts.fetch ?? fetchModelSyncInternal;
const maxWaitMs = opts.maxWaitMs ?? 30_000;
const pollMs = opts.pollMs ?? 250;
const deadline = Date.now() + maxWaitMs;
@@ -191,10 +193,10 @@ export async function ensureLoopbackServerReady(opts: EnsureReadyOptions = {}):
// readiness — we only care that the dispatcher succeeds (no
// ECONNREFUSED). Using a synthetic connection id so no real DB lookup
// is needed; the 404 is sufficient proof the server is dispatching.
const probePort = process.env.OMNIROUTE_PORT || process.env.PORT || "20128";
const res = await f(
`http://127.0.0.1:${probePort}/api/providers/__readiness_probe__/models`,
`${getModelSyncInternalBaseUrl()}/api/providers/__readiness_probe__/models`,
{
redirect: "error",
signal: AbortSignal.timeout(2_000),
}
);
@@ -264,7 +266,7 @@ export async function selfFetchWithRetry(
url: string,
opts: SelfFetchWithRetryOptions = {}
): Promise<Response> {
const f = opts.fetch ?? fetch;
const f = opts.fetch ?? fetchModelSyncInternal;
// Reduced from 5 to 3: the readiness gate now handles the boot race.
// Retries here are only for transient failures after server is confirmed up.
const maxRetries = opts.maxRetries ?? 3;
@@ -321,30 +323,20 @@ export async function selfFetchWithRetry(
// ---------------------------------------------------------------------------
async function fetchProviderModelsForSync(request: Request, connectionId: string) {
// Construct a safe localhost URL from the incoming request's origin.
// The route only accepts authenticated or internal-scheduler requests,
// and the path is hardcoded — no user-controlled URL components reach fetch.
// Always use 127.0.0.1 (IPv4) — never "localhost" which may resolve to ::1
// (IPv6) in containers, causing TypeError: fetch failed even when the HTTP
// server is bound only to 0.0.0.0 (IPv4 only).
const SAFE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
const incomingUrl = new URL(request.url);
const loopbackPort =
SAFE_HOSTS.has(incomingUrl.hostname) && incomingUrl.port
? incomingUrl.port
: process.env.PORT || "20128";
const safeOrigin = `http://127.0.0.1:${loopbackPort}`;
const modelsPath = `/api/providers/${encodeURIComponent(connectionId)}/models?refresh=true`;
const safeOrigin = getModelSyncInternalBaseUrl();
const modelsPath =
`/api/providers/${encodeURIComponent(connectionId)}/models` +
"?refresh=true&excludeCustom=true";
const headers = {
cookie: request.headers.get("cookie") || "",
...buildModelSyncInternalHeaders(),
};
const targetUrl = new URL(modelsPath, safeOrigin).href;
const targetUrl = `${safeOrigin}${modelsPath}`;
// Wrap fetch so it forwards the required headers on every retry attempt.
const fetchWithHeaders: typeof fetch = (input, init) =>
fetch(input as string, { ...init, headers });
fetchModelSyncInternal(input, { ...init, headers, redirect: "error" });
return selfFetchWithRetry(targetUrl, {
fetch: fetchWithHeaders,
@@ -376,9 +368,12 @@ async function fetchProviderModelsForSync(request: Request, connectionId: string
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const start = Date.now();
const { id } = await params;
const requestUrl = new URL(request.url);
const mode = (
new URL(request.url).searchParams.get("mode") === "import" ? "merge" : "sync"
requestUrl.searchParams.get("mode") === "import" ? "merge" : "sync"
) as ManagedModelImportMode;
// quiet=1: boot revalidation path — skip chatty ModelSync console lines
const quiet = requestUrl.searchParams.get("quiet") === "1";
let logProvider = "unknown";
let channelLabel: string | null = null;
@@ -523,7 +518,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
const updatedCount = importedChanges.updated;
const shouldLog = modelChanges.total > 0 || customModelChanges.total > 0;
if (shouldLog) {
if (shouldLog && !quiet) {
void autoSyncCodexProfilesFromLiveCatalog(request, `model-sync:${logProvider}`)
.then((syncResult) => {
if (syncResult.ok) {
@@ -561,6 +556,14 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
err?.message || err
);
});
} else if (shouldLog && quiet) {
// Still update profiles; suppress console noise from boot revalidation.
void autoSyncCodexProfilesFromLiveCatalog(request, `model-sync:${logProvider}`).catch(
() => undefined
);
void autoSyncClaudeProfilesFromLiveCatalog(request, `model-sync:${logProvider}`).catch(
() => undefined
);
}
if (shouldLog) {

View File

@@ -34,6 +34,7 @@ import { isManagedProviderConnectionId } from "@/lib/providers/catalog";
import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure";
import {
buildModelSyncInternalHeaders,
fetchModelSyncInternal,
getModelSyncInternalBaseUrl,
} from "@/shared/services/modelSyncScheduler";
@@ -194,7 +195,11 @@ export async function POST(request: Request) {
};
const syncUrl = `${internalOrigin}/api/providers/${encodeURIComponent(newConnection.id)}/sync-models?mode=import`;
// Intentionally not awaited: this is async/non-blocking work.
void fetch(syncUrl, { method: "POST", headers: syncHeaders })
void fetchModelSyncInternal(syncUrl, {
method: "POST",
headers: syncHeaders,
redirect: "error",
})
.then((syncRes) => {
if (!syncRes.ok) {
console.log(`[providers] Auto-sync failed for ${newConnection.id}: ${syncRes.status}`);

View File

@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import initializeCloudSync from "@/shared/services/initializeCloudSync";
import { startModelSyncScheduler } from "@/shared/services/modelSyncScheduler";
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
let syncInitialized = false;
let modelSyncInitialized = false;
@@ -20,8 +19,7 @@ export async function POST(request) {
// (#488) Start model auto-sync scheduler (24h, configurable via MODEL_SYNC_INTERVAL_HOURS)
if (!modelSyncInitialized) {
const origin = request.headers.get("origin") || resolveOmniRouteBaseUrl();
startModelSyncScheduler(origin);
startModelSyncScheduler();
modelSyncInitialized = true;
}

View File

@@ -157,7 +157,7 @@ async function postHandler(request: Request, context) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: image");
}
const fullModel = model || "cgpt-web/gpt-5.3-instant";
const fullModel = model || "cgpt-web/gpt-5.5";
const policy = await enforceApiKeyPolicy(request, fullModel);
if (policy.rejection) return policy.rejection;

View File

@@ -12,7 +12,10 @@ import {
import { extractAliasBackedModels } from "./aliasBackedModels";
import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias";
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry";
import { getAllImageModels, isRegisteredImageModel } from "@omniroute/open-sse/config/imageRegistry";
import {
getAllImageModels,
isRegisteredImageModel,
} from "@omniroute/open-sse/config/imageRegistry";
import { getAllRerankModels } from "@omniroute/open-sse/config/rerankRegistry";
import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry";
import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry";
@@ -78,6 +81,7 @@ import { getVisionCapabilityFields, getCustomVisionCapabilityFields } from "./ca
import { FALLBACK_ALIAS_TO_PROVIDER, buildAliasMaps } from "./catalogProviderMaps";
import { getModelCatalogAuthRejection, isCodexModelCatalogClient } from "./catalogRequest";
import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels";
import { isCodexDiscoveryModelExcluded } from "@/shared/services/codexDiscoveryPolicy";
// Public API of this module is preserved after the catalog helper extraction:
// `isVisionModelId` (vision-detection-consistency.test.ts) and
@@ -760,7 +764,8 @@ async function buildUnifiedModelsResponseCore(
if (!providerSupportsModel(canonicalProviderId, model.id)) continue;
const aliasId = `${alias}/${model.id}`;
if (getModelIsHidden(canonicalProviderId, model.id)) continue;
if (shouldHidePaid(canonicalProviderId, model.id, (model as { pricing?: unknown }).pricing)) continue;
if (shouldHidePaid(canonicalProviderId, model.id, (model as { pricing?: unknown }).pricing))
continue;
const visionFields =
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(model.id);
@@ -848,6 +853,9 @@ async function buildUnifiedModelsResponseCore(
for (const sm of syncedModels) {
if (!providerSupportsModel(canonicalProviderId, sm.id)) continue;
if (canonicalProviderId === "codex" && isCodexDiscoveryModelExcluded(sm)) {
continue;
}
if (getModelIsHidden(providerId, sm.id)) continue;
// #6457: some upstream discovery catalogs (e.g. HuggingFace's live
// `/v1/models`) return image/diffusion models with no modality info,
@@ -866,7 +874,8 @@ async function buildUnifiedModelsResponseCore(
// the FREE_MODEL_IDS_BY_PROVIDER catalog — providers with a curated
// free roster show only those; providers with none fall through to
// hide-all via providerHasFreeModels() === false.
if (shouldHidePaid(canonicalProviderId, sm.id, (sm as { pricing?: unknown }).pricing)) continue;
if (shouldHidePaid(canonicalProviderId, sm.id, (sm as { pricing?: unknown }).pricing))
continue;
const registryEntry = REGISTRY[providerId];
const displayModelId =
@@ -1227,7 +1236,10 @@ async function buildUnifiedModelsResponseCore(
// #6328: apply hidePaidModels to user-defined custom rows too.
// Custom entries do not carry pricing, so shouldHidePaid() decides
// via FREE_MODEL_IDS_BY_PROVIDER — matches synced/PROVIDER_MODELS.
if (shouldHidePaid(canonicalProviderId, modelId, (model as { pricing?: unknown }).pricing)) continue;
if (
shouldHidePaid(canonicalProviderId, modelId, (model as { pricing?: unknown }).pricing)
)
continue;
// noAuth providers have no connection rows; keep auth providers gated. (#2798/#3200)
const isNoAuthProvider = isNoAuthProviderKey(canonicalProviderId, providerId, alias);
if (
@@ -1428,7 +1440,8 @@ async function buildUnifiedModelsResponseCore(
// #6328: apply hidePaidModels to managed-fallback rows too. Compatible
// provider fallbacks lack pricing; shouldHidePaid() decides via the
// FREE_MODEL_IDS_BY_PROVIDER catalog tier.
if (shouldHidePaid(canonicalProviderId, modelId, (model as { pricing?: unknown }).pricing)) continue;
if (shouldHidePaid(canonicalProviderId, modelId, (model as { pricing?: unknown }).pricing))
continue;
if (!hasEligibleConnectionForModel([conn], modelId)) continue;
const aliasId = `${alias}/${modelId}`;

View File

@@ -1,6 +1,9 @@
import path from "node:path";
import { ensureCliConfigWriteAllowed, getCliConfigPaths } from "../../shared/services/cliRuntime";
import { getModelSyncInternalBaseUrl } from "../../shared/services/modelSyncScheduler";
import {
fetchModelSyncInternal,
getModelSyncInternalBaseUrl,
} from "../../shared/services/modelSyncScheduler";
import { isFeatureFlagEnabled } from "../../shared/utils/featureFlags";
type SyncResult =
@@ -47,8 +50,9 @@ export async function autoSyncClaudeProfilesFromLiveCatalog(
}
const internalBase = getModelSyncInternalBaseUrl().replace(/\/$/, "");
const res = await fetch(`${internalBase}/v1/models`, {
const res = await fetchModelSyncInternal(`${internalBase}/v1/models`, {
headers: forwardAuthHeaders(request),
redirect: "error",
signal: AbortSignal.timeout(10_000),
});

View File

@@ -1,6 +1,9 @@
import path from "node:path";
import { ensureCliConfigWriteAllowed, getCliConfigPaths } from "../../shared/services/cliRuntime";
import { getModelSyncInternalBaseUrl } from "../../shared/services/modelSyncScheduler";
import {
fetchModelSyncInternal,
getModelSyncInternalBaseUrl,
} from "../../shared/services/modelSyncScheduler";
import { isFeatureFlagEnabled } from "../../shared/utils/featureFlags";
type SyncResult =
@@ -47,8 +50,9 @@ export async function autoSyncCodexProfilesFromLiveCatalog(
}
const baseUrl = getModelSyncInternalBaseUrl().replace(/\/$/, "");
const res = await fetch(`${baseUrl}/v1/models`, {
const res = await fetchModelSyncInternal(`${baseUrl}/v1/models`, {
headers: forwardAuthHeaders(request),
redirect: "error",
signal: AbortSignal.timeout(10_000),
});

View File

@@ -190,6 +190,17 @@ export async function getSettings() {
}
export async function updateSettings(updates: Record<string, unknown>) {
// Detect first-time setup completion before we overwrite settings.
let setupJustCompleted = false;
if (updates.setupComplete === true) {
try {
const prev = await getSettings();
setupJustCompleted = prev.setupComplete !== true;
} catch {
setupJustCompleted = true;
}
}
const db = getDbInstance();
const insert = db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)"
@@ -221,6 +232,17 @@ export async function updateSettings(updates: Record<string, unknown>) {
);
}
// Onboarding / setup finished → one-shot Codex catalog revalidation (init case).
if (setupJustCompleted) {
void import("@/shared/services/codexCatalogRevalidation")
.then(({ scheduleCodexCatalogRevalidationAfterInit }) => {
scheduleCodexCatalogRevalidationAfterInit();
})
.catch(() => {
// non-fatal
});
}
return nextSettings;
}

View File

@@ -12,7 +12,10 @@ import {
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "@/shared/constants/models";
import { getSyncStatus, getSyncedCapability } from "@/lib/modelsDevSync";
import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization";
import {
CANONICAL_EFFORT_VALUES,
extendCodexGpt56EffortValues,
} from "@/shared/reasoning/effortStandardization";
const MODEL_METADATA_SCHEMA_VERSION = "model-metadata-v1";
@@ -296,7 +299,13 @@ export function enrichCatalogModelEntry<T extends JsonRecord>(
thinking: metadata.capabilities.supportsThinking,
supportsThinking: metadata.capabilities.supportsThinking,
...(metadata.capabilities.supportsThinking
? { effort_tiers: [...CANONICAL_EFFORT_VALUES] }
? {
effort_tiers: extendCodexGpt56EffortValues(
metadata.provider,
metadata.model,
CANONICAL_EFFORT_VALUES
),
}
: {}),
}
: {}),

View File

@@ -13,7 +13,12 @@ function asRecord(value: unknown): JsonRecord {
export type CodexFastTierValue = CodexServiceTier;
export type CodexGlobalServiceMode = "none" | CodexServiceTier;
export const CODEX_FAST_TIER_DEFAULT_SUPPORTED_MODELS: readonly string[] = ["gpt-5.5", "gpt-5.4"];
export const CODEX_FAST_TIER_DEFAULT_SUPPORTED_MODELS: readonly string[] = [
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
];
export interface CodexGlobalFastServiceTierResolved {
enabled: boolean;
@@ -30,7 +35,8 @@ export interface CodexGlobalFastServiceTierResolved {
*
* Defaults when fields are absent on an enabled config:
* - tier = "priority" (back-compat: PR #2440 only injected priority)
* - supportedModels = ["gpt-5.5", "gpt-5.4"] (OpenAI Fast-eligible per models_cache.json)
* - supportedModels = CODEX_FAST_TIER_DEFAULT_SUPPORTED_MODELS
* (OpenAI Fast-eligible per models_cache.json)
*/
export function resolveCodexGlobalFastServiceTier(
settings: unknown

View File

@@ -5,7 +5,14 @@ import { normalizeExcludedModelPatterns } from "@/domain/connectionModelRules";
import { normalizeRoutingTags } from "@/domain/tagRouter";
import { normalizeOpenRouterPreset } from "@/shared/constants/openRouterPreset";
export const CODEX_REASONING_EFFORT_VALUES = ["none", "low", "medium", "high", "xhigh"] as const;
export const CODEX_REASONING_EFFORT_VALUES = [
"none",
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_VALUES)[number];

View File

@@ -79,7 +79,7 @@ function normalizeServiceTier(value: unknown): string {
}
function stripCodexEffortSuffix(model: string): string {
return model.replace(/-(?:xhigh|high|medium|low|none)$/i, "");
return model.replace(/-(?:ultra|max|xhigh|high|medium|low|none)$/i, "");
}
export function getCodexFastCostMultiplier(
@@ -101,6 +101,12 @@ export function getCodexFastCostMultiplier(
const modelKey = stripCodexEffortSuffix(normalizeModelName(String(model || "")).toLowerCase());
const compactModelKey = modelKey.replace(/-/g, "");
if (
/^gpt-5\.6-(?:sol|terra|luna)$/.test(modelKey) ||
/^gpt5\.6(?:sol|terra|luna)$/.test(compactModelKey)
) {
return 1.5;
}
if (modelKey === "gpt-5.5" || compactModelKey === "gpt5.5") return 2.5;
if (modelKey === "gpt-5.4" || compactModelKey === "gpt5.4") return 2;
return 1;
@@ -237,7 +243,10 @@ export function computeAudioCost(
}
const characters = toNumber(usage.characters, 0);
if (characters > 0) {
const perChar = toNumber(pricing.input_cost_per_character ?? pricing.output_cost_per_character, 0);
const perChar = toNumber(
pricing.input_cost_per_character ?? pricing.output_cost_per_character,
0
);
// Round to 10 decimals to drop binary-FP artifacts (e.g. 0.000015 * 1000).
if (perChar > 0) return Math.round(perChar * characters * 1e10) / 1e10;
}

View File

@@ -1,6 +1,7 @@
import { supportsXHighEffort } from "@omniroute/open-sse/config/providerModels";
import { parseModel } from "@omniroute/open-sse/services/model";
import { stripVscodeServiceTierVariantModelId } from "@/lib/vscode/serviceTierVariants";
import { extendCodexGpt56EffortValues } from "@/shared/reasoning/effortStandardization";
export type VscodeCatalogModel = {
id?: string;
@@ -15,9 +16,10 @@ export type VscodeCatalogModel = {
default_reasoning_effort?: string;
};
const EFFORT_SUFFIX_PATTERN = /-(xhigh|high|medium|low|none)$/i;
const STANDARD_EFFORT_SUFFIX_PATTERN = /-(xhigh|high|medium|low|none)$/i;
const GPT_5_6_EXTENDED_EFFORT_SUFFIX_PATTERN = /^(.*gpt-5\.6-(?:sol|terra|luna))-(max|ultra)$/i;
const DEFAULT_REASONING_EFFORT = "none";
const KNOWN_REASONING_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh"]);
const KNOWN_REASONING_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh", "max", "ultra"]);
export type VscodeModelConfigSchema = {
type: "object";
@@ -38,6 +40,20 @@ export function getCatalogModelName(model: VscodeCatalogModel) {
return stripVscodeServiceTierVariantModelId(model.id || model.name || model.root || "");
}
function matchReasoningEffortSuffix(modelId: string) {
const extendedMatch = modelId.match(GPT_5_6_EXTENDED_EFFORT_SUFFIX_PATTERN);
if (extendedMatch?.[1] && extendedMatch[2]) {
return { baseModelId: extendedMatch[1], effort: extendedMatch[2].toLowerCase() };
}
const standardMatch = modelId.match(STANDARD_EFFORT_SUFFIX_PATTERN);
if (!standardMatch?.[1]) return undefined;
return {
baseModelId: modelId.slice(0, -standardMatch[0].length),
effort: standardMatch[1].toLowerCase(),
};
}
function normalizeReasoningEffortValue(value: string) {
const normalized = value
.trim()
@@ -104,7 +120,7 @@ export function getReasoningEffortValues(model: VscodeCatalogModel) {
values.push("xhigh");
}
return values;
return extendCodexGpt56EffortValues(providerId, providerModelId, values);
}
export function formatReasoningEffortLabel(level: string) {
@@ -123,7 +139,11 @@ function describeReasoningEffort(level: string) {
case "high":
return "Uses an extended amount of reasoning.";
case "xhigh":
return "Uses extra-high reasoning effort.";
case "max":
return "Uses the maximum available reasoning effort.";
case "ultra":
return "Uses the Ultra reasoning preset.";
default:
return `Uses ${formatReasoningEffortLabel(level)} reasoning effort.`;
}
@@ -138,11 +158,10 @@ export function inferSelectedReasoningEffort(
supportedValues?: string[]
) {
const modelId = getCatalogModelName(model);
const match = modelId.match(EFFORT_SUFFIX_PATTERN);
const match = matchReasoningEffortSuffix(modelId);
if (!match) return undefined;
const selected = match[1]?.toLowerCase();
if (!selected) return undefined;
const selected = match.effort;
if (
Array.isArray(supportedValues) &&
supportedValues.length > 0 &&
@@ -155,7 +174,7 @@ export function inferSelectedReasoningEffort(
}
export function getReasoningVariantBaseModelId(modelId: string) {
return modelId.replace(EFFORT_SUFFIX_PATTERN, "");
return matchReasoningEffortSuffix(modelId)?.baseModelId || modelId;
}
export function getDefaultReasoningEffort(model: VscodeCatalogModel, supportedValues?: string[]) {

View File

@@ -30,7 +30,7 @@ const CLAUDE_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
id: "claude-cli",
label: "Claude CLI",
headers: Object.freeze({
"User-Agent": "claude-cli/2.1.195 (external, cli)",
"User-Agent": "claude-cli/2.1.207 (external, cli)",
"X-App": "cli",
}),
});
@@ -39,7 +39,7 @@ const CODEX_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
id: "codex-cli",
label: "Codex CLI",
headers: Object.freeze({
"User-Agent": "codex_cli_rs/0.136.0",
"User-Agent": "codex_cli_rs/0.144.1",
originator: "codex_cli_rs",
}),
});
@@ -61,9 +61,7 @@ export const CLIENT_IDENTITY_PROFILES: Readonly<Record<string, ClientIdentityPro
"gemini-cli": GEMINI_CLI_PROFILE,
});
export const CLIENT_IDENTITY_PROFILE_IDS: readonly string[] = Object.keys(
CLIENT_IDENTITY_PROFILES
);
export const CLIENT_IDENTITY_PROFILE_IDS: readonly string[] = Object.keys(CLIENT_IDENTITY_PROFILES);
export const CLIENT_IDENTITY_PROFILE_OPTIONS: ReadonlyArray<{ value: string; label: string }> =
CLIENT_IDENTITY_PROFILE_IDS.map((id) => ({
@@ -72,7 +70,10 @@ export const CLIENT_IDENTITY_PROFILE_OPTIONS: ReadonlyArray<{ value: string; lab
}));
export function isClientIdentityProfileId(value: unknown): value is string {
return typeof value === "string" && Object.prototype.hasOwnProperty.call(CLIENT_IDENTITY_PROFILES, value);
return (
typeof value === "string" &&
Object.prototype.hasOwnProperty.call(CLIENT_IDENTITY_PROFILES, value)
);
}
/**

View File

@@ -65,7 +65,32 @@ const AUTHORITATIVE_PROVIDER_CONTEXT_WINDOWS = new Map<string, number>([
["zenmux/z-ai/glm-5.2-free", 1000000],
]);
const GPT_5_6_MODEL_SPEC = {
maxOutputTokens: 128000,
contextWindow: 1050000,
supportsThinking: true,
supportsTools: true,
supportsVision: true,
} satisfies ModelSpec;
export const MODEL_SPECS: Record<string, ModelSpec> = {
"gpt-5.6": {
...GPT_5_6_MODEL_SPEC,
aliases: ["openai/gpt-5.6"],
},
"gpt-5.6-sol": {
...GPT_5_6_MODEL_SPEC,
aliases: ["openai/gpt-5.6-sol"],
},
"gpt-5.6-terra": {
...GPT_5_6_MODEL_SPEC,
aliases: ["openai/gpt-5.6-terra"],
},
"gpt-5.6-luna": {
...GPT_5_6_MODEL_SPEC,
aliases: ["openai/gpt-5.6-luna"],
},
"gpt-5.5": {
maxOutputTokens: 128000,
contextWindow: 1050000,

View File

@@ -4,6 +4,9 @@
*/
import {
GPT_5_5_PRICING,
GPT_5_6_LUNA_PRICING,
GPT_5_6_SOL_PRICING,
GPT_5_6_TERRA_PRICING,
CLAUDE_FABLE_5_PRICING,
CLAUDE_OPUS_4_PRICING,
CLAUDE_SONNET_4_PRICING,
@@ -14,13 +17,16 @@ import {
export const DEFAULT_PRICING_FRONTIER = {
openai: {
"gpt-5.6": GPT_5_6_SOL_PRICING,
"gpt-5.6-sol": GPT_5_6_SOL_PRICING,
"gpt-5.6-terra": GPT_5_6_TERRA_PRICING,
"gpt-5.6-luna": GPT_5_6_LUNA_PRICING,
"gpt-5.5": GPT_5_5_PRICING,
// The -pro tier mirrors its base family pricing until OpenAI publishes a
// distinct pro rate; without these rows the openai provider's gpt-5.x-pro
// models (in the registry) resolved to $0 and tripped the catalog pricing gate.
"gpt-5.5-pro": GPT_5_5_PRICING,
// gpt-5.4 family (public API tier; mirrors the codex 5.4 tier for the
// base/mini, with a lower nano tier). Without these rows the openai
// gpt-5.4 family (public API tier, with a lower nano tier). Without these rows the openai
// provider's gpt-5.4* models resolved to $0.
"gpt-5.4": {
input: 5.0,

View File

@@ -2,7 +2,13 @@
* Pricing data — oauth-subscriptions family (OAuth / IDE subscription aliases (cc, codex, antigravity, copilot, kiro…)).
* Pure data; merged by default-pricing.ts via spread (god-file decomposition; semantic split).
*/
import { GPT_5_3_CODEX_PRICING, GPT_5_5_PRICING } from "./shared-tiers";
import {
GPT_5_3_CODEX_PRICING,
GPT_5_5_PRICING,
GPT_5_6_LUNA_PRICING,
GPT_5_6_SOL_PRICING,
GPT_5_6_TERRA_PRICING,
} from "./shared-tiers";
export const DEFAULT_PRICING_OAUTH = {
cc: {
@@ -72,6 +78,28 @@ export const DEFAULT_PRICING_OAUTH = {
},
cx: {
"codex-auto-review": GPT_5_5_PRICING,
// Codex uses credits per 1M tokens. OmniRoute stores the dollar-equivalent
// values below at the documented conversion of 25 credits per USD.
"gpt-5.6-sol": GPT_5_6_SOL_PRICING,
"gpt-5.6-sol-ultra": GPT_5_6_SOL_PRICING,
"gpt-5.6-sol-max": GPT_5_6_SOL_PRICING,
"gpt-5.6-sol-xhigh": GPT_5_6_SOL_PRICING,
"gpt-5.6-sol-high": GPT_5_6_SOL_PRICING,
"gpt-5.6-sol-medium": GPT_5_6_SOL_PRICING,
"gpt-5.6-sol-low": GPT_5_6_SOL_PRICING,
"gpt-5.6-terra": GPT_5_6_TERRA_PRICING,
"gpt-5.6-terra-ultra": GPT_5_6_TERRA_PRICING,
"gpt-5.6-terra-max": GPT_5_6_TERRA_PRICING,
"gpt-5.6-terra-xhigh": GPT_5_6_TERRA_PRICING,
"gpt-5.6-terra-high": GPT_5_6_TERRA_PRICING,
"gpt-5.6-terra-medium": GPT_5_6_TERRA_PRICING,
"gpt-5.6-terra-low": GPT_5_6_TERRA_PRICING,
"gpt-5.6-luna": GPT_5_6_LUNA_PRICING,
"gpt-5.6-luna-max": GPT_5_6_LUNA_PRICING,
"gpt-5.6-luna-xhigh": GPT_5_6_LUNA_PRICING,
"gpt-5.6-luna-high": GPT_5_6_LUNA_PRICING,
"gpt-5.6-luna-medium": GPT_5_6_LUNA_PRICING,
"gpt-5.6-luna-low": GPT_5_6_LUNA_PRICING,
// GPT 5.5
"gpt-5.5": GPT_5_5_PRICING,
"gpt5.5": GPT_5_5_PRICING,
@@ -80,42 +108,6 @@ export const DEFAULT_PRICING_OAUTH = {
"gpt-5.5-medium": GPT_5_5_PRICING,
"gpt-5.5-low": GPT_5_5_PRICING,
"gpt-5.5-none": GPT_5_5_PRICING,
// GPT 5.4
"gpt-5.4": {
input: 5.0,
output: 20.0,
cached: 2.5,
reasoning: 30.0,
cache_creation: 5.0,
},
"gpt5.4": {
input: 5.0,
output: 20.0,
cached: 2.5,
reasoning: 30.0,
cache_creation: 5.0,
},
// T12: fallback pricing for gpt-5.4 mini variants
"gpt-5.4-mini": {
input: 1.5,
output: 6.0,
cached: 0.75,
reasoning: 9.0,
cache_creation: 1.5,
},
"gpt5.4-mini": {
input: 1.5,
output: 6.0,
cached: 0.75,
reasoning: 9.0,
cache_creation: 1.5,
},
// gpt-5.4 reasoning-effort variants share the gpt-5.4 tier (registry exposes
// -xhigh/-high/-medium/-low; without these rows they resolved to $0).
"gpt-5.4-xhigh": GPT_5_3_CODEX_PRICING,
"gpt-5.4-high": GPT_5_3_CODEX_PRICING,
"gpt-5.4-medium": GPT_5_3_CODEX_PRICING,
"gpt-5.4-low": GPT_5_3_CODEX_PRICING,
// GPT 5.3 Codex family (all same pricing tier)
"gpt-5.3-codex-spark": GPT_5_3_CODEX_PRICING,
"gpt-5.3-codex": GPT_5_3_CODEX_PRICING,

View File

@@ -17,6 +17,30 @@ export const GPT_5_5_PRICING = {
cache_creation: 5.0,
};
export const GPT_5_6_SOL_PRICING = {
input: 5.0,
output: 30.0,
cached: 0.5,
reasoning: 30.0,
cache_creation: 6.25,
};
export const GPT_5_6_TERRA_PRICING = {
input: 2.5,
output: 15.0,
cached: 0.25,
reasoning: 15.0,
cache_creation: 3.125,
};
export const GPT_5_6_LUNA_PRICING = {
input: 1.0,
output: 6.0,
cached: 0.1,
reasoning: 6.0,
cache_creation: 1.25,
};
export const CLAUDE_FABLE_5_PRICING = {
input: 15.0,
output: 75.0,

View File

@@ -11,16 +11,38 @@ import { z } from "zod";
* provider-agnostic pair of request fields and folds them onto the fields the existing
* mappers already read.
*
* Canonical effort vocabulary — the SAME five values used everywhere else in the codebase
* (`providerSpecificData.ts` CODEX_REASONING_EFFORT_VALUES, `vscode/reasoningMetadata.ts`
* KNOWN_REASONING_EFFORTS, `modelSpecs.ts`). We deliberately REUSE this set instead of
* inventing a parallel Low/Medium/High/Extra/Max enum that would diverge from the rest of
* the codebase.
* The provider-agnostic vocabulary remains five values. Provider-native additions such as
* Codex GPT-5.6 Max and Ultra are exposed separately without widening this request contract.
*/
export const CANONICAL_EFFORT_VALUES = ["none", "low", "medium", "high", "xhigh"] as const;
export type CanonicalEffort = (typeof CANONICAL_EFFORT_VALUES)[number];
/** Add provider-native GPT-5.6 effort levels without widening the global request vocabulary. */
export function extendCodexGpt56EffortValues(
provider: string | null | undefined,
model: string | null | undefined,
baseValues: readonly string[]
): string[] {
const values = [...baseValues];
const normalizedProvider = provider?.trim().toLowerCase();
const normalizedModel = model
?.trim()
.toLowerCase()
.replace(/^(?:codex|cx)\//, "");
if (!normalizedModel || (normalizedProvider !== "codex" && normalizedProvider !== "cx")) {
return values;
}
const match = normalizedModel.match(
/^gpt-5\.6-(sol|terra|luna)(?:-(?:none|low|medium|high|xhigh|max|ultra))?$/
);
if (!match) return values;
const additions = match[1] === "luna" ? ["max"] : ["max", "ultra"];
return [...new Set([...values, ...additions])];
}
/**
* UI-facing tier synonyms mapped onto the canonical set. The issue (#6241) requested a
* 5-tier UI vocabulary (Low / Medium / High / Extra / Max); that request collapses onto
@@ -95,18 +117,13 @@ export function normalizeReasoningRequest<T>(body: T): T {
const reasoning = body.reasoning;
const clientSetReasoningEffort = body.reasoning_effort !== undefined;
const clientSetReasoningObjEffort =
isPlainObject(reasoning) && reasoning.effort !== undefined;
const clientSetReasoningObjEffort = isPlainObject(reasoning) && reasoning.effort !== undefined;
const next: Record<string, unknown> = { ...body };
// Canonical effort → the fields the mappers read. Skip entirely if the client already
// expressed a reasoning effort (either shape) so client intent is preserved.
if (
canonicalEffort !== undefined &&
!clientSetReasoningEffort &&
!clientSetReasoningObjEffort
) {
if (canonicalEffort !== undefined && !clientSetReasoningEffort && !clientSetReasoningObjEffort) {
next.reasoning_effort = canonicalEffort;
next.reasoning = {
...(isPlainObject(reasoning) ? reasoning : {}),

View File

@@ -0,0 +1,395 @@
/**
* Codex catalog revalidation (Codex provider only)
*
* Runs scrub + live re-sync only in three cases:
* 1) first-start — no version marker yet
* 2) upgrade — app version marker changed after update/reboot
* 3) init — setup/onboarding just completed (explicit trigger)
*
* Success log (single line): kill deprecated models complete.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { isCodexDiscoveryModelExcluded } from "@/shared/services/codexDiscoveryPolicy";
import {
getSyncedAvailableModelsForConnection,
replaceSyncedAvailableModelsForConnection,
type SyncedAvailableModel,
} from "@/lib/db/models";
import { getProviderConnections } from "@/lib/db/providers";
import { getSettings, updateSettings } from "@/lib/db/settings";
export const CODEX_CATALOG_REVALIDATED_VERSION_KEY = "codex_catalog_revalidated_version";
export type CodexCatalogRevalidationReason = "first-start" | "upgrade" | "init";
type AppVersionOptions = {
runtimeRoot?: string;
packageVersion?: string | null;
};
function readNonEmptyTextFile(filePath: string): string | null {
try {
const value = readFileSync(filePath, "utf8").trim();
return value || null;
} catch {
return null;
}
}
function readInstalledPackageVersion(runtimeRoot: string): string | null {
try {
const pkg = JSON.parse(readFileSync(path.join(runtimeRoot, "package.json"), "utf8")) as {
version?: unknown;
};
return typeof pkg.version === "string" && pkg.version.trim() ? pkg.version.trim() : null;
} catch {
return null;
}
}
/** Resolve a stable, source-qualified app identity for upgrade detection. */
export function resolveCodexCatalogAppVersion(
env: NodeJS.ProcessEnv = process.env,
options: AppVersionOptions = {}
): string | null {
for (const value of [env.OMNIROUTE_BUILD_SHA, env.BUILD_SHA]) {
if (typeof value === "string" && value.trim()) return `build:${value.trim()}`;
}
const runtimeRoot = options.runtimeRoot || process.cwd();
const buildSha = readNonEmptyTextFile(path.join(runtimeRoot, "BUILD_SHA"));
if (buildSha) return `build:${buildSha}`;
for (const buildIdPath of [
path.join(runtimeRoot, ".build", "next", "BUILD_ID"),
path.join(runtimeRoot, ".next", "BUILD_ID"),
]) {
const buildId = readNonEmptyTextFile(buildIdPath);
if (buildId) return `next:${buildId}`;
}
const envPackageVersion = env.npm_package_version || env.OMNIROUTE_VERSION;
if (typeof envPackageVersion === "string" && envPackageVersion.trim()) {
return `pkg:${envPackageVersion.trim()}`;
}
const hasPackageVersionOverride = Object.prototype.hasOwnProperty.call(options, "packageVersion");
const packageVersion = hasPackageVersionOverride
? options.packageVersion
: readInstalledPackageVersion(runtimeRoot);
return typeof packageVersion === "string" && packageVersion.trim()
? `pkg:${packageVersion.trim()}`
: null;
}
/**
* Pure: map stored marker + current version → boot trigger, or null to skip.
* - no marker → first-start
* - marker !== version → upgrade
* - else → null (do nothing on this boot)
*/
export function resolveBootRevalidationReason(
previousVersion: string | null | undefined,
appVersion: string
): CodexCatalogRevalidationReason | null {
if (!previousVersion || !String(previousVersion).trim()) return "first-start";
if (String(previousVersion).trim() !== appVersion) return "upgrade";
return null;
}
/** Pure helper: drop denylisted ids from a synced model list. */
export function scrubSyncedModelsWithCodexDenylist(models: SyncedAvailableModel[]): {
kept: SyncedAvailableModel[];
removedIds: string[];
} {
const removedIds: string[] = [];
const kept: SyncedAvailableModel[] = [];
for (const model of models) {
if (!model?.id) continue;
if (isCodexDiscoveryModelExcluded({ id: model.id })) {
removedIds.push(model.id);
continue;
}
kept.push(model);
}
return { kept, removedIds };
}
export type CodexCatalogScrubResult = {
connections: number;
connectionsChanged: number;
modelsRemoved: number;
removedIds: string[];
};
/** Offline pass: rewrite persisted Codex synced catalogs through the denylist. */
export async function scrubCodexPersistedCatalogs(): Promise<CodexCatalogScrubResult> {
const connections = await getProviderConnections({ provider: "codex" });
const removedIds = new Set<string>();
let connectionsChanged = 0;
for (const connection of connections) {
const connectionId = String(connection.id || "");
if (!connectionId) continue;
const existing = await getSyncedAvailableModelsForConnection("codex", connectionId);
if (existing.length === 0) continue;
const { kept, removedIds: removed } = scrubSyncedModelsWithCodexDenylist(existing);
if (removed.length === 0) continue;
await replaceSyncedAvailableModelsForConnection("codex", connectionId, kept);
connectionsChanged += 1;
for (const id of removed) removedIds.add(id);
}
return {
connections: connections.length,
connectionsChanged,
modelsRemoved: removedIds.size,
removedIds: Array.from(removedIds).sort(),
};
}
async function listActiveCodexConnectionIds(): Promise<Array<{ id: string; name?: string }>> {
const connections = await getProviderConnections({ provider: "codex" });
return connections
.filter((conn) => conn.isActive !== false)
.map((conn) => ({
id: String(conn.id),
name: typeof conn.name === "string" ? conn.name : undefined,
}))
.filter((conn) => conn.id.length > 0);
}
export async function waitForLoopbackHttpReady(options?: {
apiBaseUrl?: string;
maxWaitMs?: number;
pollMs?: number;
}): Promise<void> {
const maxWaitMs = options?.maxWaitMs ?? 15_000;
const pollMs = options?.pollMs ?? 50;
const { fetchModelSyncInternal, resolveModelSyncInternalBaseUrl } =
await import("./modelSyncScheduler");
const baseUrl = resolveModelSyncInternalBaseUrl(options?.apiBaseUrl);
const deadline = Date.now() + maxWaitMs;
let lastErr: unknown;
while (Date.now() < deadline) {
try {
const res = await fetchModelSyncInternal(
`${baseUrl}/api/providers/__readiness_probe__/models`,
{
redirect: "error",
signal: AbortSignal.timeout(1_500),
}
);
if (res.status >= 200 && res.status < 600) return;
} catch (err) {
lastErr = err;
}
await new Promise((r) => setTimeout(r, pollMs));
}
throw new Error(
`loopback not ready within ${maxWaitMs}ms: ${
lastErr instanceof Error ? lastErr.message : String(lastErr)
}`
);
}
export async function liveResyncCodexConnections(
apiBaseUrl?: string
): Promise<{ attempted: number; succeeded: number }> {
const connections = await listActiveCodexConnectionIds();
if (connections.length === 0) {
return { attempted: 0, succeeded: 0 };
}
const { buildModelSyncInternalHeaders, fetchModelSyncInternal, resolveModelSyncInternalBaseUrl } =
await import("./modelSyncScheduler");
const base = resolveModelSyncInternalBaseUrl(apiBaseUrl);
const results = await Promise.allSettled(
connections.map(async (conn) => {
const res = await fetchModelSyncInternal(
`${base}/api/providers/${conn.id}/sync-models?quiet=1`,
{
method: "POST",
redirect: "error",
headers: {
"Content-Type": "application/json",
...buildModelSyncInternalHeaders(),
},
}
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return true;
})
);
const succeeded = results.filter((r) => r.status === "fulfilled").length;
return { attempted: connections.length, succeeded };
}
async function readPreviousVersionMarker(): Promise<string | null> {
try {
const settings = await getSettings();
const raw = settings?.[CODEX_CATALOG_REVALIDATED_VERSION_KEY];
return typeof raw === "string" && raw.trim() ? raw.trim() : null;
} catch {
return null;
}
}
async function writeVersionMarker(appVersion: string): Promise<boolean> {
try {
await updateSettings({ [CODEX_CATALOG_REVALIDATED_VERSION_KEY]: appVersion });
return true;
} catch {
return false;
}
}
export type CodexCatalogRevalidationOutcome = {
complete: boolean;
attempted: number;
succeeded: number;
};
export async function executeCodexCatalogRevalidation(options: {
appVersion: string | null;
scrub: () => Promise<unknown>;
waitForReady: () => Promise<void>;
liveResync: () => Promise<{ attempted: number; succeeded: number }>;
writeMarker: (appVersion: string) => Promise<boolean>;
logSuccess: () => void;
}): Promise<CodexCatalogRevalidationOutcome> {
await options.scrub();
try {
await options.waitForReady();
} catch {
return { complete: false, attempted: 0, succeeded: 0 };
}
const syncResult = await options.liveResync();
if (syncResult.succeeded !== syncResult.attempted) {
return { complete: false, ...syncResult };
}
if (!options.appVersion) return { complete: false, ...syncResult };
const markerWritten = await options.writeMarker(options.appVersion);
if (!markerWritten) return { complete: false, ...syncResult };
options.logSuccess();
return { complete: true, ...syncResult };
}
type CodexCatalogRevalidationRequest = {
apiBaseUrl?: string;
reason: CodexCatalogRevalidationReason;
};
export function createCodexCatalogRevalidationCoordinator(
run: (options: CodexCatalogRevalidationRequest) => Promise<void>
): (options: CodexCatalogRevalidationRequest) => Promise<void> {
let activeRun: Promise<void> | null = null;
let activeReason: CodexCatalogRevalidationReason | null = null;
let queuedInit: CodexCatalogRevalidationRequest | null = null;
return (options) => {
if (activeRun) {
if (options.reason === "init" && activeReason !== "init") {
queuedInit = options;
}
return activeRun;
}
activeRun = (async () => {
let current: CodexCatalogRevalidationRequest | null = options;
let firstError: unknown;
try {
while (current) {
activeReason = current.reason;
try {
await run(current);
} catch (error) {
firstError ??= error;
}
current = queuedInit;
queuedInit = null;
}
} finally {
activeRun = null;
activeReason = null;
}
if (firstError) throw firstError;
})();
return activeRun;
};
}
/**
* Run scrub + live re-sync for an explicit reason, then mark version.
* Operator-facing success log is a single line.
*/
async function performCodexCatalogRevalidation(
options: CodexCatalogRevalidationRequest
): Promise<void> {
const appVersion = resolveCodexCatalogAppVersion();
const { resolveModelSyncInternalBaseUrl } = await import("./modelSyncScheduler");
const apiBaseUrl = resolveModelSyncInternalBaseUrl(options.apiBaseUrl);
await executeCodexCatalogRevalidation({
appVersion,
scrub: scrubCodexPersistedCatalogs,
waitForReady: () => waitForLoopbackHttpReady({ apiBaseUrl }),
liveResync: () => liveResyncCodexConnections(apiBaseUrl),
writeMarker: writeVersionMarker,
logSuccess: () => console.log("kill deprecated models complete."),
});
}
const requestCodexCatalogRevalidation = createCodexCatalogRevalidationCoordinator(
performCodexCatalogRevalidation
);
export function revalidateCodexCatalogs(options: CodexCatalogRevalidationRequest): Promise<void> {
return requestCodexCatalogRevalidation(options);
}
/** Boot path: only first-start or upgrade. */
export async function revalidateCodexCatalogsOnStartup(options?: {
apiBaseUrl?: string;
}): Promise<void> {
const appVersion = resolveCodexCatalogAppVersion();
const previousVersion = await readPreviousVersionMarker();
const reason = appVersion
? resolveBootRevalidationReason(previousVersion, appVersion)
: "first-start";
if (!reason) return;
await revalidateCodexCatalogs({ apiBaseUrl: options?.apiBaseUrl, reason });
}
function scheduleRun(run: () => Promise<void>): void {
const timer = setTimeout(() => {
void run().catch(() => {
// silent — success line only on full success
});
}, 0);
timer.unref?.();
}
/** Fire-and-forget boot schedule (first-start / upgrade only). */
export function scheduleCodexCatalogRevalidation(options?: { apiBaseUrl?: string }): void {
scheduleRun(() => revalidateCodexCatalogsOnStartup({ apiBaseUrl: options?.apiBaseUrl }));
}
/** Fire-and-forget after setup/onboarding completes. */
export function scheduleCodexCatalogRevalidationAfterInit(options?: { apiBaseUrl?: string }): void {
scheduleRun(() => revalidateCodexCatalogs({ apiBaseUrl: options?.apiBaseUrl, reason: "init" }));
}

View File

@@ -0,0 +1,30 @@
/** Exact Codex model ids retired after discovery merge. */
export const CODEX_DISCOVERY_EXCLUDED_IDS: ReadonlySet<string> = new Set([
// Reserved for one-off retired ids that do not share a clean prefix family.
]);
/**
* Codex model-id families retired after discovery merge. Delimiter-aware
* matching prevents prefixes such as `gpt-5.40` from being removed.
*/
export const CODEX_DISCOVERY_EXCLUDED_ID_PREFIXES: readonly string[] = ["gpt-5.4"];
export type CodexDiscoveryModelIdentity = {
id?: unknown;
};
export function isCodexDiscoveryModelExcluded(model: CodexDiscoveryModelIdentity): boolean {
const id = typeof model?.id === "string" ? model.id.trim().toLowerCase() : "";
if (!id) return true;
if (CODEX_DISCOVERY_EXCLUDED_IDS.has(id)) return true;
return CODEX_DISCOVERY_EXCLUDED_ID_PREFIXES.some((prefix) => {
const normalizedPrefix = prefix.toLowerCase();
return (
id === normalizedPrefix ||
id.startsWith(`${normalizedPrefix}-`) ||
id.startsWith(`${normalizedPrefix}_`) ||
id.startsWith(`${normalizedPrefix}.`)
);
});
}

View File

@@ -9,6 +9,7 @@
*/
import { randomUUID } from "node:crypto";
import { Agent, buildConnector, fetch as undiciFetch, type Dispatcher } from "undici";
import { getSettings, updateSettings } from "@/lib/localDb";
import { getRuntimePorts } from "@/lib/runtime/ports";
@@ -16,13 +17,15 @@ const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const MODEL_SYNC_SETTING_KEY = "model_sync_last_run";
const MODEL_SYNC_INTERNAL_AUTH_HEADER = "x-model-sync-internal-auth";
const { dashboardPort } = getRuntimePorts();
function normalizeInternalBasePath(value: string | undefined): string {
const trimmed = value?.trim();
if (!trimmed || trimmed === "/") return "";
if (!trimmed.startsWith("/") || /[?#\\]/.test(trimmed)) return "";
const INTERNAL_BASE_URL =
process.env.BASE_URL ||
process.env.NEXT_PUBLIC_BASE_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
`http://127.0.0.1:${dashboardPort}`;
const segments = trimmed.split("/").filter(Boolean);
if (segments.some((segment) => segment === "." || segment === "..")) return "";
return `/${segments.join("/")}`;
}
/**
* Trusted origin for server-internal self-fetches (model sync, auto-discovery).
@@ -34,9 +37,80 @@ const INTERNAL_BASE_URL =
* this loopback/env-pinned origin instead.
*/
export function getModelSyncInternalBaseUrl(): string {
return INTERNAL_BASE_URL;
return resolveModelSyncInternalBaseUrl();
}
export function resolveModelSyncInternalBaseUrl(_candidate?: string): string {
const { dashboardPort } = getRuntimePorts();
const nativeTls = process.env.OMNIROUTE_INTERNAL_SCHEME === "https";
const origin = nativeTls
? `https://localhost:${dashboardPort}`
: `http://127.0.0.1:${dashboardPort}`;
return `${origin}${normalizeInternalBasePath(process.env.OMNIROUTE_BASE_PATH)}`;
}
export function createPinnedModelSyncTlsConnector(
connect: buildConnector.connector = buildConnector({ servername: "localhost" })
): buildConnector.connector {
return (options, callback) =>
connect(
{
...options,
host: "localhost",
hostname: "127.0.0.1",
servername: "localhost",
},
callback
);
}
let pinnedModelSyncTlsDispatcher: Dispatcher | null = null;
function getPinnedModelSyncTlsDispatcher(): Dispatcher {
if (!pinnedModelSyncTlsDispatcher) {
pinnedModelSyncTlsDispatcher = new Agent({
connect: createPinnedModelSyncTlsConnector(),
connections: 8,
pipelining: 0,
});
}
return pinnedModelSyncTlsDispatcher;
}
const fetchWithDispatcher = undiciFetch as unknown as (
input: RequestInfo | URL,
init: RequestInit & { dispatcher: Dispatcher }
) => Promise<Response>;
export const fetchModelSyncInternal: typeof fetch = async (input, init = {}) => {
const inputUrl =
typeof input === "string" || input instanceof URL ? new URL(input) : new URL(input.url);
const expectedBase = new URL(getModelSyncInternalBaseUrl());
if (
inputUrl.protocol !== expectedBase.protocol ||
inputUrl.hostname !== expectedBase.hostname ||
inputUrl.port !== expectedBase.port ||
inputUrl.username ||
inputUrl.password
) {
throw new TypeError("model sync internal fetch must target the active dashboard listener");
}
const basePath = expectedBase.pathname === "/" ? "" : expectedBase.pathname;
if (basePath && inputUrl.pathname !== basePath && !inputUrl.pathname.startsWith(`${basePath}/`)) {
throw new TypeError("model sync internal fetch must stay under the configured base path");
}
const requestInit = { ...init, redirect: "error" as const };
if (inputUrl.protocol === "https:") {
return fetchWithDispatcher(inputUrl, {
...requestInit,
dispatcher: getPinnedModelSyncTlsDispatcher(),
});
}
return globalThis.fetch(inputUrl.href, requestInit);
};
const globalState = globalThis as typeof globalThis & {
__omnirouteModelSyncInternalAuthToken?: string;
};
@@ -78,14 +152,22 @@ async function getAutoSyncConnections(): Promise<
try {
const { getProviderConnections } = await import("@/lib/localDb");
const connections = await getProviderConnections();
return connections.filter((conn: any) => {
if (!conn.isActive && conn.isActive !== undefined) return false;
const autoSyncConnections: Array<{ id: string; provider: string; name?: string }> = [];
for (const conn of connections) {
if (!conn.isActive && conn.isActive !== undefined) continue;
const psd =
conn.providerSpecificData && typeof conn.providerSpecificData === "object"
? conn.providerSpecificData
? (conn.providerSpecificData as Record<string, unknown>)
: {};
return psd.autoSync === true;
});
if (psd.autoSync !== true) continue;
if (typeof conn.id !== "string" || typeof conn.provider !== "string") continue;
autoSyncConnections.push({
id: conn.id,
provider: conn.provider,
...(typeof conn.name === "string" ? { name: conn.name } : {}),
});
}
return autoSyncConnections;
} catch (err) {
console.warn("[ModelSync] Failed to load connections:", (err as Error).message);
return [];
@@ -101,13 +183,17 @@ async function syncConnectionModels(
baseUrl: string
): Promise<boolean> {
try {
const res = await fetch(`${baseUrl}/api/providers/${connectionId}/sync-models`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...buildModelSyncInternalHeaders(),
},
});
const res = await fetchModelSyncInternal(
`${baseUrl}/api/providers/${connectionId}/sync-models`,
{
method: "POST",
redirect: "error",
headers: {
"Content-Type": "application/json",
...buildModelSyncInternalHeaders(),
},
}
);
if (!res.ok) {
console.warn(
`[ModelSync] ${provider} (${connectionId.slice(0, 8)}): sync returned ${res.status}`
@@ -177,7 +263,7 @@ async function runSyncCycle(apiBaseUrl: string): Promise<void> {
* @param intervalMs — sync interval in milliseconds (default: 24h)
*/
export function startModelSyncScheduler(
apiBaseUrl = INTERNAL_BASE_URL,
apiBaseUrl = getModelSyncInternalBaseUrl(),
intervalMs = DEFAULT_INTERVAL_MS
): void {
if (schedulerTimer) {
@@ -189,15 +275,25 @@ export function startModelSyncScheduler(
const envHours = parseInt(process.env.MODEL_SYNC_INTERVAL_HOURS ?? "", 10);
const effectiveIntervalMs =
!isNaN(envHours) && envHours > 0 ? envHours * 60 * 60 * 1000 : intervalMs;
const trustedApiBaseUrl = resolveModelSyncInternalBaseUrl(apiBaseUrl);
console.log(`[ModelSync] Scheduler started — interval: ${effectiveIntervalMs / 3_600_000}h`);
// Run immediately on startup (staggered by 5s to avoid startup congestion)
const startupDelay = setTimeout(() => runSyncCycle(apiBaseUrl), 5_000);
const startupDelay = setTimeout(() => runSyncCycle(trustedApiBaseUrl), 5_000);
startupDelay.unref?.();
// Codex-only: revalidate catalog only on first-start or app upgrade (not every boot).
void import("./codexCatalogRevalidation")
.then(({ scheduleCodexCatalogRevalidation }) => {
scheduleCodexCatalogRevalidation({ apiBaseUrl: trustedApiBaseUrl });
})
.catch(() => {
// silent
});
// Then run on the regular interval
schedulerTimer = setInterval(() => runSyncCycle(apiBaseUrl), effectiveIntervalMs);
schedulerTimer = setInterval(() => runSyncCycle(trustedApiBaseUrl), effectiveIntervalMs);
schedulerTimer.unref?.();
}

View File

@@ -13,7 +13,7 @@ function isHttpUrl(value: string): boolean {
}
}
const CODEX_REASONING_EFFORT_VALUES = new Set(["none", "low", "medium", "high", "xhigh"]);
const CODEX_REASONING_EFFORT_VALUES = new Set(["none", "low", "medium", "high", "xhigh", "max"]);
const REQUEST_DEFAULT_SERVICE_TIER_VALUES = new Set(["default", "priority", "fast", "flex"]);
export function validateProviderSpecificData(
@@ -131,7 +131,7 @@ export function validateProviderSpecificData(
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"providerSpecificData.requestDefaults.reasoningEffort must be one of none, low, medium, high, xhigh",
"providerSpecificData.requestDefaults.reasoningEffort must be one of none, low, medium, high, xhigh, max",
path: ["requestDefaults", "reasoningEffort"],
});
}

View File

@@ -68,7 +68,7 @@ export const cliModelConfigSchema = z.object({
baseUrl: z.string().trim().min(1, "baseUrl and model are required"),
apiKey: z.string().nullable().optional(),
model: z.string().trim().min(1, "baseUrl and model are required"),
reasoningEffort: z.enum(["none", "low", "medium", "high", "xhigh"]).optional(),
reasoningEffort: z.enum(["none", "low", "medium", "high", "xhigh", "max", "ultra"]).optional(),
wireApi: z.enum(["chat", "responses"]).optional(),
modelMappings: z.record(z.string().trim().min(1), z.string().trim().min(1)).optional(),
});

View File

@@ -14,7 +14,6 @@ import {
} from "@/shared/constants/upstreamHeaders";
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
export function isHttpUrl(value: string): boolean {
try {
const parsed = new URL(value);
@@ -24,7 +23,14 @@ export function isHttpUrl(value: string): boolean {
}
}
export const CODEX_REASONING_EFFORT_VALUES = new Set(["none", "low", "medium", "high", "xhigh"]);
export const CODEX_REASONING_EFFORT_VALUES = new Set([
"none",
"low",
"medium",
"high",
"xhigh",
"max",
]);
export const REQUEST_DEFAULT_SERVICE_TIER_VALUES = new Set(["default", "priority", "fast", "flex"]);
@@ -200,4 +206,4 @@ export const confirmedAccountSchema = z.object({
fingerprint: z.string().min(1).max(100),
});
export type ConfirmedAccount = z.infer<typeof confirmedAccountSchema>;
export type ConfirmedAccount = z.infer<typeof confirmedAccountSchema>;

View File

@@ -34,6 +34,7 @@ import {
} from "@omniroute/open-sse/config/constants.ts";
import { getTargetFormat } from "@omniroute/open-sse/services/provider.ts";
import {
getModelsByProviderId,
getModelTargetFormat,
PROVIDER_ID_TO_ALIAS,
} from "@omniroute/open-sse/config/providerModels.ts";
@@ -311,11 +312,7 @@ export async function handleChat(
}
}
if (b.max_tokens !== undefined) {
if (
typeof b.max_tokens !== "number" ||
!Number.isInteger(b.max_tokens) ||
b.max_tokens < 1
) {
if (typeof b.max_tokens !== "number" || !Number.isInteger(b.max_tokens) || b.max_tokens < 1) {
return badParam("max_tokens", "must be a positive integer");
}
}
@@ -402,9 +399,14 @@ export async function handleChat(
// Image-only models live in IMAGE_PROVIDERS (open-sse/config/imageRegistry.ts)
// and are served by /v1/images/generations. Forwarding them to a chat upstream
// yielded confusing raw provider 400s (e.g. HuggingFace: "not a chat model").
// getImageModelEntry returns non-null only for models registered in the image
// registry — chat-only models (openai/gpt-4o, etc.) resolve to null and pass.
if (getImageModelEntry(modelStr)) {
// Models such as Codex GPT-5.5 support both chat and image generation, so an
// image-registry match is only image-only when the same provider/model pair is
// absent from the chat catalog.
const imageModel = getImageModelEntry(modelStr);
const isChatCatalogModel = imageModel
? getModelsByProviderId(imageModel.provider).some((model) => model.id === imageModel.model)
: false;
if (imageModel && !isChatCatalogModel) {
log.warn("CHAT", `Rejecting image-generation model on chat endpoint: ${modelStr}`);
return errorResponse(
HTTP_STATUS.BAD_REQUEST,

View File

@@ -698,7 +698,7 @@ test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests",
assert.equal(call.headers.Version, getCodexClientVersion());
assert.equal(call.headers["Openai-Beta"], "responses=experimental");
assert.equal(call.headers["X-Codex-Beta-Features"], "responses_websockets");
assert.equal(call.headers["User-Agent"], "codex-cli/0.144.0 (Windows 10.0.26200; x64)");
assert.equal(call.headers["User-Agent"], "codex-cli/0.144.1 (Windows 10.0.26200; x64)");
assert.equal(call.headers["x-codex-window-id"], "conv_codex_fingerprint:0");
assert.ok(call.headers["x-client-request-id"], "expected Codex request id header");
assert.ok(call.headers["x-codex-turn-metadata"], "expected Codex turn metadata header");

View File

@@ -59,7 +59,7 @@ Authorization: Bearer {{OMNIROUTE_API_KEY}}
Content-Type: application/json
{
"model": "codex/gpt-5.4",
"model": "codex/gpt-5.6-sol",
"prompt": "A happy red kitten, highly detailed fur, studio lighting",
"n": 1,
"size": "1024x1024",
@@ -90,4 +90,4 @@ Content-Type: application/json
"stream": true
}
###
###

View File

@@ -28,7 +28,7 @@
"apiKey": {
"Accept": "text/event-stream",
"Content-Type": "application/json",
"User-Agent": "claude-cli/2.1.195 (external, sdk-cli)",
"User-Agent": "claude-cli/2.1.207 (external, sdk-cli)",
"X-Stainless-Arch": "<ARCH>",
"X-Stainless-Lang": "js",
"X-Stainless-OS": "MacOS",
@@ -47,7 +47,7 @@
"nonStream": {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "claude-cli/2.1.195 (external, sdk-cli)",
"User-Agent": "claude-cli/2.1.207 (external, sdk-cli)",
"X-Stainless-Arch": "<ARCH>",
"X-Stainless-Lang": "js",
"X-Stainless-OS": "MacOS",
@@ -66,7 +66,7 @@
"oauth": {
"Accept": "text/event-stream",
"Content-Type": "application/json",
"User-Agent": "claude-cli/2.1.195 (external, sdk-cli)",
"User-Agent": "claude-cli/2.1.207 (external, sdk-cli)",
"X-Stainless-Arch": "<ARCH>",
"X-Stainless-Lang": "js",
"X-Stainless-OS": "MacOS",
@@ -713,7 +713,7 @@
"Anthropic-Dangerous-Direct-Browser-Access": "true",
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json",
"User-Agent": "claude-cli/2.1.195 (external, cli)",
"User-Agent": "claude-cli/2.1.207 (external, cli)",
"X-App": "cli",
"X-Stainless-Arch": "<ARCH>",
"X-Stainless-Helper-Method": "stream",
@@ -731,7 +731,7 @@
"Anthropic-Dangerous-Direct-Browser-Access": "true",
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json",
"User-Agent": "claude-cli/2.1.195 (external, cli)",
"User-Agent": "claude-cli/2.1.207 (external, cli)",
"X-App": "cli",
"X-Stainless-Arch": "<ARCH>",
"X-Stainless-Helper-Method": "stream",
@@ -750,7 +750,7 @@
"Anthropic-Dangerous-Direct-Browser-Access": "true",
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json",
"User-Agent": "claude-cli/2.1.195 (external, cli)",
"User-Agent": "claude-cli/2.1.207 (external, cli)",
"X-App": "cli",
"X-Stainless-Arch": "<ARCH>",
"X-Stainless-Helper-Method": "stream",
@@ -966,16 +966,16 @@
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"Openai-Beta": "responses=experimental",
"User-Agent": "codex-cli/0.144.0 (<OS>; <ARCH>)",
"Version": "0.144.0",
"User-Agent": "codex-cli/0.144.1 (<OS>; <ARCH>)",
"Version": "0.144.1",
"X-Codex-Beta-Features": "responses_websockets"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"Openai-Beta": "responses=experimental",
"User-Agent": "codex-cli/0.144.0 (<OS>; <ARCH>)",
"Version": "0.144.0",
"User-Agent": "codex-cli/0.144.1 (<OS>; <ARCH>)",
"Version": "0.144.1",
"X-Codex-Beta-Features": "responses_websockets"
},
"oauth": {
@@ -983,8 +983,8 @@
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json",
"Openai-Beta": "responses=experimental",
"User-Agent": "codex-cli/0.144.0 (<OS>; <ARCH>)",
"Version": "0.144.0",
"User-Agent": "codex-cli/0.144.1 (<OS>; <ARCH>)",
"Version": "0.144.1",
"X-Codex-Beta-Features": "responses_websockets"
}
},

View File

@@ -9,7 +9,7 @@ const { BaseExecutor, buildRequest, combosDb, handleChat, resetStorage, waitFor,
const providersDb = await import("../../src/lib/db/providers.ts");
const handoffDb = await import("../../src/lib/db/contextHandoffs.ts");
function buildResponsesResponse(text = "ok", model = "gpt-5.4") {
function buildResponsesResponse(text = "ok", model = "gpt-5.6-sol") {
return new Response(
JSON.stringify({
id: "resp_context_relay",
@@ -112,7 +112,7 @@ test("handleChat generates and injects context-relay handoffs across Codex accou
handoffThreshold: 0.85,
maxMessagesForSummary: 12,
},
models: ["codex/gpt-5.4"],
models: ["codex/gpt-5.6-sol"],
});
const upstreamBodies = [];
@@ -142,12 +142,12 @@ test("handleChat generates and injects context-relay handoffs across Codex accou
taskProgress: "Runtime and UI are wired; tests are next",
activeEntities: ["open-sse/services/combo.ts", "src/sse/handlers/chat.ts"],
}),
"gpt-5.4"
"gpt-5.6-sol"
);
}
upstreamBodies.push({ body, serializedBody });
return buildResponsesResponse("relay-success", "gpt-5.4");
return buildResponsesResponse("relay-success", "gpt-5.6-sol");
};
const firstResponse = await handleChat(
@@ -226,7 +226,7 @@ test("handleChat injects context-relay handoffs during live failover for Respons
handoffThreshold: 0.85,
maxMessagesForSummary: 12,
},
models: ["codex/gpt-5.4"],
models: ["codex/gpt-5.6-sol"],
});
const upstreamBodies = [];
@@ -255,7 +255,7 @@ test("handleChat injects context-relay handoffs during live failover for Respons
taskProgress: "Continue after the first account is exhausted",
activeEntities: ["src/sse/handlers/chat.ts", "open-sse/services/contextHandoff.ts"],
}),
"gpt-5.4"
"gpt-5.6-sol"
);
}
@@ -271,7 +271,7 @@ test("handleChat injects context-relay handoffs during live failover for Respons
}
}
return buildResponsesResponse("relay-success", "gpt-5.4");
return buildResponsesResponse("relay-success", "gpt-5.6-sol");
};
const firstResponse = await handleChat(
@@ -331,9 +331,7 @@ test("handleChat injects context-relay handoffs during live failover for Respons
assert.equal(secondResponse.status, 200);
const relayedSecondaryCall = upstreamBodies.find(
(call) =>
call.authHeader === "Bearer token-b" &&
typeof call.body.instructions === "string"
(call) => call.authHeader === "Bearer token-b" && typeof call.body.instructions === "string"
);
assert.ok(relayedSecondaryCall, "secondary account should receive a request after primary 429");

View File

@@ -71,3 +71,23 @@ test("POST /v1/chat/completions with a chat model still reaches routing (guard i
assert.doesNotMatch(msg, /image-generation model/i, "chat model must not trip the image guard");
}
});
test("POST /v1/chat/completions allows a model registered for both chat and image generation", async () => {
const request = buildRequest({
body: {
model: "codex/gpt-5.6-sol",
messages: [{ role: "user", content: "hi" }],
},
});
const res = await handleChat(request);
if (res.status === 400) {
const body = (await res.json()) as { error?: { message?: string } };
const msg = body?.error?.message || JSON.stringify(body);
assert.doesNotMatch(
msg,
/image-generation model/i,
"a model present in the chat catalog must not trip the image-only guard"
);
}
});

View File

@@ -0,0 +1,260 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TlsFetchOptions } from "../../open-sse/services/chatgptTlsClient.ts";
const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } =
await import("../../open-sse/executors/chatgpt-web.ts");
const { __setTlsFetchOverrideForTesting } =
await import("../../open-sse/services/chatgptTlsClient.ts");
function makeHeaders(values: Record<string, string> = {}): Headers {
const headers = new Headers();
for (const [name, value] of Object.entries(values)) headers.set(name, value);
return headers;
}
function sseText(events: unknown[]): string {
return `${events.map((event) => `data: ${JSON.stringify(event)}\r\n\r\n`).join("")}data: [DONE]\r\n\r\n`;
}
type ResumeRequest = {
body: { conversation_id?: string; offset?: number };
headers: Record<string, string>;
};
function installHandoffMock(
finalText: string,
options: { firstResumeStatus?: number } = {}
): {
calls: { conversationDetail: number; resume: ResumeRequest[] };
restore: () => void;
} {
const calls = {
conversationDetail: 0,
resume: [] as ResumeRequest[],
};
__setTlsFetchOverrideForTesting(async (url: string, request: TlsFetchOptions = {}) => {
const target = String(url);
const json = (body: unknown, status = 200) => ({
status,
headers: makeHeaders({ "Content-Type": "application/json" }),
text: JSON.stringify(body),
body: null,
});
if (
(target === "https://chatgpt.com/" || target === "https://chatgpt.com") &&
(request.method ?? "GET") === "GET"
) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/html" }),
text: '<html data-build="prod-test"><script src="/_next/static/chunks/main.js"></script></html>',
body: null,
};
}
if (target.includes("/api/auth/session")) {
return json({
accessToken: "jwt-test",
expires: new Date(Date.now() + 3_600_000).toISOString(),
user: { id: "account-test" },
});
}
if (target.includes("/sentinel/chat-requirements")) {
return json({ token: "requirements-token", proofofwork: { required: false } });
}
if (target.endsWith("/backend-api/f/conversation/resume")) {
const body = JSON.parse(request.body ?? "{}") as ResumeRequest["body"];
calls.resume.push({ body, headers: request.headers ?? {} });
if (options.firstResumeStatus && calls.resume.length === 1) {
return {
status: options.firstResumeStatus,
headers: makeHeaders({ "Content-Type": "text/plain" }),
text: "not ready",
body: null,
};
}
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
text: sseText([
{
conversation_id: "conversation-handoff",
message: {
id: "assistant-final",
author: { role: "assistant" },
content: { content_type: "text", parts: [finalText] },
status: "in_progress",
},
},
{
conversation_id: "conversation-handoff",
message: {
id: "assistant-final",
author: { role: "assistant" },
content: { content_type: "text", parts: [finalText] },
status: "finished_successfully",
end_turn: true,
},
},
]),
body: null,
};
}
if (target.endsWith("/backend-api/f/conversation")) {
return {
status: 200,
headers: makeHeaders({ "Content-Type": "text/event-stream" }),
text: sseText([
{
type: "resume_conversation_token",
token: "resume-token",
conversation_id: "conversation-handoff",
},
{
type: "stream_handoff",
conversation_id: "conversation-handoff",
turn_exchange_id: "turn-handoff",
options: [
{ type: "resume_sse_endpoint", topic_id: "conversation-turn-handoff" },
{ type: "subscribe_ws_topic", topic_id: "conversation-turn-handoff" },
],
},
]),
body: null,
};
}
if (/\/backend-api\/conversation\/[^/?#]+$/.test(target)) {
calls.conversationDetail++;
return json(
{
detail: {
message: "You do not have access to this temporary conversation.",
code: "conversation_not_found",
},
},
404
);
}
// Browser warmup requests are non-fatal, but returning 200 keeps test logs quiet.
if (
target.includes("/backend-api/me") ||
target.includes("/backend-api/conversations?") ||
target.includes("/backend-api/models?")
) {
return json({});
}
return { status: 404, headers: makeHeaders(), text: "not mocked", body: null };
});
return {
calls,
restore() {
__setTlsFetchOverrideForTesting(null);
},
};
}
test("ChatGPT Web Pro models resume Temporary Chat handoffs through native SSE", async (t) => {
for (const model of ["gpt-5.6-pro", "gpt-5.5-pro", "gpt-5.5-pro-extended"]) {
await t.test(model, async () => {
__resetChatGptWebCachesForTesting();
const expected = `RESUMED_${model}`;
const mock = installHandoffMock(expected);
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model,
body: { messages: [{ role: "user", content: "hard problem" }] },
stream: false,
credentials: { apiKey: `cookie-${model}` },
signal: AbortSignal.timeout(20_000),
log: null,
});
assert.equal(result.response.status, 200);
const response = await result.response.json();
assert.equal(response.choices[0].message.content, expected);
assert.equal(mock.calls.resume.length, 1);
assert.deepEqual(mock.calls.resume[0].body, {
conversation_id: "conversation-handoff",
offset: 0,
});
assert.equal(mock.calls.resume[0].headers["x-conduit-token"], "resume-token");
assert.equal(mock.calls.conversationDetail, 0);
} finally {
mock.restore();
}
});
}
});
test("ChatGPT Web handoff retries the next resume offset after a 404", async () => {
__resetChatGptWebCachesForTesting();
const mock = installHandoffMock("OFFSET_ONE_OK", { firstResumeStatus: 404 });
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.6-pro",
body: { messages: [{ role: "user", content: "hard problem" }] },
stream: false,
credentials: { apiKey: "cookie-offset" },
signal: AbortSignal.timeout(20_000),
log: null,
});
assert.equal(result.response.status, 200);
const response = await result.response.json();
assert.equal(response.choices[0].message.content, "OFFSET_ONE_OK");
assert.deepEqual(
mock.calls.resume.map((call) => call.body.offset),
[0, 1]
);
assert.equal(mock.calls.conversationDetail, 0);
} finally {
mock.restore();
}
});
test("ChatGPT Web streaming appends the native resumed Pro answer", async () => {
__resetChatGptWebCachesForTesting();
const mock = installHandoffMock("STREAM_RESUME_OK");
try {
const executor = new ChatGptWebExecutor();
const result = await executor.execute({
model: "gpt-5.5-pro-extended",
body: { messages: [{ role: "user", content: "hard problem" }], stream: true },
stream: true,
credentials: { apiKey: "cookie-stream" },
signal: AbortSignal.timeout(20_000),
log: null,
});
assert.equal(result.response.status, 200);
const responseText = await result.response.text();
const content = responseText
.split("\n")
.filter((line) => line.startsWith("data: ") && line !== "data: [DONE]")
.map((line) => JSON.parse(line.slice(6)) as Record<string, unknown>)
.map((event) => {
const choices = event.choices as Array<{ delta?: { content?: string } }> | undefined;
return choices?.[0]?.delta?.content ?? "";
})
.join("");
assert.equal(content, "STREAM_RESUME_OK");
assert.equal(mock.calls.resume.length, 1);
assert.equal(mock.calls.conversationDetail, 0);
} finally {
mock.restore();
}
});

View File

@@ -1245,21 +1245,26 @@ test("Provider registry: chatgpt-web exposes the current ChatGPT Web model catal
assert.equal(entry.authHeader, "cookie");
const ids = (entry.models || []).map((m) => m.id);
// Public OmniRoute ids stay in historical dot form even though ChatGPT's
// backend routes use dash-form slugs. Retired GPT-5/GPT-5.1 entries should
// stay out of this list.
// Retired GPT-5.4 and older entries stay out of the advertised catalog.
assert.deepEqual(ids, [
"gpt-5.5-pro",
"gpt-5.6-pro",
"gpt-5.6-thinking",
"gpt-5.5-pro-extended",
"gpt-5.5-pro",
"gpt-5.5-thinking",
"gpt-5.5",
"gpt-5.4-pro",
"gpt-5.4-thinking",
"gpt-5.4-thinking-mini",
"gpt-5.3",
"gpt-5.3-mini",
"o3",
]);
assert.equal(
ids.some((id) => id.startsWith("gpt-5.4")),
false
);
const { MODEL_MAP } = await import("../../open-sse/executors/chatgpt-web/models.ts");
assert.equal(
Object.keys(MODEL_MAP).some((id) => id.startsWith("gpt-5.4") || id.startsWith("gpt-5-4")),
false
);
});
test("Executor MODEL_MAP: OmniRoute IDs translate to ChatGPT backend slugs", async () => {
@@ -1268,18 +1273,17 @@ test("Executor MODEL_MAP: OmniRoute IDs translate to ChatGPT backend slugs", asy
try {
const cases: Array<[string, string]> = [
// Public catalog ids.
["gpt-5.3", "gpt-5-3"],
["gpt-5.6-pro", "gpt-5-6-pro"],
["gpt-5.6-thinking", "gpt-5-6-thinking"],
["gpt-5.5-thinking", "gpt-5-5-thinking"],
["gpt-5.4-thinking-mini", "gpt-5-4-t-mini"],
["gpt-5.5", "gpt-5-5"],
["gpt-5.5-pro", "gpt-5-5-pro"],
["gpt-5.5-pro-extended", "gpt-5-5-pro"],
["gpt-5.4-pro", "gpt-5-4-pro"],
["o3", "o3"],
// Backend dash-form slugs are still accepted for direct provider/model callers.
["gpt-5-3", "gpt-5-3"],
["gpt-5-5-thinking", "gpt-5-5-thinking"],
["gpt-5-4-t-mini", "gpt-5-4-t-mini"],
["gpt-5-6-pro", "gpt-5-6-pro"],
["gpt-5-5-pro", "gpt-5-5-pro"],
["gpt-5-5-pro-extended", "gpt-5-5-pro"],
];
@@ -1309,15 +1313,12 @@ test("MODEL_MAP drift guard: every advertised catalog id reaches ChatGPT as a ba
const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts");
const ids = (getRegistryEntry("chatgpt-web")?.models || []).map((m) => m.id);
const expectedSlugById: Record<string, string> = {
"gpt-5.5-pro": "gpt-5-5-pro",
"gpt-5.6-pro": "gpt-5-6-pro",
"gpt-5.6-thinking": "gpt-5-6-thinking",
"gpt-5.5-pro-extended": "gpt-5-5-pro",
"gpt-5.5-pro": "gpt-5-5-pro",
"gpt-5.5-thinking": "gpt-5-5-thinking",
"gpt-5.5": "gpt-5-5",
"gpt-5.4-pro": "gpt-5-4-pro",
"gpt-5.4-thinking": "gpt-5-4-thinking",
"gpt-5.4-thinking-mini": "gpt-5-4-t-mini",
"gpt-5.3": "gpt-5-3",
"gpt-5.3-mini": "gpt-5-3-mini",
o3: "o3",
};
const m = installMockFetch();
@@ -1466,7 +1467,7 @@ test("thinking_effort: low/medium → PATCH with standard", async () => {
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.4-thinking",
model: "gpt-5.6-thinking",
body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: effort },
stream: false,
credentials: { apiKey: `cookie-${effort}` },
@@ -1475,7 +1476,7 @@ test("thinking_effort: low/medium → PATCH with standard", async () => {
});
assert.equal(m.calls.userConfig, 1, `effort=${effort} should issue exactly one PATCH`);
assert.match(m.calls.userConfigUrls[0], /thinking_effort=standard/, `${effort} → standard`);
assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-4-thinking/);
assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-6-thinking/);
} finally {
m.restore();
}
@@ -1501,12 +1502,8 @@ test("thinking_effort: instant model never triggers PATCH even with reasoning_ef
}
});
test("thinking_effort: bare chatgpt.com slug (e.g. gpt-5-4-t-mini) passed as model still PATCHes", async () => {
// Regression: the abbreviated dash-form slug "gpt-5-4-t-mini" doesn't
// carry the literal "thinking" substring, and isn't a key in MODEL_MAP
// (only its dot-form alias is), so a substring-only check would silently
// skip the PATCH for callers that send the chatgpt.com slug directly.
for (const bareSlug of ["gpt-5-4-t-mini", "gpt-5-5-thinking", "o3"]) {
test("thinking_effort: bare chatgpt.com thinking slugs still PATCH", async () => {
for (const bareSlug of ["gpt-5-6-thinking", "gpt-5-5-thinking", "o3"]) {
reset();
const m = installMockFetch();
try {
@@ -1559,7 +1556,7 @@ test("thinking_effort: providerSpecificData.thinkingEffort=extended overrides bo
try {
const executor = new ChatGptWebExecutor();
await executor.execute({
model: "gpt-5.4-thinking-mini",
model: "gpt-5.6-thinking",
body: {
messages: [{ role: "user", content: "hi" }],
reasoning_effort: "low", // would normally map to standard
@@ -1573,7 +1570,7 @@ test("thinking_effort: providerSpecificData.thinkingEffort=extended overrides bo
log: null,
});
assert.equal(m.calls.userConfig, 1);
assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-4-t-mini/);
assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-6-thinking/);
assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/);
} finally {
m.restore();
@@ -1676,12 +1673,12 @@ test("thinking_effort: PATCH failure is non-fatal — conversation request still
}
});
test("Image registry: cgpt-web/gpt-5.3-instant routes to ChatGPT Web image handler", async () => {
test("Image registry: cgpt-web/gpt-5.5 routes to ChatGPT Web image handler", async () => {
const { parseImageModel, getImageProvider } =
await import("../../open-sse/config/imageRegistry.ts");
const parsed = parseImageModel("cgpt-web/gpt-5.3-instant");
const parsed = parseImageModel("cgpt-web/gpt-5.5");
assert.equal(parsed.provider, "chatgpt-web");
assert.equal(parsed.model, "gpt-5.3-instant");
assert.equal(parsed.model, "gpt-5.5");
const provider = getImageProvider(parsed.provider);
assert.equal(provider.format, "chatgpt-web");
assert.equal(provider.authHeader, "cookie");

View File

@@ -36,12 +36,12 @@ test("Claude CLI version constants are in lockstep across all 4 sources", () =>
);
});
test("Claude CLI is pinned to the captured 2.1.195 release", () => {
assert.equal(id.CLAUDE_CODE_VERSION, "2.1.195");
test("Claude CLI is pinned to the captured 2.1.207 release", () => {
assert.equal(id.CLAUDE_CODE_VERSION, "2.1.207");
});
test("Codex client is pinned to the captured 0.144.0 release", () => {
assert.equal(codexCfg.getCodexClientVersion(), "0.144.0");
assert.equal(codexCfg.getCodexUserAgent(), "codex-cli/0.144.0 (Windows 10.0.26200; x64)");
assert.equal(codexCfg.getCodexDefaultHeaders().Version, "0.144.0");
test("Codex client is pinned to the captured 0.144.1 release", () => {
assert.equal(codexCfg.getCodexClientVersion(), "0.144.1");
assert.equal(codexCfg.getCodexUserAgent(), "codex-cli/0.144.1 (Windows 10.0.26200; x64)");
assert.equal(codexCfg.getCodexDefaultHeaders().Version, "0.144.1");
});

View File

@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
// Claude-Code identity version is hand-bumped in lockstep across several modules
// (2.1.158 → .187 → .195 …). A silent partial bump makes one surface advertise a stale
// (2.1.158 → .187 → .195 → .207 …). A silent partial bump makes one surface advertise a stale
// `claude-cli/<version>` and can break Anthropic identity gating. This guard fails on drift.
// (quota-share-hardening Phase 2 — gaps v3.8.42.)
const claudeIdentity = await import("../../open-sse/executors/claudeIdentity.ts");
@@ -13,7 +13,7 @@ const glmProvider = await import("../../open-sse/config/glmProvider.ts");
const CANONICAL = claudeIdentity.CLAUDE_CODE_VERSION;
// "claude-cli/2.1.195 (external, sdk-cli)" → "2.1.195". String ops only — never a RegExp over
// "claude-cli/2.1.207 (external, sdk-cli)" → "2.1.207". String ops only — never a RegExp over
// the value, per the project's anti-ReDoS contract.
function versionFromUserAgent(userAgent: string): string {
const afterSlash = userAgent.split("claude-cli/")[1] ?? "";

View File

@@ -17,3 +17,20 @@ test("cliModelConfigSchema accepts Codex xhigh reasoning effort", () => {
assert.equal(result.data.reasoningEffort, "xhigh");
}
});
test("cliModelConfigSchema accepts Codex max and ultra reasoning efforts", () => {
for (const reasoningEffort of ["max", "ultra"] as const) {
const result = cliModelConfigSchema.safeParse({
baseUrl: "http://localhost:20128/api/v1",
apiKey: "sk_omniroute",
model: "gpt-5.6-sol",
reasoningEffort,
wireApi: "responses",
});
assert.equal(result.success, true, reasoningEffort);
if (result.success) {
assert.equal(result.data.reasoningEffort, reasoningEffort);
}
}
});

View File

@@ -86,12 +86,12 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo
"codex",
{
Authorization: "Bearer token",
"User-Agent": "codex-cli/0.144.0 (Windows 10.0.26200; x64)",
"User-Agent": "codex-cli/0.144.1 (Windows 10.0.26200; x64)",
},
{ model: "gpt-5.5", messages: [], stream: true }
);
assert.equal(codex.headers["User-Agent"], "codex-cli/0.144.0 (Windows 10.0.26200; x64)");
assert.equal(codex.headers["User-Agent"], "codex-cli/0.144.1 (Windows 10.0.26200; x64)");
assert.deepEqual(Object.keys(JSON.parse(codex.bodyString)), ["model", "stream", "messages"]);
const copilot = applyFingerprint(

View File

@@ -17,7 +17,8 @@ const {
getClientIdentityProfileHeaders,
isClientIdentityProfileId,
} = await import("../../src/shared/constants/clientIdentityProfiles.ts");
const { isForbiddenCustomHeaderName } = await import("../../src/shared/constants/upstreamHeaders.ts");
const { isForbiddenCustomHeaderName } =
await import("../../src/shared/constants/upstreamHeaders.ts");
const { DefaultExecutor } = await import("../../open-sse/executors/default.ts");
const core = await import("../../src/lib/db/core.ts");
@@ -38,11 +39,11 @@ test("getClientIdentityProfileHeaders: unknown profile id falls back to no heade
test("getClientIdentityProfileHeaders: known CLI profiles expose their preset headers", () => {
const claudeCli = getClientIdentityProfileHeaders("claude-cli");
assert.equal(claudeCli["User-Agent"], "claude-cli/2.1.195 (external, cli)");
assert.equal(claudeCli["User-Agent"], "claude-cli/2.1.207 (external, cli)");
assert.equal(claudeCli["X-App"], "cli");
const codexCli = getClientIdentityProfileHeaders("codex-cli");
assert.equal(codexCli["User-Agent"], "codex_cli_rs/0.136.0");
assert.equal(codexCli["User-Agent"], "codex_cli_rs/0.144.1");
assert.equal(codexCli.originator, "codex_cli_rs");
const geminiCli = getClientIdentityProfileHeaders("gemini-cli");
@@ -54,7 +55,7 @@ test("getClientIdentityProfileHeaders: returns a fresh mutable copy (catalog sta
headers["User-Agent"] = "tampered";
assert.equal(
CLIENT_IDENTITY_PROFILES["claude-cli"].headers["User-Agent"],
"claude-cli/2.1.195 (external, cli)"
"claude-cli/2.1.207 (external, cli)"
);
});
@@ -79,7 +80,7 @@ test("a selected profile's headers land in providerSpecificData.customHeaders",
customHeaders: { ...profileHeaders, "X-Operator-Set": "keep-me" },
};
assert.equal(providerSpecificData.customHeaders["User-Agent"], "codex_cli_rs/0.136.0");
assert.equal(providerSpecificData.customHeaders["User-Agent"], "codex_cli_rs/0.144.1");
assert.equal(providerSpecificData.customHeaders.originator, "codex_cli_rs");
assert.equal(providerSpecificData.customHeaders["X-Operator-Set"], "keep-me");
});
@@ -99,7 +100,7 @@ test("profile headers merged into customHeaders survive applyCustomHeaders sanit
true
) as Record<string, string>;
assert.equal(headers["User-Agent"], "claude-cli/2.1.195 (external, cli)");
assert.equal(headers["User-Agent"], "claude-cli/2.1.207 (external, cli)");
assert.equal(headers["X-App"], "cli");
assert.equal(headers["Authorization"], "Bearer test-key");
});

View File

@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-revalidation-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const revalidation = await import("../../src/shared/services/codexCatalogRevalidation.ts");
const originalFetch = globalThis.fetch;
const originalEnv = {
OMNIROUTE_PORT: process.env.OMNIROUTE_PORT,
PORT: process.env.PORT,
DASHBOARD_PORT: process.env.DASHBOARD_PORT,
BASE_URL: process.env.BASE_URL,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
OMNIROUTE_INTERNAL_SCHEME: process.env.OMNIROUTE_INTERNAL_SCHEME,
};
async function resetStorage() {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
process.env.OMNIROUTE_PORT = "20128";
process.env.PORT = "22128";
process.env.DASHBOARD_PORT = "22128";
process.env.BASE_URL = "https://attacker.example";
delete process.env.NEXT_PUBLIC_BASE_URL;
delete process.env.NEXT_PUBLIC_APP_URL;
delete process.env.OMNIROUTE_INTERNAL_SCHEME;
});
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
test("live Codex revalidation sends its internal header only to the dashboard loopback origin", async () => {
const connection = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
name: "Codex Runtime Safety",
accessToken: "test-token",
isActive: true,
providerSpecificData: { workspaceId: "runtime-safety" },
});
const calls: Array<{ url: string; hasInternalAuth: boolean; redirect?: RequestRedirect }> = [];
globalThis.fetch = async (input, init) => {
const headers = new Headers(init?.headers);
calls.push({
url: String(input),
hasInternalAuth: headers.has("x-model-sync-internal-auth"),
redirect: init?.redirect,
});
return Response.json({ syncedModels: 1 });
};
const result = await revalidation.liveResyncCodexConnections("http://127.0.0.1:7777");
assert.deepEqual(result, { attempted: 1, succeeded: 1 });
assert.deepEqual(calls, [
{
url: `http://127.0.0.1:22128/api/providers/${connection.id}/sync-models?quiet=1`,
hasInternalAuth: true,
redirect: "error",
},
]);
});
test("Codex readiness and live sync resolve the same dashboard loopback port", async () => {
const calls: string[] = [];
globalThis.fetch = async (input, init) => {
calls.push(String(input));
assert.equal(init?.redirect, "error");
return new Response(null, { status: 404 });
};
await revalidation.waitForLoopbackHttpReady({
apiBaseUrl: "http://localhost:7777",
maxWaitMs: 100,
pollMs: 1,
});
assert.deepEqual(calls, ["http://127.0.0.1:22128/api/providers/__readiness_probe__/models"]);
});

View File

@@ -0,0 +1,257 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import type { SyncedAvailableModel } from "../../src/lib/db/models.ts";
import {
createCodexCatalogRevalidationCoordinator,
executeCodexCatalogRevalidation,
resolveBootRevalidationReason,
resolveCodexCatalogAppVersion,
scrubSyncedModelsWithCodexDenylist,
} from "../../src/shared/services/codexCatalogRevalidation.ts";
test("Codex revalidation avoids top-level createRequire in packaged Next modules", () => {
const source = fs.readFileSync(
path.join(process.cwd(), "src/shared/services/codexCatalogRevalidation.ts"),
"utf8"
);
assert.doesNotMatch(source, /^const\s+\w+\s*=\s*createRequire\s*\(/m);
});
test("scrubSyncedModelsWithCodexDenylist drops the GPT-5.4 family and keeps others", () => {
const input = [
{ id: "gpt-5.6-sol", name: "Sol", source: "imported" },
{ id: "gpt-5.4", name: "Retired", source: "imported" },
{ id: "gpt-5.4-mini", name: "Retired Mini", source: "imported" },
{ id: "future-codex-experimental", name: "Future", source: "imported" },
] satisfies SyncedAvailableModel[];
const { kept, removedIds } = scrubSyncedModelsWithCodexDenylist(input);
assert.deepEqual(
kept.map((m) => m.id),
["gpt-5.6-sol", "future-codex-experimental"]
);
assert.deepEqual(removedIds.sort(), ["gpt-5.4", "gpt-5.4-mini"]);
});
test("scrubSyncedModelsWithCodexDenylist is a no-op when nothing is denylisted", () => {
const input = [
{ id: "gpt-5.6-sol", name: "Sol", source: "imported" },
{ id: "gpt-5.5-low", name: "5.5 Low", source: "imported" },
] satisfies SyncedAvailableModel[];
const { kept, removedIds } = scrubSyncedModelsWithCodexDenylist(input);
assert.equal(removedIds.length, 0);
assert.equal(kept.length, 2);
});
test("resolveCodexCatalogAppVersion uses stable, source-qualified identities", () => {
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-version-identity-"));
assert.equal(
resolveCodexCatalogAppVersion(
{
OMNIROUTE_BUILD_SHA: "abc123",
npm_package_version: "9.9.9",
},
{ runtimeRoot }
),
"build:abc123"
);
assert.equal(
resolveCodexCatalogAppVersion(
{
npm_package_version: "3.8.47",
},
{ runtimeRoot }
),
"pkg:3.8.47"
);
try {
fs.writeFileSync(path.join(runtimeRoot, "BUILD_SHA"), "sentinel-sha\n");
assert.equal(
resolveCodexCatalogAppVersion({}, { runtimeRoot, packageVersion: "3.8.47" }),
"build:sentinel-sha"
);
fs.rmSync(path.join(runtimeRoot, "BUILD_SHA"));
fs.writeFileSync(path.join(runtimeRoot, "package.json"), '{"version":"9.8.7"}\n');
assert.equal(resolveCodexCatalogAppVersion({}, { runtimeRoot }), "pkg:9.8.7");
assert.equal(
resolveCodexCatalogAppVersion({}, { runtimeRoot, packageVersion: "3.8.47" }),
"pkg:3.8.47"
);
assert.equal(resolveCodexCatalogAppVersion({}, { runtimeRoot, packageVersion: null }), null);
} finally {
fs.rmSync(runtimeRoot, { recursive: true, force: true });
}
});
test("resolveBootRevalidationReason only fires on first-start or upgrade", () => {
assert.equal(resolveBootRevalidationReason(null, "v2"), "first-start");
assert.equal(resolveBootRevalidationReason("", "v2"), "first-start");
assert.equal(resolveBootRevalidationReason("v1", "v2"), "upgrade");
assert.equal(resolveBootRevalidationReason("v2", "v2"), null);
});
test("executeCodexCatalogRevalidation records completion only after a full live sync", async () => {
const events: string[] = [];
const failed = await executeCodexCatalogRevalidation({
appVersion: "build:audit",
scrub: async () => {
events.push("scrub");
},
waitForReady: async () => {
events.push("ready");
},
liveResync: async () => {
events.push("sync");
return { attempted: 2, succeeded: 1 };
},
writeMarker: async () => {
events.push("marker");
return true;
},
logSuccess: () => {
events.push("log");
},
});
assert.equal(failed.complete, false);
assert.deepEqual(events, ["scrub", "ready", "sync"]);
events.length = 0;
const complete = await executeCodexCatalogRevalidation({
appVersion: "build:audit",
scrub: async () => {
events.push("scrub");
},
waitForReady: async () => {
events.push("ready");
},
liveResync: async () => {
events.push("sync");
return { attempted: 2, succeeded: 2 };
},
writeMarker: async () => {
events.push("marker");
return true;
},
logSuccess: () => {
events.push("log");
},
});
assert.equal(complete.complete, true);
assert.deepEqual(events, ["scrub", "ready", "sync", "marker", "log"]);
});
test("executeCodexCatalogRevalidation leaves an unknown-version run incomplete and unlogged", async () => {
const events: string[] = [];
const result = await executeCodexCatalogRevalidation({
appVersion: null,
scrub: async () => undefined,
waitForReady: async () => undefined,
liveResync: async () => ({ attempted: 0, succeeded: 0 }),
writeMarker: async () => {
events.push("marker");
return true;
},
logSuccess: () => {
events.push("log");
},
});
assert.equal(result.complete, false);
assert.deepEqual(events, []);
});
test("executeCodexCatalogRevalidation does not complete after readiness or marker failure", async () => {
let liveCalls = 0;
let markerCalls = 0;
let successLogs = 0;
const notReady = await executeCodexCatalogRevalidation({
appVersion: "build:audit",
scrub: async () => undefined,
waitForReady: async () => {
throw new Error("not ready");
},
liveResync: async () => {
liveCalls += 1;
return { attempted: 1, succeeded: 1 };
},
writeMarker: async () => {
markerCalls += 1;
return true;
},
logSuccess: () => {
successLogs += 1;
},
});
assert.equal(notReady.complete, false);
assert.equal(liveCalls, 0);
assert.equal(markerCalls, 0);
assert.equal(successLogs, 0);
const markerFailed = await executeCodexCatalogRevalidation({
appVersion: "build:audit",
scrub: async () => undefined,
waitForReady: async () => undefined,
liveResync: async () => ({ attempted: 1, succeeded: 1 }),
writeMarker: async () => false,
logSuccess: () => {
successLogs += 1;
},
});
assert.equal(markerFailed.complete, false);
assert.equal(successLogs, 0);
});
test("Codex revalidation coordinator coalesces startup and queues one init rerun", async () => {
let releaseFirst: (() => void) | undefined;
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const reasons: string[] = [];
let active = 0;
let maxActive = 0;
const request = createCodexCatalogRevalidationCoordinator(async (options) => {
reasons.push(options.reason);
active += 1;
maxActive = Math.max(maxActive, active);
if (reasons.length === 1) await firstGate;
active -= 1;
});
const startupA = request({ reason: "upgrade" });
const startupB = request({ reason: "upgrade" });
const initA = request({ reason: "init" });
const initB = request({ reason: "init" });
releaseFirst?.();
await Promise.all([startupA, startupB, initA, initB]);
assert.deepEqual(reasons, ["upgrade", "init"]);
assert.equal(maxActive, 1);
});
test("Codex revalidation coordinator does not lose init during active-run settlement", async () => {
let releaseFirst: (() => void) | undefined;
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const reasons: string[] = [];
const request = createCodexCatalogRevalidationCoordinator(async ({ reason }) => {
reasons.push(reason);
if (reasons.length === 1) await firstGate;
});
const activeRun = request({ reason: "upgrade" });
const settlementInit = firstGate.then(() =>
Promise.resolve().then(() => request({ reason: "init" }))
);
releaseFirst?.();
await Promise.all([activeRun, settlementInit]);
assert.deepEqual(reasons, ["upgrade", "init"]);
});

View File

@@ -38,7 +38,11 @@ test("Codex global service mode distinguishes no setting from explicit tiers", (
);
assert.deepEqual(
resolveCodexGlobalFastServiceTier({ codexServiceTier: { enabled: true, tier: "default" } }),
{ enabled: true, tier: "default", supportedModels: ["gpt-5.5", "gpt-5.4"] }
{
enabled: true,
tier: "default",
supportedModels: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"],
}
);
});

View File

@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getModelsByProviderId } from "../../open-sse/config/providerModels.ts";
test("Codex catalog exposes the GPT-5.6 lineup in configured priority order", () => {
const models = getModelsByProviderId("codex");
const expectedIds = [
"gpt-5.6-sol",
"gpt-5.6-sol-ultra",
"gpt-5.6-sol-max",
"gpt-5.6-sol-xhigh",
"gpt-5.6-sol-high",
"gpt-5.6-sol-medium",
"gpt-5.6-sol-low",
"gpt-5.6-terra",
"gpt-5.6-terra-ultra",
"gpt-5.6-terra-max",
"gpt-5.6-terra-xhigh",
"gpt-5.6-terra-high",
"gpt-5.6-terra-medium",
"gpt-5.6-terra-low",
"gpt-5.6-luna",
"gpt-5.6-luna-max",
"gpt-5.6-luna-xhigh",
"gpt-5.6-luna-high",
"gpt-5.6-luna-medium",
"gpt-5.6-luna-low",
];
assert.deepEqual(
models.slice(0, expectedIds.length).map((model) => model.id),
expectedIds
);
for (const modelId of expectedIds) {
const model = models.find((entry) => entry.id === modelId);
assert.ok(model, `codex must expose ${modelId}`);
assert.equal(model.contextLength, 500000);
assert.equal(model.maxInputTokens, 372000);
assert.equal(model.maxOutputTokens, 128000);
assert.equal(model.targetFormat, "openai-responses");
assert.equal(model.toolCalling, true);
assert.equal(model.supportsReasoning, true);
assert.equal(model.supportsVision, true);
assert.equal(model.supportsXHighEffort, true);
}
assert.equal(
models.some((model) => model.id === "gpt-5.6-luna-ultra"),
false
);
});
test("Codex catalog no longer exposes GPT-5.4 models", () => {
const models = getModelsByProviderId("codex");
assert.deepEqual(
models.filter((model) => model.id.startsWith("gpt-5.4")).map((model) => model.id),
[]
);
});

View File

@@ -32,8 +32,14 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-test-secret"
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
type CatalogResponse = {
data?: Array<{ id: string }>;
};
async function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
@@ -56,7 +62,8 @@ test("codex client (originator: codex_exec) receives a top-level `models` array
new Request("http://localhost/v1/models?client_version=0.137.0", {
headers: {
originator: "codex_exec",
"user-agent": "codex_exec/0.137.0 (Ubuntu 24.4.0; x86_64) vscode/3.7.19 (codex_exec; 0.137.0)",
"user-agent":
"codex_exec/0.137.0 (Ubuntu 24.4.0; x86_64) vscode/3.7.19 (codex_exec; 0.137.0)",
},
})
);
@@ -103,3 +110,48 @@ test("non-codex OpenAI client keeps the unchanged {object,data} shape (no `model
"non-codex clients must NOT receive a `models` key (response stays byte-identical)"
);
});
test("v1 models catalog exposes remote-only Codex IDs from the discovery cache", async () => {
const connection = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
name: "codex-curated-catalog",
accessToken: "codex-access-token",
isActive: true,
testStatus: "active",
});
await modelsDb.replaceSyncedAvailableModelsForConnection("codex", connection.id, [
{
id: "codex-auto-review",
name: "Codex Auto Review Remote",
source: "imported",
supportedEndpoints: ["responses"],
},
{
id: "future-codex-model",
name: "Future Codex Model",
source: "imported",
supportedEndpoints: ["responses"],
},
{
id: "gpt-5.4-mini",
name: "Retired GPT-5.4 Mini",
source: "imported",
supportedEndpoints: ["responses"],
},
]);
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models")
);
const body = (await response.json()) as CatalogResponse;
const ids = new Set((body.data || []).map((item) => item.id));
assert.equal(response.status, 200);
assert.equal(ids.has("cx/codex-auto-review"), true);
assert.equal(ids.has("cx/future-codex-model"), true);
assert.equal(ids.has("codex/future-codex-model"), true);
assert.equal(ids.has("cx/gpt-5.4-mini"), false);
assert.equal(ids.has("codex/gpt-5.4-mini"), false);
});

View File

@@ -213,14 +213,14 @@ test.after(async () => {
test("CodexExecutor.transformRequest clones the request body before forcing stream=true", () => {
const executor = new CodexExecutor();
const body = {
model: "gpt-5.4",
model: "gpt-5.6-sol",
input: [{ role: "user", content: [{ type: "input_text", text: "Oi" }] }],
stream: false,
reasoning: { effort: "low" },
};
const original = structuredClone(body);
const transformed = executor.transformRequest("gpt-5.4", body, false, {
const transformed = executor.transformRequest("gpt-5.6-sol", body, false, {
requestEndpointPath: "/responses",
});
@@ -314,7 +314,7 @@ test("chatCore converts Responses-style NDJSON fallback into JSON when stream=fa
test("handleComboChat validates non-stream quality using the original client stream intent", async () => {
const combo = {
name: "codex-stream-false-quality",
models: ["codex/gpt-5.4", "openai/gpt-4o-mini"],
models: ["codex/gpt-5.6-sol", "openai/gpt-4o-mini"],
};
const log = createComboLog();
const seenModels = [];
@@ -327,7 +327,7 @@ test("handleComboChat validates non-stream quality using the original client str
combo,
handleSingleModel: async (requestBody, modelStr) => {
seenModels.push(modelStr);
if (modelStr === "codex/gpt-5.4") {
if (modelStr === "codex/gpt-5.6-sol") {
requestBody.stream = true;
return jsonResponse({
choices: [
@@ -355,7 +355,7 @@ test("handleComboChat validates non-stream quality using the original client str
const payload = (await result.json()) as any;
assert.equal(result.ok, true);
assert.deepEqual(seenModels, ["codex/gpt-5.4", "openai/gpt-4o-mini"]);
assert.deepEqual(seenModels, ["codex/gpt-5.6-sol", "openai/gpt-4o-mini"]);
assert.equal(payload.choices[0].message.content, "Brasilia");
assert.ok(
log.entries.some(

View File

@@ -68,7 +68,7 @@ test("#4279 combo stops at the first body-specific 400 instead of trying every t
const result = await handleComboChat({
body: { model: "test", messages: [{ role: "user", content: "hi" }] },
combo: makeCombo(["codex/gpt-5.2", "codex/gpt-5.3-codex", "codex/gpt-5.4"]),
combo: makeCombo(["codex/gpt-5.2", "codex/gpt-5.3-codex", "codex/gpt-5.6-sol"]),
handleSingleModel,
log,
settings: {},

View File

@@ -369,7 +369,7 @@ test("resolveComboConfig tolerates invalid or missing inputs and falls back to d
test("createComboSchema accepts context-relay strategy with handoff config", () => {
const parsed = createComboSchema.parse({
name: "codex-relay",
models: ["codex/gpt-5.4"],
models: ["codex/gpt-5.6-sol"],
strategy: "context-relay",
config: {
handoffThreshold: 0.85,
@@ -443,7 +443,7 @@ test("createComboSchema accepts structured combo steps with pinned connection an
kind: "model",
id: "step-codex-a",
providerId: "codex",
model: "gpt-5.4",
model: "gpt-5.6-sol",
connectionId: "conn-codex-a",
weight: 10,
},
@@ -472,14 +472,14 @@ test("createComboSchema accepts composite tiers that reference normalized combo
kind: "model",
id: "step-primary",
providerId: "codex",
model: "gpt-5.4",
model: "gpt-5.6-sol",
connectionId: "conn-codex-a",
},
{
kind: "model",
id: "step-backup",
providerId: "codex",
model: "gpt-5.4",
model: "gpt-5.6-sol",
connectionId: "conn-codex-b",
},
],

View File

@@ -156,14 +156,14 @@ test("handleComboChat context-relay skips unavailable models and falls through t
combo: {
name: "relay-skip-unavailable",
strategy: "context-relay",
models: ["codex/gpt-5.4", "openai/gpt-4o-mini"],
models: ["codex/gpt-5.6-sol", "openai/gpt-4o-mini"],
config: { maxRetries: 0 },
},
handleSingleModel: async (_body, modelStr) => {
calls.push(modelStr);
return okResponse();
},
isModelAvailable: async (modelStr) => modelStr !== "codex/gpt-5.4",
isModelAvailable: async (modelStr) => modelStr !== "codex/gpt-5.6-sol",
log: createLog(),
settings: null,
allCombos: null,
@@ -181,7 +181,7 @@ test("handleComboChat context-relay treats provider circuit breaker responses as
const combo = {
name: "relay-breaker",
strategy: "context-relay",
models: ["codex/gpt-5.4", "openai/gpt-4o-mini"],
models: ["codex/gpt-5.6-sol", "openai/gpt-4o-mini"],
config: { maxRetries: 0 },
};
const calls = [];
@@ -193,7 +193,7 @@ test("handleComboChat context-relay treats provider circuit breaker responses as
combo,
handleSingleModel: async (_body, modelStr) => {
calls.push(modelStr);
if (modelStr === "codex/gpt-5.4") {
if (modelStr === "codex/gpt-5.6-sol") {
return providerBreakerOpenResponse();
}
return okResponse();
@@ -205,7 +205,7 @@ test("handleComboChat context-relay treats provider circuit breaker responses as
});
assert.equal(result.ok, true);
assert.deepEqual(calls, ["codex/gpt-5.4", "openai/gpt-4o-mini"]);
assert.deepEqual(calls, ["codex/gpt-5.6-sol", "openai/gpt-4o-mini"]);
});
test("handleComboChat context-relay persists a handoff when codex quota reaches the warning threshold", async () => {
@@ -235,7 +235,7 @@ test("handleComboChat context-relay persists a handoff when codex quota reaches
combo: {
name: "relay-generate",
strategy: "context-relay",
models: ["codex/gpt-5.4"],
models: ["codex/gpt-5.6-sol"],
config: { maxRetries: 0, handoffThreshold: 0.85, handoffProviders: ["codex"] },
},
handleSingleModel: async (body) => {
@@ -309,7 +309,7 @@ test("handleComboChat context-relay respects handoffProviders and skips generati
combo: {
name: "relay-disabled-provider",
strategy: "context-relay",
models: ["codex/gpt-5.4"],
models: ["codex/gpt-5.6-sol"],
config: { maxRetries: 0, handoffProviders: ["openai"] },
},
handleSingleModel: async (body) => {
@@ -363,7 +363,7 @@ test("handleComboChat context-relay treats explicit empty handoffProviders as di
combo: {
name: "relay-empty-providers",
strategy: "context-relay",
models: ["codex/gpt-5.4"],
models: ["codex/gpt-5.6-sol"],
config: { maxRetries: 0, handoffProviders: [] },
},
handleSingleModel: async () => okResponse(),

View File

@@ -46,7 +46,7 @@ test("buildHandoffSystemMessage and injectHandoffIntoBody preserve existing hist
taskProgress: "Need to finish tests",
activeEntities: ["combo.ts", "chat.ts"],
messageCount: 42,
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
warningThresholdPct: 0.85,
generatedAt: "2099-04-08T12:00:00.000Z",
expiresAt: "2099-04-08T17:00:00.000Z",
@@ -79,7 +79,7 @@ test("injectHandoffIntoBody preserves Responses API shape for native Codex reque
taskProgress: "Need to carry state across account switches",
activeEntities: ["chat.ts", "contextHandoff.ts"],
messageCount: 8,
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
warningThresholdPct: 0.85,
generatedAt: "2099-04-08T12:00:00.000Z",
expiresAt: "2099-04-08T17:00:00.000Z",
@@ -139,7 +139,7 @@ test("maybeGenerateHandoff skips below the warning threshold", async () => {
connectionId: "conn-low",
percentUsed: 0.7,
messages: [{ role: "user", content: "hello" }],
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
expiresAt: null,
handleSingleModel: async () => {
called = true;
@@ -164,7 +164,7 @@ test("maybeGenerateHandoff persists a structured handoff once the threshold is r
{ role: "user", content: "Please continue wiring the combo" },
{ role: "assistant", content: "Working on it" },
],
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
expiresAt: "2099-04-08T17:00:00.000Z",
handleSingleModel: async (body, modelStr) => {
calls.push({ body, modelStr });
@@ -197,7 +197,7 @@ test("maybeGenerateHandoff persists a structured handoff once the threshold is r
assert.equal(saved.summary, "Relay summary generated");
assert.deepEqual(saved.keyDecisions, ["Use context-relay"]);
assert.equal(calls.length, 1);
assert.equal(calls[0].modelStr, "codex/gpt-5.4");
assert.equal(calls[0].modelStr, "codex/gpt-5.6-sol");
assert.equal(calls[0].body._omnirouteSkipContextRelay, true);
assert.equal(calls[0].body._omnirouteInternalRequest, "context-handoff");
});
@@ -215,7 +215,7 @@ test("maybeGenerateHandoff deduplicates concurrent in-flight generations for the
connectionId: "conn-dedupe",
percentUsed: 0.89,
messages: [{ role: "user", content: "Generate once" }],
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
expiresAt: "2099-01-01T00:00:00.000Z",
handleSingleModel: async () => {
calls.push("summary");
@@ -265,7 +265,7 @@ test("maybeGenerateHandoff allows a new attempt after a failed in-flight generat
connectionId: "conn-retry",
percentUsed: 0.9,
messages: [{ role: "user", content: "Retry after failure" }],
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
expiresAt: "2099-01-01T00:00:00.000Z",
handleSingleModel: async () => {
calls += 1;
@@ -316,7 +316,7 @@ test("maybeGenerateHandoff respects explicit empty handoffProviders and skips ge
connectionId: "conn-disabled",
percentUsed: 0.92,
messages: [{ role: "user", content: "Do not generate" }],
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
expiresAt: null,
config: { handoffProviders: [] },
handleSingleModel: async () => {
@@ -340,7 +340,7 @@ test("context handoff DB module upserts and deletes active handoffs", () => {
taskProgress: "step one",
activeEntities: ["a.ts"],
messageCount: 3,
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
warningThresholdPct: 0.85,
generatedAt: "2099-04-08T10:00:00.000Z",
expiresAt: "2099-01-01T00:00:00.000Z",
@@ -354,7 +354,7 @@ test("context handoff DB module upserts and deletes active handoffs", () => {
taskProgress: "step two",
activeEntities: ["b.ts"],
messageCount: 4,
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
warningThresholdPct: 0.86,
generatedAt: "2099-04-08T11:00:00.000Z",
expiresAt: "2099-01-01T00:00:00.000Z",
@@ -385,7 +385,7 @@ test("selectMessagesForSummary filters falsy values and preserves system/develop
messages as contextHandoff.MessageLike[],
2
);
assert.equal(selected.length, 4);
assert.equal(selected[0].role, "system");
assert.equal(selected[1].role, "developer");

View File

@@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert/strict";
import { CodexExecutor } from "../../open-sse/executors/codex.ts";
test("CodexExecutor.transformRequest preserves max effort for GPT-5.6", () => {
const executor = new CodexExecutor();
const result = executor.transformRequest(
"gpt-5.6-sol",
{
model: "gpt-5.6-sol",
input: [],
reasoning_effort: "max",
},
false,
{ requestEndpointPath: "/responses" }
);
assert.equal(result.model, "gpt-5.6-sol");
assert.equal(result.reasoning.effort, "max");
assert.equal(result.reasoning_effort, undefined);
});
test("CodexExecutor.transformRequest maps GPT-5.6 ultra aliases to max wire effort", () => {
const executor = new CodexExecutor();
for (const model of ["gpt-5.6-sol-ultra", "gpt-5.6-terra-ultra"]) {
const result = executor.transformRequest(model, { model, input: [] }, false, {
requestEndpointPath: "/responses",
});
assert.equal(result.model, model.replace(/-ultra$/, ""));
assert.equal(result.reasoning.effort, "max");
}
});
test("CodexExecutor.transformRequest clamps Luna ultra requests to its max effort", () => {
const executor = new CodexExecutor();
const result = executor.transformRequest(
"gpt-5.6-luna",
{
model: "gpt-5.6-luna",
input: [],
reasoning_effort: "ultra",
},
false,
{ requestEndpointPath: "/responses" }
);
assert.equal(result.model, "gpt-5.6-luna");
assert.equal(result.reasoning.effort, "max");
});

View File

@@ -86,16 +86,15 @@ test("Codex helper functions isolate rate-limit scopes and parse quota headers",
assert.equal(getCodexModelScope("gpt-5.5-xhigh"), "codex");
assert.equal(getCodexUpstreamModel("gpt-5.5-xhigh"), "gpt-5.5");
assert.equal(getCodexUpstreamModel("gpt-5.5-medium"), "gpt-5.5");
assert.equal(getCodexUpstreamModel("gpt-5.1-codex-max"), "gpt-5.1-codex-max");
// With mock WS transport + codexTransport=websocket, gpt-5.5 models require WS
__setCodexWebSocketTransportForTesting(
async (): Promise<MockCodexWebSocket> => ({
send() {},
close() {},
onmessage: null,
onerror: null,
onclose: null,
})
);
__setCodexWebSocketTransportForTesting(async (): Promise<MockCodexWebSocket> => ({
send() {},
close() {},
onmessage: null,
onerror: null,
onclose: null,
}));
assert.equal(
isCodexResponsesWebSocketRequired("gpt-5.5-xhigh", {
providerSpecificData: { codexTransport: "websocket" },
@@ -185,10 +184,10 @@ test("CodexExecutor.buildHeaders binds workspace ids and disables SSE accept for
assert.equal(standardHeaders.Authorization, "Bearer codex-token");
assert.equal(standardHeaders.Accept, "text/event-stream");
assert.equal(standardHeaders["chatgpt-account-id"], "workspace-1");
assert.equal(standardHeaders.Version, "0.144.0");
assert.equal(standardHeaders.Version, "0.144.1");
assert.equal(standardHeaders["Openai-Beta"], "responses=experimental");
assert.equal(standardHeaders["X-Codex-Beta-Features"], "responses_websockets");
assert.equal(standardHeaders["User-Agent"], "codex-cli/0.144.0 (Windows 10.0.26200; x64)");
assert.equal(standardHeaders["User-Agent"], "codex-cli/0.144.1 (Windows 10.0.26200; x64)");
assert.equal(compactHeaders.Accept, "application/json");
});
@@ -214,7 +213,7 @@ test("CodexExecutor.buildHeaders honors safe env overrides for Version and User-
},
() => {
const headers = executor.buildHeaders({ accessToken: "codex-token" }, true);
assert.equal(headers.Version, "0.144.0");
assert.equal(headers.Version, "0.144.1");
assert.equal(headers["User-Agent"], "custom-codex/9.9.9");
}
);
@@ -799,12 +798,12 @@ test("CodexExecutor.transformRequest keeps GPT 5.3 Codex reasoning in Responses
assert.equal(sanitized.reasoning_effort, undefined);
});
test("CodexExecutor.transformRequest passes GPT 5.4 Mini xhigh reasoning through unchanged in Responses shape (#3756)", () => {
test("CodexExecutor.transformRequest passes GPT 5.6 Luna xhigh reasoning through unchanged", () => {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
"gpt-5.4-mini",
"gpt-5.6-luna",
{
model: "gpt-5.4-mini",
model: "gpt-5.6-luna",
input: [],
reasoning: { effort: "xhigh", summary: "detailed" },
include: ["code_interpreter_call.outputs"],
@@ -817,12 +816,12 @@ test("CodexExecutor.transformRequest passes GPT 5.4 Mini xhigh reasoning through
const sanitized = sanitizeReasoningEffortForProvider(
transformed,
"codex",
"gpt-5.4-mini",
"gpt-5.6-luna",
null
) as Record<string, unknown>;
const reasoning = getRecord(sanitized.reasoning);
assert.equal(sanitized.model, "gpt-5.4-mini");
assert.equal(sanitized.model, "gpt-5.6-luna");
assert.deepEqual(reasoning, { effort: "xhigh", summary: "detailed" });
assert.deepEqual(sanitized.include, [
"code_interpreter_call.outputs",
@@ -1058,9 +1057,9 @@ test("CodexExecutor.execute skips identity headers for unsafe session ids", asyn
test("CodexExecutor.transformRequest preserves namespace MCP tools and hosted tool types", () => {
const executor = new CodexExecutor();
const result = executor.transformRequest(
"gpt-5.4",
"gpt-5.6-sol",
{
model: "gpt-5.4",
model: "gpt-5.6-sol",
input: [],
tools: [
{ type: "function", name: "exec_command", parameters: { type: "object" } },

View File

@@ -181,7 +181,7 @@ test("GlmExecutor separates OpenAI-compatible coding headers from Anthropic head
assert.equal(anthropicHeaders["anthropic-version"], "2023-06-01");
assert.match(anthropicHeaders["anthropic-beta"], /claude-code-20250219/);
assert.equal(anthropicHeaders["anthropic-dangerous-direct-browser-access"], "true");
assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.195 \(external, sdk-cli\)$/);
assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.207 \(external, sdk-cli\)$/);
assert.equal(anthropicHeaders["X-Stainless-Lang"], "js");
assert.equal(anthropicHeaders["X-Stainless-Runtime"], "node");
});

View File

@@ -55,10 +55,10 @@ test("all codex gpt-5.5 effort variants carry the distinct input cap (#6191)", (
});
test("regression: a model without maxInputTokens still falls back to its context window", () => {
// codex gpt-5.4 declares no maxInputTokens, so max_input_tokens must equal
// the context window (the historical fallback) — no under-reporting.
const caps = modelCapabilities.getResolvedModelCapabilities("codex/gpt-5.4");
assert.ok((caps.contextWindow ?? 0) > 0, "gpt-5.4 should have a context window");
// OpenAI GPT-5.4 declares a context window without maxInputTokens, so the
// historical fallback must still avoid under-reporting.
const caps = modelCapabilities.getResolvedModelCapabilities("openai/gpt-5.4");
assert.ok((caps.contextWindow ?? 0) > 0, "OpenAI GPT-5.4 should have a context window");
assert.equal(
caps.maxInputTokens,
caps.contextWindow,

View File

@@ -61,8 +61,8 @@ test("model suffix -high triggers strip; -none keeps sampling", () => {
});
test("non-openai provider is untouched (codex is guarded by the executor allowlist)", () => {
const body = { model: "gpt-5.4", temperature: 0.7, reasoning_effort: "high" };
const result = stripGpt5SamplingWhenReasoning(body, "codex", "gpt-5.4");
const body = { model: "gpt-5.6-sol", temperature: 0.7, reasoning_effort: "high" };
const result = stripGpt5SamplingWhenReasoning(body, "codex", "gpt-5.6-sol");
assert.equal(result.temperature, 0.7);
});

View File

@@ -224,9 +224,9 @@ test("guide-settings POST preserves existing OpenCode config fields while only u
body: JSON.stringify({
baseUrl: "http://my-omni/v1",
apiKey: "sk-123",
models: ["cx/gpt-5.4", "opencode-go/kimi-k2.6"],
models: ["cx/gpt-5.6-sol", "opencode-go/kimi-k2.6"],
modelLabels: {
"cx/gpt-5.4": "GPT-5.4",
"cx/gpt-5.6-sol": "GPT-5.6 Sol",
"opencode-go/kimi-k2.6": "Kimi K2.6",
},
}),
@@ -252,7 +252,7 @@ test("guide-settings POST preserves existing OpenCode config fields while only u
assert.equal(content.provider.omniroute.options.baseURL, "http://my-omni/v1");
assert.ok(content.provider.omniroute.options.apiKey.startsWith("sk-"));
assert.deepEqual(content.provider.omniroute.models, {
"cx/gpt-5.4": { name: "GPT-5.4" },
"cx/gpt-5.6-sol": { name: "GPT-5.6 Sol" },
"opencode-go/kimi-k2.6": { name: "Kimi K2.6" },
});
});

View File

@@ -1814,7 +1814,7 @@ test("handleImageGeneration routes codex image requests through /responses with
try {
const result = await handleImageGeneration({
body: {
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
prompt: "Draw a happy red kitten",
response_format: "b64_json",
},
@@ -1829,7 +1829,7 @@ test("handleImageGeneration routes codex image requests through /responses with
assert.equal(captured.url, "https://chatgpt.com/backend-api/codex/responses");
assert.equal(captured.headers.Authorization, "Bearer codex-token");
assert.equal(captured.headers["chatgpt-account-id"], "acct-123");
assert.equal(captured.body.model, "gpt-5.4");
assert.equal(captured.body.model, "gpt-5.6-sol");
assert.equal(captured.body.stream, true);
assert.equal(captured.body.store, false);
assert.deepEqual(captured.body.tools, [{ type: "image_generation", output_format: "png" }]);
@@ -1853,7 +1853,7 @@ test("handleImageGeneration (codex) returns a data URL when response_format is n
try {
const result = await handleImageGeneration({
body: { model: "cx/gpt-5.4", prompt: "kitten" },
body: { model: "cx/gpt-5.6-sol", prompt: "kitten" },
credentials: { accessToken: "codex-token" },
log: null,
});
@@ -1895,7 +1895,7 @@ test("handleImageGeneration (codex) fans out n>1 requests in parallel", async ()
try {
pending = handleImageGeneration({
body: {
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
prompt: "kitten",
n: 2,
response_format: "b64_json",
@@ -1937,7 +1937,7 @@ test("handleImageGeneration (codex) surfaces an error when no image_generation_c
try {
const result = await handleImageGeneration({
body: { model: "codex/gpt-5.4", prompt: "kitten" },
body: { model: "codex/gpt-5.6-sol", prompt: "kitten" },
credentials: { accessToken: "codex-token" },
log: null,
});
@@ -1956,7 +1956,7 @@ test("handleImageGeneration (codex) propagates upstream HTTP errors", async () =
try {
const result = await handleImageGeneration({
body: { model: "codex/gpt-5.4", prompt: "kitten" },
body: { model: "codex/gpt-5.6-sol", prompt: "kitten" },
credentials: { accessToken: "codex-token" },
log: null,
});
@@ -1982,7 +1982,7 @@ test("handleImageGeneration (codex) forwards size and maps GPT-Image quality to
try {
await handleImageGeneration({
body: {
model: "codex/gpt-5.4",
model: "codex/gpt-5.6-sol",
prompt: "kitten",
size: "1024x1792",
quality: "hd",
@@ -2000,14 +2000,14 @@ test("handleImageGeneration (codex) forwards size and maps GPT-Image quality to
]);
await handleImageGeneration({
body: { model: "codex/gpt-5.4", prompt: "kitten", quality: "standard" },
body: { model: "codex/gpt-5.6-sol", prompt: "kitten", quality: "standard" },
credentials: { accessToken: "codex-token" },
log: null,
});
assert.equal(captured.tools[0].quality, "medium");
await handleImageGeneration({
body: { model: "codex/gpt-5.4", prompt: "kitten" },
body: { model: "codex/gpt-5.6-sol", prompt: "kitten" },
credentials: { accessToken: "codex-token" },
log: null,
});

View File

@@ -69,7 +69,7 @@ test("v1 image models GET exposes image-only modalities for credential-backed im
assert.deepEqual((byId.get("stability-ai/fast") as any).input_modalities, ["image"]);
});
test("v1 image models GET hides providers without active credentials", async () => {
test("v1 image models GET exposes current Codex image models and hides inactive providers", async () => {
await seedConnection("codex", { apiKey: "codex-key" });
const response = await imageRoute.GET();
@@ -77,7 +77,11 @@ test("v1 image models GET hides providers without active credentials", async ()
const ids = body.data.map((item) => item.id);
assert.equal(response.status, 200);
assert.ok(ids.includes("codex/gpt-5.5"));
assert.deepEqual(
ids.filter((id) => id.startsWith("codex/")),
["codex/gpt-5.6-sol", "codex/gpt-5.6-terra", "codex/gpt-5.6-luna"]
);
assert.ok(!ids.includes("codex/gpt-5.5"));
assert.ok(!ids.includes("openai/gpt-image-2"));
assert.ok(!ids.some((id: string) => id.startsWith("xai/")));
});
@@ -147,7 +151,7 @@ test("v1 image edit POST enforces disabled API key policy", async () => {
const formData = new FormData();
formData.set("prompt", "make the background lighter");
formData.set("model", "cgpt-web/gpt-5.3-instant");
formData.set("model", "cgpt-web/gpt-5.5");
formData.set("image", new File([new Uint8Array([1, 2, 3])], "source.png", { type: "image/png" }));
const response = await imageEditRoute.POST(

View File

@@ -0,0 +1,26 @@
import test from "node:test";
import assert from "node:assert/strict";
import { IMAGE_PROVIDERS, parseImageModel } from "../../open-sse/config/imageRegistry.ts";
test("ChatGPT Web image catalog exposes GPT-5.5 Instant instead of GPT-5.3 Instant", () => {
assert.deepEqual(IMAGE_PROVIDERS["chatgpt-web"].models, [
{ id: "gpt-5.5", name: "GPT-5.5 Instant (ChatGPT Web Image)" },
]);
assert.deepEqual(parseImageModel("cgpt-web/gpt-5.5"), {
provider: "chatgpt-web",
model: "gpt-5.5",
});
});
test("Codex image catalog exposes only the GPT-5.6 Sol, Terra, and Luna models", () => {
assert.deepEqual(IMAGE_PROVIDERS.codex.models, [
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol (Codex Image)" },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra (Codex Image)" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna (Codex Image)" },
]);
for (const model of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) {
assert.deepEqual(parseImageModel(`cx/${model}`), { provider: "codex", model });
}
});

View File

@@ -124,16 +124,17 @@ test("resolveImageRouteModel lets bare combos shadow built-in image aliases", as
});
test("resolveImageRouteModel keeps codex bare aliases over same-name combos", async () => {
await createCombo({ name: "gpt-5.5", models: ["myimg/gpt-5.5"], strategy: "priority" });
await createCombo({
name: "gpt-5.6-sol",
models: ["myimg/gpt-5.6-sol"],
strategy: "priority",
});
assert.equal(await resolveSingleImageComboTarget("gpt-5.5"), "myimg/gpt-5.5");
assert.equal(await resolveImageRouteModel("gpt-5.5"), "gpt-5.5");
assert.equal(await resolveSingleImageComboTarget("gpt-5.6-sol"), "myimg/gpt-5.6-sol");
assert.equal(await resolveImageRouteModel("gpt-5.6-sol"), "gpt-5.6-sol");
});
test("resolveImageRouteModel leaves built-in / already-resolved ids untouched", async () => {
assert.equal(
await resolveImageRouteModel("cgpt-web/gpt-5.3-instant"),
"cgpt-web/gpt-5.3-instant"
);
assert.equal(await resolveImageRouteModel("cgpt-web/gpt-5.5"), "cgpt-web/gpt-5.5");
assert.equal(await resolveSingleImageComboTarget("definitely-not-a-combo-3215"), null);
});

View File

@@ -19,82 +19,82 @@ import assert from "node:assert/strict";
import { getModelInfoCore } from "../../open-sse/services/model.ts";
// ── Scenario A: direct exact alias "gpt-5.4" → "cx/gpt-5.4" ─────────────────
// When aliases contain { "gpt-5.4": "cx/gpt-5.4" }, the request for "gpt-5.4"
// ── Scenario A: direct exact alias "gpt-5.6-sol" → "cx/gpt-5.6-sol" ─────────
// When aliases contain { "gpt-5.6-sol": "cx/gpt-5.6-sol" }, the request for "gpt-5.6-sol"
// must resolve to provider "codex" (cx = codex alias), NOT "openai".
test("A: custom alias gpt-5.4 → cx/gpt-5.4 resolves to codex, not openai", async () => {
const aliases = { "gpt-5.4": "cx/gpt-5.4" };
const result = await getModelInfoCore("gpt-5.4", aliases);
test("A: custom alias gpt-5.6-sol → cx/gpt-5.6-sol resolves to codex, not openai", async () => {
const aliases = { "gpt-5.6-sol": "cx/gpt-5.6-sol" };
const result = await getModelInfoCore("gpt-5.6-sol", aliases);
assert.strictEqual(
result.provider,
"codex",
`Expected provider "codex" but got "${result.provider}" — alias was overridden by provider inference`
);
assert.strictEqual(result.model, "gpt-5.4");
assert.strictEqual(result.model, "gpt-5.6-sol");
});
test("A: custom alias gpt-5.4 → cx/gpt-5.4 with async getter also resolves to codex", async () => {
const aliases = { "gpt-5.4": "cx/gpt-5.4" };
const result = await getModelInfoCore("gpt-5.4", async () => aliases);
test("A: custom alias gpt-5.6-sol → cx/gpt-5.6-sol with async getter resolves to codex", async () => {
const aliases = { "gpt-5.6-sol": "cx/gpt-5.6-sol" };
const result = await getModelInfoCore("gpt-5.6-sol", async () => aliases);
assert.strictEqual(result.provider, "codex");
assert.strictEqual(result.model, "gpt-5.4");
assert.strictEqual(result.model, "gpt-5.6-sol");
});
test("A: without alias, gpt-5.4 still resolves to openai via inference (baseline)", async () => {
test("A: without alias, gpt-5.6-sol resolves to openai via inference (baseline)", async () => {
// Ensure that inference still works when no alias is configured.
const result = await getModelInfoCore("gpt-5.4", {});
const result = await getModelInfoCore("gpt-5.6-sol", {});
assert.strictEqual(result.provider, "openai");
assert.strictEqual(result.model, "gpt-5.4");
assert.strictEqual(result.model, "gpt-5.6-sol");
});
// ── Scenario B: wildcard alias "*gpt-5.4*" → "cx/gpt-5.4" ───────────────────
// ── Scenario B: wildcard alias "*gpt-5.6*" → "cx/gpt-5.6-sol" ───────────────
// Glob patterns are also supported via resolveWildcardAlias.
test("B: wildcard alias *gpt-5.4* → cx/gpt-5.4 resolves to codex", async () => {
const aliases = { "*gpt-5.4*": "cx/gpt-5.4" };
const result = await getModelInfoCore("gpt-5.4", aliases);
test("B: wildcard alias *gpt-5.6* → cx/gpt-5.6-sol resolves to codex", async () => {
const aliases = { "*gpt-5.6*": "cx/gpt-5.6-sol" };
const result = await getModelInfoCore("gpt-5.6-sol", aliases);
assert.strictEqual(result.provider, "codex");
assert.strictEqual(result.model, "gpt-5.4");
assert.strictEqual(result.model, "gpt-5.6-sol");
});
test("B: wildcard alias *gpt-5* → cx/gpt-5.4 resolves to codex for gpt-5.4", async () => {
const aliases = { "*gpt-5*": "cx/gpt-5.4" };
const result = await getModelInfoCore("gpt-5.4", aliases);
test("B: wildcard alias *gpt-5* → cx/gpt-5.6-sol resolves to codex", async () => {
const aliases = { "*gpt-5*": "cx/gpt-5.6-sol" };
const result = await getModelInfoCore("gpt-5.6-sol", aliases);
assert.strictEqual(result.provider, "codex");
});
// ── Scenario C: explicit provider prefix "openai/gpt-5.4" ────────────────────
// When the client sends "openai/gpt-5.4" explicitly, parseModel returns
// ── Scenario C: explicit provider prefix "openai/gpt-5.6-sol" ────────────────
// When the client sends "openai/gpt-5.6-sol" explicitly, parseModel returns
// isAlias=false. This bypasses the alias lookup in getModelInfoCore (by design —
// explicit provider prefixes override aliases). The alias "gpt-5.4 → cx/gpt-5.4"
// explicit provider prefixes override aliases). The alias "gpt-5.6-sol → cx/gpt-5.6-sol"
// should NOT apply here; this is intentional behavior.
// NOTE: Aliases for "openai/gpt-5.4" → "cx/gpt-5.4" are a distinct key and
// NOTE: Aliases for "openai/gpt-5.6-sol" → "cx/gpt-5.6-sol" are a distinct key and
// must be configured explicitly if desired.
test("C: explicit openai/gpt-5.4 resolves to openai regardless of bare alias", async () => {
// The alias is for bare "gpt-5.4", NOT for "openai/gpt-5.4".
test("C: explicit openai/gpt-5.6-sol resolves to openai regardless of bare alias", async () => {
// The alias is for bare "gpt-5.6-sol", NOT for "openai/gpt-5.6-sol".
// An explicit provider prefix takes precedence over aliases on the bare name.
const aliases = { "gpt-5.4": "cx/gpt-5.4" };
const result = await getModelInfoCore("openai/gpt-5.4", aliases);
const aliases = { "gpt-5.6-sol": "cx/gpt-5.6-sol" };
const result = await getModelInfoCore("openai/gpt-5.6-sol", aliases);
assert.strictEqual(result.provider, "openai");
assert.strictEqual(result.model, "gpt-5.4");
assert.strictEqual(result.model, "gpt-5.6-sol");
});
test("C: explicit provider prefix alias openai/gpt-5.4 → cx/gpt-5.4 is NOT applied (isAlias=false path)", async () => {
test("C: explicit openai/gpt-5.6-sol alias is not applied on the isAlias=false path", async () => {
// A slashful input is NOT treated as an alias key by getModelInfoCore.
// The alias lookup only runs when isAlias=true (bare model name).
// Users wanting to override openai/gpt-5.4 must configure cx/gpt-5.4 explicitly.
const aliases = { "openai/gpt-5.4": "cx/gpt-5.4" };
const result = await getModelInfoCore("openai/gpt-5.4", aliases);
// The slashful input is parsed as provider=openai, model=gpt-5.4 — alias not consulted
// Users wanting Codex must send cx/gpt-5.6-sol explicitly.
const aliases = { "openai/gpt-5.6-sol": "cx/gpt-5.6-sol" };
const result = await getModelInfoCore("openai/gpt-5.6-sol", aliases);
// The slashful input is parsed as provider=openai, model=gpt-5.6-sol — alias not consulted.
assert.strictEqual(result.provider, "openai");
});
// ── Scenario D: non-gpt aliases are unaffected ───────────────────────────────
test("D: unrelated models with no alias still route by inference", async () => {
const aliases = { "gpt-5.4": "cx/gpt-5.4" };
const aliases = { "gpt-5.6-sol": "cx/gpt-5.6-sol" };
const result = await getModelInfoCore("gpt-4o", aliases);
assert.strictEqual(result.provider, "openai");
assert.strictEqual(result.model, "gpt-4o");
@@ -102,7 +102,10 @@ test("D: unrelated models with no alias still route by inference", async () => {
test("D: claude model alias routes to requested provider", async () => {
// Explicit alias for a multi-provider model wins over inference
const aliases = { "gpt-5.4": "cx/gpt-5.4", "my-claude": "anthropic/claude-opus-4-7" };
const aliases = {
"gpt-5.6-sol": "cx/gpt-5.6-sol",
"my-claude": "anthropic/claude-opus-4-7",
};
const result = await getModelInfoCore("my-claude", aliases);
assert.strictEqual(result.provider, "anthropic");
assert.strictEqual(result.model, "claude-opus-4-7");

View File

@@ -22,10 +22,10 @@ test("[1m] suffix: works with provider prefix", () => {
});
test("parseModel trims provider prefix and model id", () => {
const result = parseModel(" cx / gpt-5.4 ");
const result = parseModel(" cx / gpt-5.6-sol ");
assert.strictEqual(result.providerAlias, "cx");
assert.strictEqual(result.provider, "codex");
assert.strictEqual(result.model, "gpt-5.4");
assert.strictEqual(result.model, "gpt-5.6-sol");
});
test("parseModel treats exact slashful model ids as models, not provider prefixes", () => {

View File

@@ -0,0 +1,78 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-model-sync-custom-preservation-")
);
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET ||= `test-model-sync-custom-${Date.now()}`;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const modelSyncRoute = await import("../../src/app/api/providers/[id]/sync-models/route.ts");
const scheduler = await import("../../src/shared/services/modelSyncScheduler.ts");
const originalFetch = globalThis.fetch;
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("model sync preserves response-only custom models during discovery", async () => {
const connection = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
name: "Codex Custom Preservation",
accessToken: "test-codex-token",
providerSpecificData: { workspaceId: "workspace-custom-preservation" },
});
await modelsDb.addCustomModel(
"codex",
"operator-private-codex",
"Operator Private Codex",
"manual",
"responses",
["responses"],
"openai-responses",
{ inputTokenLimit: 123456, outputTokenLimit: 6543 }
);
const before = await modelsDb.getCustomModels("codex");
globalThis.fetch = async (input) => {
const url = new URL(String(input));
if (url.pathname.includes("__readiness_probe__")) {
return new Response(null, { status: 404 });
}
if (url.pathname === `/api/providers/${connection.id}/models`) {
const models = [{ id: "future-codex-experimental", name: "Future Codex" }];
if (url.searchParams.get("excludeCustom") !== "true") {
models.push({ id: "operator-private-codex", name: "Operator Private Codex" });
}
return Response.json({ models, source: "api" });
}
throw new Error(`Unexpected fetch in custom preservation test: ${url.href}`);
};
const response = await modelSyncRoute.POST(
new Request(`http://localhost/api/providers/${connection.id}/sync-models?quiet=1`, {
method: "POST",
headers: scheduler.buildModelSyncInternalHeaders(),
}),
{ params: { id: connection.id } }
);
assert.equal(response.status, 200);
assert.deepEqual(await modelsDb.getCustomModels("codex"), before);
assert.deepEqual(
(await modelsDb.getSyncedAvailableModelsForConnection("codex", connection.id)).map(
(model) => model.id
),
["future-codex-experimental"]
);
});

View File

@@ -74,7 +74,7 @@ test("model sync route skips success log when fetched models do not change store
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({
models: [{ id: "custom-model-1", name: "Custom Model 1" }],
@@ -119,7 +119,7 @@ test("model sync route stores the real provider while keeping the account label"
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({
models: [{ id: "custom-model-2", name: "Custom Model 2" }],
@@ -204,7 +204,7 @@ test("model sync route propagates upstream failures and records an error log ent
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({ error: "Provider upstream unavailable" }, { status: 502 });
};
@@ -241,7 +241,7 @@ test("model sync route falls back to the upstream HTTP status when the models pa
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({}, { status: 429 });
};
@@ -277,7 +277,7 @@ test("model sync route reports invalid JSON /models responses without losing ups
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return new Response("<html>bad gateway</html>", {
status: 200,
@@ -325,7 +325,7 @@ test("model sync route preserves previously synced models when the upstream omit
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({});
};
@@ -369,7 +369,7 @@ test("model sync route writes synced available models for Gemini connections", a
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({
models: [
@@ -433,7 +433,7 @@ test("model sync route writes synced available models for non-Gemini providers t
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({
models: [
@@ -489,7 +489,7 @@ test("model sync route import mode merges discovered models without deleting man
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({
models: [{ id: "router-v4", name: "Router V4" }],
@@ -555,7 +555,7 @@ test("model sync route import mode ignores supported endpoint ordering changes",
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({
models: [
@@ -619,7 +619,7 @@ test("model sync route import mode reports updates without counting them as new
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({
models: [
@@ -693,7 +693,7 @@ test("model sync route records added, removed, and updated model diffs with fall
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({
models: [
@@ -772,7 +772,7 @@ test("model sync route forwards cookies, filters built-ins, and syncs aliases fo
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
assert.equal(init.headers.cookie, "session=test-cookie");
assert.equal(
@@ -837,7 +837,7 @@ test("model sync route reports synced managed models separately from preserved m
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({
models: [{ id: "router-v4", name: "Router V4" }],
@@ -902,7 +902,7 @@ test("model sync route uses provider-node prefixes when syncing compatible-provi
if (String(url).includes("__readiness_probe__")) return new Response(null, { status: 404 });
assert.equal(
String(url),
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true`
`http://127.0.0.1:20128/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`
);
return Response.json({
models: [{ id: "sonnet-4-6", name: "Sonnet 4.6" }],
@@ -957,7 +957,7 @@ test("model sync route falls back to in-process discovery when internal self-fet
fetchCalls.push(urlString);
if (urlString === `http://localhost/api/providers/${connection.id}/models?refresh=true`) {
if (urlString.includes("/models?refresh=true&excludeCustom=true")) {
throw new Error("fetch failed");
}
@@ -1004,7 +1004,7 @@ test("model sync route falls back to in-process discovery when internal self-fet
// Route forces IPv4 origin (http://127.0.0.1:PORT) — never "localhost" — to avoid
// ::1 (IPv6) resolution issues in containers. PORT defaults to 20128 when env unset.
const expectedPort = process.env.OMNIROUTE_PORT || process.env.PORT || "20128";
const selfFetchUrl = `http://127.0.0.1:${expectedPort}/api/providers/${connection.id}/models?refresh=true`;
const selfFetchUrl = `http://127.0.0.1:${expectedPort}/api/providers/${connection.id}/models?refresh=true&excludeCustom=true`;
assert.equal(
fetchCalls.slice(0, 3).every((u) => u === selfFetchUrl),
true,

View File

@@ -130,6 +130,157 @@ test("modelSyncScheduler: internal auth headers validate only for scheduler requ
assert.equal(isModelSyncInternalRequest(externalRequest), false);
});
test("modelSyncScheduler resolves only loopback origins and uses the dashboard port", async () => {
const previous = {
OMNIROUTE_PORT: process.env.OMNIROUTE_PORT,
PORT: process.env.PORT,
DASHBOARD_PORT: process.env.DASHBOARD_PORT,
BASE_URL: process.env.BASE_URL,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
OMNIROUTE_BASE_PATH: process.env.OMNIROUTE_BASE_PATH,
OMNIROUTE_INTERNAL_SCHEME: process.env.OMNIROUTE_INTERNAL_SCHEME,
OMNIROUTE_TLS_CERT: process.env.OMNIROUTE_TLS_CERT,
OMNIROUTE_TLS_KEY: process.env.OMNIROUTE_TLS_KEY,
};
process.env.OMNIROUTE_PORT = "20128";
process.env.PORT = "22128";
process.env.DASHBOARD_PORT = "22128";
process.env.BASE_URL = "https://attacker.example";
delete process.env.NEXT_PUBLIC_BASE_URL;
delete process.env.NEXT_PUBLIC_APP_URL;
process.env.OMNIROUTE_BASE_PATH = "/omniroute/";
delete process.env.OMNIROUTE_INTERNAL_SCHEME;
delete process.env.OMNIROUTE_TLS_CERT;
delete process.env.OMNIROUTE_TLS_KEY;
try {
const scheduler = await loadScheduler("trusted-loopback-origin");
assert.equal(scheduler.getModelSyncInternalBaseUrl(), "http://127.0.0.1:22128/omniroute");
assert.equal(
scheduler.resolveModelSyncInternalBaseUrl("https://attacker.example/steal"),
"http://127.0.0.1:22128/omniroute"
);
assert.equal(
scheduler.resolveModelSyncInternalBaseUrl("http://127.0.0.1:7777/nested/path"),
"http://127.0.0.1:22128/omniroute"
);
assert.equal(
scheduler.resolveModelSyncInternalBaseUrl("http://0.0.0.0:7777/nested/path"),
"http://127.0.0.1:22128/omniroute"
);
assert.equal(
scheduler.resolveModelSyncInternalBaseUrl("http://user:pass@localhost:7777"),
"http://127.0.0.1:22128/omniroute"
);
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
test("modelSyncScheduler does not infer the internal listener scheme from public URLs", async () => {
const previous = {
DASHBOARD_PORT: process.env.DASHBOARD_PORT,
BASE_URL: process.env.BASE_URL,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
OMNIROUTE_INTERNAL_SCHEME: process.env.OMNIROUTE_INTERNAL_SCHEME,
OMNIROUTE_TLS_CERT: process.env.OMNIROUTE_TLS_CERT,
OMNIROUTE_TLS_KEY: process.env.OMNIROUTE_TLS_KEY,
};
process.env.DASHBOARD_PORT = "22128";
process.env.BASE_URL = "https://attacker.example";
process.env.NEXT_PUBLIC_BASE_URL = "file:///tmp/not-http";
process.env.NEXT_PUBLIC_APP_URL = "https://localhost:7777/ignored";
delete process.env.OMNIROUTE_INTERNAL_SCHEME;
delete process.env.OMNIROUTE_TLS_CERT;
delete process.env.OMNIROUTE_TLS_KEY;
try {
const scheduler = await loadScheduler("safe-loopback-fallback");
assert.equal(scheduler.getModelSyncInternalBaseUrl(), "http://127.0.0.1:22128");
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
test("modelSyncScheduler uses the listener-declared TLS scheme without trusting candidates", async () => {
const previous = {
DASHBOARD_PORT: process.env.DASHBOARD_PORT,
BASE_URL: process.env.BASE_URL,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
OMNIROUTE_BASE_PATH: process.env.OMNIROUTE_BASE_PATH,
OMNIROUTE_INTERNAL_SCHEME: process.env.OMNIROUTE_INTERNAL_SCHEME,
OMNIROUTE_TLS_CERT: process.env.OMNIROUTE_TLS_CERT,
OMNIROUTE_TLS_KEY: process.env.OMNIROUTE_TLS_KEY,
};
process.env.DASHBOARD_PORT = "22128";
process.env.BASE_URL = "https://attacker.example";
delete process.env.NEXT_PUBLIC_BASE_URL;
delete process.env.NEXT_PUBLIC_APP_URL;
process.env.OMNIROUTE_BASE_PATH = "/omniroute";
process.env.OMNIROUTE_INTERNAL_SCHEME = "https";
delete process.env.OMNIROUTE_TLS_CERT;
delete process.env.OMNIROUTE_TLS_KEY;
try {
const scheduler = await loadScheduler("trusted-native-tls");
assert.equal(
scheduler.resolveModelSyncInternalBaseUrl("https://attacker.example:7777/steal"),
"https://localhost:22128/omniroute"
);
assert.equal(
scheduler.resolveModelSyncInternalBaseUrl("https://127.0.0.1:7777/nested/path"),
"https://localhost:22128/omniroute"
);
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
test("modelSyncScheduler pins HTTPS transport to IPv4 while retaining localhost SNI", async () => {
const scheduler = await loadScheduler("pinned-loopback-tls-connector");
let forwardedOptions: Record<string, unknown> | undefined;
const connector = scheduler.createPinnedModelSyncTlsConnector((options, callback) => {
forwardedOptions = options;
callback(new Error("stop before socket creation"), null);
});
connector(
{
hostname: "localhost",
host: "localhost:22128",
protocol: "https:",
port: "22128",
},
() => undefined
);
assert.equal(forwardedOptions?.hostname, "127.0.0.1");
assert.equal(forwardedOptions?.servername, "localhost");
});
test("runtime launchers publish the actual internal listener scheme", () => {
const runNext = fs.readFileSync(path.join(process.cwd(), "scripts/dev/run-next.mjs"), "utf8");
const standalone = fs.readFileSync(
path.join(process.cwd(), "scripts/dev/standalone-server-ws.mjs"),
"utf8"
);
assert.match(runNext, /OMNIROUTE_INTERNAL_SCHEME\s*=\s*["']http["']/);
assert.match(standalone, /OMNIROUTE_INTERNAL_SCHEME\s*=\s*tlsOptions\s*\?\s*["']https["']/);
});
test("initCloudSync: startup initialization also starts model sync scheduler", () => {
const filePath = path.join(process.cwd(), "src/lib/initCloudSync.ts");
const source = fs.readFileSync(filePath, "utf8");
@@ -224,8 +375,10 @@ test("modelSyncScheduler starts once, honors env interval and syncs only active
await timers.timeouts[0].fn();
assert.equal(fetchCalls.length, 1);
assert.match(fetchCalls[0].url, /^http:\/\/127\.0\.0\.1:20128\//);
assert.match(fetchCalls[0].url, /\/api\/providers\/.*\/sync-models$/);
assert.equal(fetchCalls[0].options.method, "POST");
assert.equal(fetchCalls[0].options.redirect, "error");
assert.equal(fetchCalls[0].options.headers["Content-Type"], "application/json");
assert.equal(
fetchCalls[0].options.headers[scheduler.getModelSyncInternalAuthHeaderName()],

Some files were not shown because too many files have changed in this diff Show More