mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
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:
@@ -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
|
||||
|
||||
2
changelog.d/features/6862-gpt-5-6-providers.md
Normal file
2
changelog.d/features/6862-gpt-5-6-providers.md
Normal 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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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` 解析)。未设置时进行试运行。 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
154
open-sse/executors/chatgpt-web/handoff.ts
Normal file
154
open-sse/executors/chatgpt-web/handoff.ts
Normal 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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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…"
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : {}),
|
||||
|
||||
395
src/shared/services/codexCatalogRevalidation.ts
Normal file
395
src/shared/services/codexCatalogRevalidation.ts
Normal 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" }));
|
||||
}
|
||||
30
src/shared/services/codexDiscoveryPolicy.ts
Normal file
30
src/shared/services/codexDiscoveryPolicy.ts
Normal 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}.`)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
###
|
||||
###
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
260
tests/unit/chatgpt-web-handoff-resume.test.ts
Normal file
260
tests/unit/chatgpt-web-handoff-resume.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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] ?? "";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
100
tests/unit/codex-catalog-revalidation-runtime.test.ts
Normal file
100
tests/unit/codex-catalog-revalidation-runtime.test.ts
Normal 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"]);
|
||||
});
|
||||
257
tests/unit/codex-catalog-revalidation.test.ts
Normal file
257
tests/unit/codex-catalog-revalidation.test.ts
Normal 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"]);
|
||||
});
|
||||
@@ -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"],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
62
tests/unit/codex-gpt56-catalog.test.ts
Normal file
62
tests/unit/codex-gpt56-catalog.test.ts
Normal 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),
|
||||
[]
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
52
tests/unit/executor-codex-gpt56.test.ts
Normal file
52
tests/unit/executor-codex-gpt56.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
26
tests/unit/image-registry-gpt56.test.ts
Normal file
26
tests/unit/image-registry-gpt56.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
78
tests/unit/model-sync-custom-preservation.test.ts
Normal file
78
tests/unit/model-sync-custom-preservation.test.ts
Normal 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"]
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user