From 67a0b99240750eead3515ac490bbb1521e7f4c8e Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 12 Jul 2026 15:30:47 +0900 Subject: [PATCH] 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 --- .env.example | 8 +- .../features/6862-gpt-5-6-providers.md | 2 + config/quality/eslint-suppressions.json | 2 +- docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md | 4 +- docs/providers/AGENTROUTER.md | 2 +- docs/reference/ENVIRONMENT.md | 2 +- docs/security/STEALTH_GUIDE.md | 4 +- open-sse/config/anthropicHeaders.ts | 2 +- open-sse/config/codexClient.ts | 2 +- open-sse/config/glmProvider.ts | 2 +- open-sse/config/imageRegistry.ts | 8 +- open-sse/config/providerRegistry.ts | 1 - .../providers/registry/chatgpt-web/index.ts | 9 +- .../config/providers/registry/codex/index.ts | 135 ++++-- .../config/providers/registry/openai/index.ts | 20 +- open-sse/config/providers/shared.ts | 18 +- open-sse/executors/chatgpt-web.ts | 91 +++- open-sse/executors/chatgpt-web/handoff.ts | 154 +++++++ open-sse/executors/chatgpt-web/models.ts | 32 +- open-sse/executors/claudeIdentity.ts | 2 +- open-sse/executors/codex.ts | 41 +- open-sse/executors/commandCode.ts | 4 +- open-sse/services/ccBridgeTransforms.ts | 4 +- open-sse/services/claudeCodeCompatible.ts | 4 +- scripts/check/check-env-doc-sync.mjs | 3 + scripts/dev/run-next.mjs | 9 +- scripts/dev/standalone-server-ws.mjs | 1 + scripts/i18n/run-translation.mjs | 2 +- .../cli-code/components/CodexToolCard.tsx | 14 +- .../cli-code/components/codexToolOptions.ts | 9 + src/app/(dashboard)/dashboard/combos/page.tsx | 2 +- .../providers/[id]/providerPageHelpers.ts | 1 + .../settings/components/CodexFastTierTab.tsx | 7 +- .../settings/components/ComboDefaultsTab.tsx | 2 +- src/app/api/cli-tools/codex-settings/route.ts | 2 +- .../providers/[id]/models/discovery/codex.ts | 76 +++- src/app/api/providers/[id]/models/route.ts | 96 +++-- .../api/providers/[id]/sync-models/route.ts | 47 ++- src/app/api/providers/route.ts | 7 +- src/app/api/sync/initialize/route.ts | 4 +- src/app/api/v1/images/edits/route.ts | 2 +- src/app/api/v1/models/catalog.ts | 23 +- src/lib/cli-helper/claudeProfileAutoSync.ts | 8 +- src/lib/cli-helper/codexProfileAutoSync.ts | 8 +- src/lib/db/settings.ts | 22 + src/lib/modelMetadataRegistry.ts | 13 +- src/lib/providers/codexFastTier.ts | 10 +- src/lib/providers/requestDefaults.ts | 9 +- src/lib/usage/costCalculator.ts | 13 +- src/lib/vscode/reasoningMetadata.ts | 33 +- .../constants/clientIdentityProfiles.ts | 13 +- src/shared/constants/modelSpecs.ts | 25 ++ src/shared/constants/pricing/frontier-labs.ts | 10 +- .../constants/pricing/oauth-subscriptions.ts | 66 ++- src/shared/constants/pricing/shared-tiers.ts | 24 ++ src/shared/reasoning/effortStandardization.ts | 41 +- .../services/codexCatalogRevalidation.ts | 395 ++++++++++++++++++ src/shared/services/codexDiscoveryPolicy.ts | 30 ++ src/shared/services/modelSyncScheduler.ts | 140 ++++++- src/shared/validation/providerSpecificData.ts | 4 +- src/shared/validation/schemas/cli.ts | 2 +- src/shared/validation/schemas/misc.ts | 12 +- src/sse/handlers/chat.ts | 18 +- tests/integration/chat-pipeline.test.ts | 2 +- tests/manual/image-generation.http | 4 +- tests/snapshots/provider/translate-path.json | 24 +- tests/unit/chat-context-relay.test.ts | 18 +- .../chat-rejects-image-only-model.test.ts | 20 + tests/unit/chatgpt-web-handoff-resume.test.ts | 260 ++++++++++++ tests/unit/chatgpt-web.test.ts | 61 ++- ...claude-codex-identity-version-sync.test.ts | 12 +- .../unit/claude-identity-version-sync.test.ts | 4 +- tests/unit/cli-model-config-schema.test.ts | 17 + tests/unit/cli-tools.test.ts | 4 +- tests/unit/client-identity-profiles.test.ts | 13 +- ...codex-catalog-revalidation-runtime.test.ts | 100 +++++ tests/unit/codex-catalog-revalidation.test.ts | 257 ++++++++++++ tests/unit/codex-fast-tier.test.ts | 6 +- tests/unit/codex-gpt56-catalog.test.ts | 62 +++ .../unit/codex-models-catalog-refresh.test.ts | 54 ++- tests/unit/codex-stream-false.test.ts | 10 +- .../combo-body-specific-400-stop-4279.test.ts | 2 +- tests/unit/combo-config.test.ts | 8 +- tests/unit/combo-context-relay.test.ts | 16 +- tests/unit/context-handoff.test.ts | 22 +- tests/unit/executor-codex-gpt56.test.ts | 52 +++ tests/unit/executor-codex.test.ts | 37 +- tests/unit/glm-executor.test.ts | 2 +- tests/unit/gpt-max-input-tokens-6191.test.ts | 8 +- tests/unit/gpt5-sampling-guard.test.ts | 4 +- tests/unit/guide-settings-route.test.ts | 6 +- tests/unit/image-generation-handler.test.ts | 18 +- tests/unit/image-generation-route.test.ts | 10 +- tests/unit/image-registry-gpt56.test.ts | 26 ++ ...image-routes-combo-edits-3214-3215.test.ts | 15 +- .../model-alias-provider-resolution.test.ts | 77 ++-- tests/unit/model-parse.test.ts | 4 +- .../model-sync-custom-preservation.test.ts | 78 ++++ tests/unit/model-sync-route.test.ts | 34 +- tests/unit/model-sync-scheduler.test.ts | 153 +++++++ tests/unit/openai-gpt56-catalog.test.ts | 57 +++ .../provider-models-custom-merge-6247.test.ts | 21 + .../provider-models-discovery-split.test.ts | 121 +++++- .../unit/provider-models-route-codex.test.ts | 167 ++++++-- tests/unit/provider-page-helpers-3501.test.ts | 3 +- .../provider-specific-data-schema.test.ts | 16 + .../unit/providers-autosync-ssrf-323.test.ts | 50 +++ tests/unit/quota-catalog-filter.test.ts | 10 +- .../request-defaults-store-session.test.ts | 6 + ...settings-schema-routing-strategies.test.ts | 2 +- .../specialty-model-catalog-routes.test.ts | 5 +- tests/unit/t12-pricing-updates.test.ts | 15 +- ...t40-opencode-cli-tools-integration.test.ts | 11 +- tests/unit/usage-analytics-route.test.ts | 20 +- tests/unit/usage-analytics.test.ts | 14 +- tests/unit/vscode-shared-metadata.test.ts | 67 ++- tests/unit/vscode-token-routes-gpt56.test.ts | 134 ++++++ tests/unit/vscode-token-routes.test.ts | 341 +++++++-------- 118 files changed, 3525 insertions(+), 791 deletions(-) create mode 100644 changelog.d/features/6862-gpt-5-6-providers.md create mode 100644 open-sse/executors/chatgpt-web/handoff.ts create mode 100644 src/app/(dashboard)/dashboard/cli-code/components/codexToolOptions.ts create mode 100644 src/shared/services/codexCatalogRevalidation.ts create mode 100644 src/shared/services/codexDiscoveryPolicy.ts create mode 100644 tests/unit/chatgpt-web-handoff-resume.test.ts create mode 100644 tests/unit/codex-catalog-revalidation-runtime.test.ts create mode 100644 tests/unit/codex-catalog-revalidation.test.ts create mode 100644 tests/unit/codex-gpt56-catalog.test.ts create mode 100644 tests/unit/executor-codex-gpt56.test.ts create mode 100644 tests/unit/image-registry-gpt56.test.ts create mode 100644 tests/unit/model-sync-custom-preservation.test.ts create mode 100644 tests/unit/openai-gpt56-catalog.test.ts create mode 100644 tests/unit/vscode-token-routes-gpt56.test.ts diff --git a/.env.example b/.env.example index 546cd4dd69..d6eb181ffb 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/changelog.d/features/6862-gpt-5-6-providers.md b/changelog.d/features/6862-gpt-5-6-providers.md new file mode 100644 index 0000000000..695473c505 --- /dev/null +++ b/changelog.d/features/6862-gpt-5-6-providers.md @@ -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 diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index ec6383fc29..dcea92f2a9 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -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": { diff --git a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md index 9dd8f61069..b97bf07d96 100644 --- a/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md +++ b/docs/i18n/zh-CN/docs/reference/ENVIRONMENT.md @@ -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` 解析)。未设置时进行试运行。 | \ No newline at end of file +| `OMNIROUTE_EVAL_CREDENTIALS` | `{}`(空) | `scripts/compression-eval/index.ts` | 运维人员提供的 JSON 凭证,供离线压缩评估 CLI 使用的服务商使用(通过 `JSON.parse` 解析)。未设置时进行试运行。 | diff --git a/docs/providers/AGENTROUTER.md b/docs/providers/AGENTROUTER.md index dcd54c7777..c5b1538fc9 100644 --- a/docs/providers/AGENTROUTER.md +++ b/docs/providers/AGENTROUTER.md @@ -130,7 +130,7 @@ request (see `open-sse/services/claudeCodeCompatible.ts`): | Header | Value | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `Authorization` | `Bearer ` | -| `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 | diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 15c310dbc5..dd93725f72 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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 | diff --git a/docs/security/STEALTH_GUIDE.md b/docs/security/STEALTH_GUIDE.md index c3415e93c0..9d60d7be59 100644 --- a/docs/security/STEALTH_GUIDE.md +++ b/docs/security/STEALTH_GUIDE.md @@ -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` | diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index d378407e8d..fa8d1e2212 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -121,7 +121,7 @@ export function normalizeAnthropicHeaderVariants(headers: Record } } -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"; diff --git a/open-sse/config/codexClient.ts b/open-sse/config/codexClient.ts index a24497f774..339886a8e6 100644 --- a/open-sse/config/codexClient.ts +++ b/open-sse/config/codexClient.ts @@ -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"; diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index 32bf03e8d2..951e9e550d 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -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", diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 0c0acf4e8d..41850fe2cf 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -149,7 +149,11 @@ export const IMAGE_PROVIDERS: Record = { 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 = { 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"], }, diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 85403fdec9..307afe2a18 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -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, diff --git a/open-sse/config/providers/registry/chatgpt-web/index.ts b/open-sse/config/providers/registry/chatgpt-web/index.ts index 2106b94a0b..6bdaeed9fc 100644 --- a/open-sse/config/providers/registry/chatgpt-web/index.ts +++ b/open-sse/config/providers/registry/chatgpt-web/index.ts @@ -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 ], }; diff --git a/open-sse/config/providers/registry/codex/index.ts b/open-sse/config/providers/registry/codex/index.ts index d739e09829..01a43c71fb 100644 --- a/open-sse/config/providers/registry/codex/index.ts +++ b/open-sse/config/providers/registry/codex/index.ts @@ -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, - }, ], }; diff --git a/open-sse/config/providers/registry/openai/index.ts b/open-sse/config/providers/registry/openai/index.ts index fcd291d254..63b6a30fa3 100644 --- a/open-sse/config/providers/registry/openai/index.ts +++ b/open-sse/config/providers/registry/openai/index.ts @@ -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, + }, ], }; diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 9582f66a9b..acf510ecf9 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -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; diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index ed4480857a..8be020cf8a 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -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).metadata as - | Record - | undefined; + Record | 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; } -interface FinalAssistantAnswer { - text: string; - messageId?: string; - metadata?: Record; - 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) | 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) | null, + // Legacy fallback for handoffs that omit the conduit token. pollFinalAnswer: ((conversationId: string) => Promise) | 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) | null, + resumeFinalAnswer: + ((conversationId: string, resumeToken: string) => Promise) | null, pollFinalAnswer: ((conversationId: string) => Promise) | 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 | 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 | null)?.messages as - | Array> - | undefined; + Array> | 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 diff --git a/open-sse/executors/chatgpt-web/handoff.ts b/open-sse/executors/chatgpt-web/handoff.ts new file mode 100644 index 0000000000..c0f66b5092 --- /dev/null +++ b/open-sse/executors/chatgpt-web/handoff.ts @@ -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; + finished: boolean; +} + +interface HandoffContentChunk { + answer?: string; + messageId?: string; + metadata?: Record; + error?: string; +} + +type HandoffContentReader = ( + eventStream: ReadableStream, + signal?: AbortSignal | null +) => AsyncIterable; + +interface ResumeHandoffOptions { + conversationId: string; + resumeToken: string; + headers: Record; + 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; +} + +interface ResumeAttemptResult { + answer: FinalAssistantAnswer | null; + shouldRetry: boolean; +} + +function stringToStream(text: string): ReadableStream { + const bytes = new TextEncoder().encode(text); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +async function readFinalAssistantAnswer( + eventStream: ReadableStream, + signal: AbortSignal | null | undefined, + readContent: HandoffContentReader +): Promise { + let text = ""; + let messageId: string | undefined; + let metadata: Record | 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 { + 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 { + 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; +} diff --git a/open-sse/executors/chatgpt-web/models.ts b/open-sse/executors/chatgpt-web/models.ts index 72738f43b0..b0f905d783 100644 --- a/open-sse/executors/chatgpt-web/models.ts +++ b/open-sse/executors/chatgpt-web/models.ts @@ -3,24 +3,22 @@ export const MODEL_MAP: Record = { // 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 = { }; export const MODEL_FORCED_EFFORT: Record = { + "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 = { /** 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 = 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 }; } diff --git a/open-sse/executors/claudeIdentity.ts b/open-sse/executors/claudeIdentity.ts index a5c276687a..f8a94a32ce 100644 --- a/open-sse/executors/claudeIdentity.ts +++ b/open-sse/executors/claudeIdentity.ts @@ -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"; diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 6712c3eb4c..d5a81b5e69 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -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 = { + "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; const directError = parsed.error as Record | undefined; const nestedError = (parsed.response as Record | undefined)?.error as - | Record - | undefined; + Record | 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 ); } diff --git a/open-sse/executors/commandCode.ts b/open-sse/executors/commandCode.ts index afd9811dd4..bebe6ebd21 100644 --- a/open-sse/executors/commandCode.ts +++ b/open-sse/executors/commandCode.ts @@ -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 ]; diff --git a/open-sse/services/ccBridgeTransforms.ts b/open-sse/services/ccBridgeTransforms.ts index ae444f97a9..41deb102a4 100644 --- a/open-sse/services/ccBridgeTransforms.ts +++ b/open-sse/services/ccBridgeTransforms.ts @@ -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."; diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index eff67c4203..01b021579f 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -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"; diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index d6fea05d1e..197d8ddeb9 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -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). diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 0ff02837f0..68eb9d2198 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -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…" diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index c96c9624ef..3d7bd1f0ca 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -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}`); } diff --git a/scripts/i18n/run-translation.mjs b/scripts/i18n/run-translation.mjs index 117ba0fd86..ae59d83028 100755 --- a/scripts/i18n/run-translation.mjs +++ b/scripts/i18n/run-translation.mjs @@ -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 */ diff --git a/src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx index 8abb015c9a..a8b352cdaf 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx @@ -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>({}); 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()}" + + diff --git a/src/app/(dashboard)/dashboard/cli-code/components/codexToolOptions.ts b/src/app/(dashboard)/dashboard/cli-code/components/codexToolOptions.ts new file mode 100644 index 0000000000..3d653c71dc --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-code/components/codexToolOptions.ts @@ -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; diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index fefc15aeda..a91ac0c7f3 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -4090,7 +4090,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo setConfig({ ...config, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index 9af38ea80e..4345d11962 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -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[] = [ diff --git a/src/app/(dashboard)/dashboard/settings/components/CodexFastTierTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CodexFastTierTab.tsx index 94af28bdf1..fe595b0421 100644 --- a/src/app/(dashboard)/dashboard/settings/components/CodexFastTierTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/CodexFastTierTab.tsx @@ -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); diff --git a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx index d522dfd593..285f830131 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx @@ -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, diff --git a/src/app/api/cli-tools/codex-settings/route.ts b/src/app/api/cli-tools/codex-settings/route.ts index 7e890fd8ed..2f382a62bc 100644 --- a/src/app/api/cli-tools/codex-settings/route.ts +++ b/src/app/api/cli-tools/codex-settings/route.ts @@ -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; diff --git a/src/app/api/providers/[id]/models/discovery/codex.ts b/src/app/api/providers/[id]/models/discovery/codex.ts index 431b822fbc..7992d36b67 100644 --- a/src/app/api/providers/[id]/models/discovery/codex.ts +++ b/src/app/api/providers/[id]/models/discovery/codex.ts @@ -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(); 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(); + 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[] diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 65a15ed642..1eadb25e55 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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 = {} + ) => { 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", diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index b4c5646cd3..94578e4c4c 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -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 { 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 { - 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) { diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 7d0aad17db..bf63734ffd 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -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}`); diff --git a/src/app/api/sync/initialize/route.ts b/src/app/api/sync/initialize/route.ts index 46f5a83b90..353db3f82f 100644 --- a/src/app/api/sync/initialize/route.ts +++ b/src/app/api/sync/initialize/route.ts @@ -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; } diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 0843173656..21e525d6df 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -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; diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index f6d9d25cc6..380827f11a 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -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}`; diff --git a/src/lib/cli-helper/claudeProfileAutoSync.ts b/src/lib/cli-helper/claudeProfileAutoSync.ts index 431f0a1013..a6c910b65b 100644 --- a/src/lib/cli-helper/claudeProfileAutoSync.ts +++ b/src/lib/cli-helper/claudeProfileAutoSync.ts @@ -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), }); diff --git a/src/lib/cli-helper/codexProfileAutoSync.ts b/src/lib/cli-helper/codexProfileAutoSync.ts index 023da5d899..6ae66ec59f 100644 --- a/src/lib/cli-helper/codexProfileAutoSync.ts +++ b/src/lib/cli-helper/codexProfileAutoSync.ts @@ -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), }); diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index ef29caea54..94e62594f1 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -190,6 +190,17 @@ export async function getSettings() { } export async function updateSettings(updates: Record) { + // 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) { ); } + // 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; } diff --git a/src/lib/modelMetadataRegistry.ts b/src/lib/modelMetadataRegistry.ts index 37e0dfa6e3..12d9f215a6 100644 --- a/src/lib/modelMetadataRegistry.ts +++ b/src/lib/modelMetadataRegistry.ts @@ -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( 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 + ), + } : {}), } : {}), diff --git a/src/lib/providers/codexFastTier.ts b/src/lib/providers/codexFastTier.ts index 9fe973e467..4f8887c787 100644 --- a/src/lib/providers/codexFastTier.ts +++ b/src/lib/providers/codexFastTier.ts @@ -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 diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 355937f199..ae6c2a5449 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -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]; diff --git a/src/lib/usage/costCalculator.ts b/src/lib/usage/costCalculator.ts index 94fc8668fe..a8976319ba 100644 --- a/src/lib/usage/costCalculator.ts +++ b/src/lib/usage/costCalculator.ts @@ -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; } diff --git a/src/lib/vscode/reasoningMetadata.ts b/src/lib/vscode/reasoningMetadata.ts index 60c1d30c1f..03780176d5 100644 --- a/src/lib/vscode/reasoningMetadata.ts +++ b/src/lib/vscode/reasoningMetadata.ts @@ -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[]) { diff --git a/src/shared/constants/clientIdentityProfiles.ts b/src/shared/constants/clientIdentityProfiles.ts index 3b3ef31e5a..3788bd507b 100644 --- a/src/shared/constants/clientIdentityProfiles.ts +++ b/src/shared/constants/clientIdentityProfiles.ts @@ -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 = 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) + ); } /** diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index b724487326..e70783b4ef 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -65,7 +65,32 @@ const AUTHORITATIVE_PROVIDER_CONTEXT_WINDOWS = new Map([ ["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 = { + "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, diff --git a/src/shared/constants/pricing/frontier-labs.ts b/src/shared/constants/pricing/frontier-labs.ts index 3c6d4819d3..0c7117cb57 100644 --- a/src/shared/constants/pricing/frontier-labs.ts +++ b/src/shared/constants/pricing/frontier-labs.ts @@ -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, diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index fcf65066dc..19c1f2e991 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -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, diff --git a/src/shared/constants/pricing/shared-tiers.ts b/src/shared/constants/pricing/shared-tiers.ts index 1d6cec77e6..596fe17446 100644 --- a/src/shared/constants/pricing/shared-tiers.ts +++ b/src/shared/constants/pricing/shared-tiers.ts @@ -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, diff --git a/src/shared/reasoning/effortStandardization.ts b/src/shared/reasoning/effortStandardization.ts index b71b721ec9..611433b8ff 100644 --- a/src/shared/reasoning/effortStandardization.ts +++ b/src/shared/reasoning/effortStandardization.ts @@ -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(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 = { ...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 : {}), diff --git a/src/shared/services/codexCatalogRevalidation.ts b/src/shared/services/codexCatalogRevalidation.ts new file mode 100644 index 0000000000..a65b7ad658 --- /dev/null +++ b/src/shared/services/codexCatalogRevalidation.ts @@ -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 { + const connections = await getProviderConnections({ provider: "codex" }); + const removedIds = new Set(); + 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> { + 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 { + 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 { + 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 { + 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; + waitForReady: () => Promise; + liveResync: () => Promise<{ attempted: number; succeeded: number }>; + writeMarker: (appVersion: string) => Promise; + logSuccess: () => void; +}): Promise { + 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 +): (options: CodexCatalogRevalidationRequest) => Promise { + let activeRun: Promise | 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 { + 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 { + return requestCodexCatalogRevalidation(options); +} + +/** Boot path: only first-start or upgrade. */ +export async function revalidateCodexCatalogsOnStartup(options?: { + apiBaseUrl?: string; +}): Promise { + 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 { + 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" })); +} diff --git a/src/shared/services/codexDiscoveryPolicy.ts b/src/shared/services/codexDiscoveryPolicy.ts new file mode 100644 index 0000000000..0da01f7035 --- /dev/null +++ b/src/shared/services/codexDiscoveryPolicy.ts @@ -0,0 +1,30 @@ +/** Exact Codex model ids retired after discovery merge. */ +export const CODEX_DISCOVERY_EXCLUDED_IDS: ReadonlySet = 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}.`) + ); + }); +} diff --git a/src/shared/services/modelSyncScheduler.ts b/src/shared/services/modelSyncScheduler.ts index 553a6783e1..026ebee310 100644 --- a/src/shared/services/modelSyncScheduler.ts +++ b/src/shared/services/modelSyncScheduler.ts @@ -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; + +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) : {}; - 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 { 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 { * @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?.(); } diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index 0087dd0e64..c07aceb52e 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -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"], }); } diff --git a/src/shared/validation/schemas/cli.ts b/src/shared/validation/schemas/cli.ts index 6c8f09113e..554ec47556 100644 --- a/src/shared/validation/schemas/cli.ts +++ b/src/shared/validation/schemas/cli.ts @@ -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(), }); diff --git a/src/shared/validation/schemas/misc.ts b/src/shared/validation/schemas/misc.ts index 54aa8e4e40..050598efdc 100644 --- a/src/shared/validation/schemas/misc.ts +++ b/src/shared/validation/schemas/misc.ts @@ -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; \ No newline at end of file +export type ConfirmedAccount = z.infer; diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 7de503e7e7..9609173633 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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, diff --git a/tests/integration/chat-pipeline.test.ts b/tests/integration/chat-pipeline.test.ts index 4aafa37adc..152a21a0ee 100644 --- a/tests/integration/chat-pipeline.test.ts +++ b/tests/integration/chat-pipeline.test.ts @@ -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"); diff --git a/tests/manual/image-generation.http b/tests/manual/image-generation.http index 5fc1645fd3..755245527c 100644 --- a/tests/manual/image-generation.http +++ b/tests/manual/image-generation.http @@ -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 } -### \ No newline at end of file +### diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 65269d491f..ec208cafd1 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -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": "", "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": "", "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": "", "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": "", "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": "", "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": "", "X-Stainless-Helper-Method": "stream", @@ -966,16 +966,16 @@ "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.144.0 (; )", - "Version": "0.144.0", + "User-Agent": "codex-cli/0.144.1 (; )", + "Version": "0.144.1", "X-Codex-Beta-Features": "responses_websockets" }, "nonStream": { "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.144.0 (; )", - "Version": "0.144.0", + "User-Agent": "codex-cli/0.144.1 (; )", + "Version": "0.144.1", "X-Codex-Beta-Features": "responses_websockets" }, "oauth": { @@ -983,8 +983,8 @@ "Authorization": "Bearer ", "Content-Type": "application/json", "Openai-Beta": "responses=experimental", - "User-Agent": "codex-cli/0.144.0 (; )", - "Version": "0.144.0", + "User-Agent": "codex-cli/0.144.1 (; )", + "Version": "0.144.1", "X-Codex-Beta-Features": "responses_websockets" } }, diff --git a/tests/unit/chat-context-relay.test.ts b/tests/unit/chat-context-relay.test.ts index a5c0d1f5a2..e9c766a699 100644 --- a/tests/unit/chat-context-relay.test.ts +++ b/tests/unit/chat-context-relay.test.ts @@ -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"); diff --git a/tests/unit/chat-rejects-image-only-model.test.ts b/tests/unit/chat-rejects-image-only-model.test.ts index 6d4fe33b24..a485544290 100644 --- a/tests/unit/chat-rejects-image-only-model.test.ts +++ b/tests/unit/chat-rejects-image-only-model.test.ts @@ -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" + ); + } +}); diff --git a/tests/unit/chatgpt-web-handoff-resume.test.ts b/tests/unit/chatgpt-web-handoff-resume.test.ts new file mode 100644 index 0000000000..29b549c628 --- /dev/null +++ b/tests/unit/chatgpt-web-handoff-resume.test.ts @@ -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 = {}): 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; +}; + +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: '', + 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) + .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(); + } +}); diff --git a/tests/unit/chatgpt-web.test.ts b/tests/unit/chatgpt-web.test.ts index 10ec9abec5..cc97ecd69d 100644 --- a/tests/unit/chatgpt-web.test.ts +++ b/tests/unit/chatgpt-web.test.ts @@ -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 = { - "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"); diff --git a/tests/unit/claude-codex-identity-version-sync.test.ts b/tests/unit/claude-codex-identity-version-sync.test.ts index 1d830299b8..cd64c72506 100644 --- a/tests/unit/claude-codex-identity-version-sync.test.ts +++ b/tests/unit/claude-codex-identity-version-sync.test.ts @@ -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"); }); diff --git a/tests/unit/claude-identity-version-sync.test.ts b/tests/unit/claude-identity-version-sync.test.ts index 52c6ddf810..b695826fc7 100644 --- a/tests/unit/claude-identity-version-sync.test.ts +++ b/tests/unit/claude-identity-version-sync.test.ts @@ -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/` 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] ?? ""; diff --git a/tests/unit/cli-model-config-schema.test.ts b/tests/unit/cli-model-config-schema.test.ts index 1c1b23bf75..29b089a112 100644 --- a/tests/unit/cli-model-config-schema.test.ts +++ b/tests/unit/cli-model-config-schema.test.ts @@ -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); + } + } +}); diff --git a/tests/unit/cli-tools.test.ts b/tests/unit/cli-tools.test.ts index 997b9f9eac..5156d3d97b 100644 --- a/tests/unit/cli-tools.test.ts +++ b/tests/unit/cli-tools.test.ts @@ -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( diff --git a/tests/unit/client-identity-profiles.test.ts b/tests/unit/client-identity-profiles.test.ts index 119c8a7b2d..d42e250d95 100644 --- a/tests/unit/client-identity-profiles.test.ts +++ b/tests/unit/client-identity-profiles.test.ts @@ -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; - 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"); }); diff --git a/tests/unit/codex-catalog-revalidation-runtime.test.ts b/tests/unit/codex-catalog-revalidation-runtime.test.ts new file mode 100644 index 0000000000..c7d2d78665 --- /dev/null +++ b/tests/unit/codex-catalog-revalidation-runtime.test.ts @@ -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"]); +}); diff --git a/tests/unit/codex-catalog-revalidation.test.ts b/tests/unit/codex-catalog-revalidation.test.ts new file mode 100644 index 0000000000..e70bdbc686 --- /dev/null +++ b/tests/unit/codex-catalog-revalidation.test.ts @@ -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((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((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"]); +}); diff --git a/tests/unit/codex-fast-tier.test.ts b/tests/unit/codex-fast-tier.test.ts index f411574345..645d4ce43d 100644 --- a/tests/unit/codex-fast-tier.test.ts +++ b/tests/unit/codex-fast-tier.test.ts @@ -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"], + } ); }); diff --git a/tests/unit/codex-gpt56-catalog.test.ts b/tests/unit/codex-gpt56-catalog.test.ts new file mode 100644 index 0000000000..4c5d3c6d30 --- /dev/null +++ b/tests/unit/codex-gpt56-catalog.test.ts @@ -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), + [] + ); +}); diff --git a/tests/unit/codex-models-catalog-refresh.test.ts b/tests/unit/codex-models-catalog-refresh.test.ts index 1889005b17..9de940db09 100644 --- a/tests/unit/codex-models-catalog-refresh.test.ts +++ b/tests/unit/codex-models-catalog-refresh.test.ts @@ -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); +}); diff --git a/tests/unit/codex-stream-false.test.ts b/tests/unit/codex-stream-false.test.ts index c20ff069b6..5af1f80f3c 100644 --- a/tests/unit/codex-stream-false.test.ts +++ b/tests/unit/codex-stream-false.test.ts @@ -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( diff --git a/tests/unit/combo-body-specific-400-stop-4279.test.ts b/tests/unit/combo-body-specific-400-stop-4279.test.ts index 4bf6bf92bd..838f9a5677 100644 --- a/tests/unit/combo-body-specific-400-stop-4279.test.ts +++ b/tests/unit/combo-body-specific-400-stop-4279.test.ts @@ -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: {}, diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts index e3b99e9733..a49513d835 100644 --- a/tests/unit/combo-config.test.ts +++ b/tests/unit/combo-config.test.ts @@ -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", }, ], diff --git a/tests/unit/combo-context-relay.test.ts b/tests/unit/combo-context-relay.test.ts index ea36bba079..5e83b460c3 100644 --- a/tests/unit/combo-context-relay.test.ts +++ b/tests/unit/combo-context-relay.test.ts @@ -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(), diff --git a/tests/unit/context-handoff.test.ts b/tests/unit/context-handoff.test.ts index 86eeb23024..845f118a5e 100644 --- a/tests/unit/context-handoff.test.ts +++ b/tests/unit/context-handoff.test.ts @@ -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"); diff --git a/tests/unit/executor-codex-gpt56.test.ts b/tests/unit/executor-codex-gpt56.test.ts new file mode 100644 index 0000000000..3e5d2b9506 --- /dev/null +++ b/tests/unit/executor-codex-gpt56.test.ts @@ -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"); +}); diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index d011823963..771930e1e5 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -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 => ({ - send() {}, - close() {}, - onmessage: null, - onerror: null, - onclose: null, - }) - ); + __setCodexWebSocketTransportForTesting(async (): Promise => ({ + 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; 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" } }, diff --git a/tests/unit/glm-executor.test.ts b/tests/unit/glm-executor.test.ts index fad28da90e..0ae127d7e8 100644 --- a/tests/unit/glm-executor.test.ts +++ b/tests/unit/glm-executor.test.ts @@ -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"); }); diff --git a/tests/unit/gpt-max-input-tokens-6191.test.ts b/tests/unit/gpt-max-input-tokens-6191.test.ts index f89c42edb1..df2a90fd60 100644 --- a/tests/unit/gpt-max-input-tokens-6191.test.ts +++ b/tests/unit/gpt-max-input-tokens-6191.test.ts @@ -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, diff --git a/tests/unit/gpt5-sampling-guard.test.ts b/tests/unit/gpt5-sampling-guard.test.ts index 1bfa01433c..9bc9d72e6c 100644 --- a/tests/unit/gpt5-sampling-guard.test.ts +++ b/tests/unit/gpt5-sampling-guard.test.ts @@ -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); }); diff --git a/tests/unit/guide-settings-route.test.ts b/tests/unit/guide-settings-route.test.ts index 75960c2938..b6648d2d79 100644 --- a/tests/unit/guide-settings-route.test.ts +++ b/tests/unit/guide-settings-route.test.ts @@ -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" }, }); }); diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index 0fcc9e5d13..348cd83817 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -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, }); diff --git a/tests/unit/image-generation-route.test.ts b/tests/unit/image-generation-route.test.ts index b00c159255..4cd99aa0e5 100644 --- a/tests/unit/image-generation-route.test.ts +++ b/tests/unit/image-generation-route.test.ts @@ -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( diff --git a/tests/unit/image-registry-gpt56.test.ts b/tests/unit/image-registry-gpt56.test.ts new file mode 100644 index 0000000000..937e0393c0 --- /dev/null +++ b/tests/unit/image-registry-gpt56.test.ts @@ -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 }); + } +}); diff --git a/tests/unit/image-routes-combo-edits-3214-3215.test.ts b/tests/unit/image-routes-combo-edits-3214-3215.test.ts index c71a5c08e4..55201c85ce 100644 --- a/tests/unit/image-routes-combo-edits-3214-3215.test.ts +++ b/tests/unit/image-routes-combo-edits-3214-3215.test.ts @@ -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); }); diff --git a/tests/unit/model-alias-provider-resolution.test.ts b/tests/unit/model-alias-provider-resolution.test.ts index d300e08cad..32ef346b29 100644 --- a/tests/unit/model-alias-provider-resolution.test.ts +++ b/tests/unit/model-alias-provider-resolution.test.ts @@ -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"); diff --git a/tests/unit/model-parse.test.ts b/tests/unit/model-parse.test.ts index cc2a950713..7f05dbf2d5 100644 --- a/tests/unit/model-parse.test.ts +++ b/tests/unit/model-parse.test.ts @@ -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", () => { diff --git a/tests/unit/model-sync-custom-preservation.test.ts b/tests/unit/model-sync-custom-preservation.test.ts new file mode 100644 index 0000000000..c8f8fc7e67 --- /dev/null +++ b/tests/unit/model-sync-custom-preservation.test.ts @@ -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"] + ); +}); diff --git a/tests/unit/model-sync-route.test.ts b/tests/unit/model-sync-route.test.ts index ed62050236..0e71b96c17 100644 --- a/tests/unit/model-sync-route.test.ts +++ b/tests/unit/model-sync-route.test.ts @@ -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("bad gateway", { 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, diff --git a/tests/unit/model-sync-scheduler.test.ts b/tests/unit/model-sync-scheduler.test.ts index 1204666683..449c91475b 100644 --- a/tests/unit/model-sync-scheduler.test.ts +++ b/tests/unit/model-sync-scheduler.test.ts @@ -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 | 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()], diff --git a/tests/unit/openai-gpt56-catalog.test.ts b/tests/unit/openai-gpt56-catalog.test.ts new file mode 100644 index 0000000000..b928d6f7c5 --- /dev/null +++ b/tests/unit/openai-gpt56-catalog.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getModelsByProviderId } from "../../open-sse/config/providerModels.ts"; +import { getModelSpec } from "../../src/shared/constants/modelSpecs.ts"; +import { getPricingForModel } from "../../src/shared/constants/pricing.ts"; + +const EXPECTED_MODELS = ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]; + +test("OpenAI API catalog exposes the public GPT-5.6 family and keeps GPT-5.4", () => { + const models = getModelsByProviderId("openai"); + + assert.deepEqual( + models.slice(0, EXPECTED_MODELS.length).map((model) => model.id), + EXPECTED_MODELS + ); + + for (const modelId of EXPECTED_MODELS) { + const model = models.find((entry) => entry.id === modelId); + assert.ok(model, `openai must expose ${modelId}`); + assert.equal(model.contextLength, 1050000); + assert.equal(model.maxInputTokens, 922000); + assert.equal(model.maxOutputTokens, 128000); + assert.equal(model.toolCalling, true); + assert.equal(model.supportsReasoning, true); + assert.equal(model.supportsVision, true); + + const spec = getModelSpec(modelId); + assert.equal(spec?.contextWindow, 1050000); + assert.equal(spec?.maxOutputTokens, 128000); + } + + for (const retainedModelId of ["gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini", "gpt-5.4-nano"]) { + assert.ok( + models.some((model) => model.id === retainedModelId), + `${retainedModelId} must remain` + ); + } +}); + +test("OpenAI API GPT-5.6 pricing matches the published standard tier", () => { + const expectedPricing = { + "gpt-5.6": { input: 5, cached: 0.5, cache_creation: 6.25, output: 30 }, + "gpt-5.6-sol": { input: 5, cached: 0.5, cache_creation: 6.25, output: 30 }, + "gpt-5.6-terra": { input: 2.5, cached: 0.25, cache_creation: 3.125, output: 15 }, + "gpt-5.6-luna": { input: 1, cached: 0.1, cache_creation: 1.25, output: 6 }, + }; + + for (const [modelId, expected] of Object.entries(expectedPricing)) { + const pricing = getPricingForModel("openai", modelId); + assert.ok(pricing, `missing openai pricing for ${modelId}`); + assert.equal(pricing.input, expected.input, `${modelId} input`); + assert.equal(pricing.cached, expected.cached, `${modelId} cached`); + assert.equal(pricing.cache_creation, expected.cache_creation, `${modelId} cache creation`); + assert.equal(pricing.output, expected.output, `${modelId} output`); + } +}); diff --git a/tests/unit/provider-models-custom-merge-6247.test.ts b/tests/unit/provider-models-custom-merge-6247.test.ts index 85e15fcf77..b7cc0904b2 100644 --- a/tests/unit/provider-models-custom-merge-6247.test.ts +++ b/tests/unit/provider-models-custom-merge-6247.test.ts @@ -98,3 +98,24 @@ test("per-connection models route includes user-added custom models on the local assert.ok(custom, "user-added custom model must appear in the per-connection catalog"); assert.equal(custom.owned_by, "aimlapi", "custom model must be stamped owned_by = provider"); }); + +test("per-connection models route can exclude response-only custom models for sync", async () => { + const connection = await seedConnection("aimlapi", { apiKey: "aiml-key" }); + + await modelsDb.addCustomModel("aimlapi", "my-org/custom-model-sync", "My Custom Sync"); + globalThis.fetch = (async () => new Response("upstream down", { status: 500 })) as typeof fetch; + + const response = await callRoute(connection.id, "?excludeCustom=true"); + const body = (await response.json()) as { + source?: string; + models?: Array<{ id: string }>; + }; + + assert.equal(response.status, 200); + assert.equal(body.source, "local_catalog"); + assert.equal( + (body.models || []).some((model) => model.id === "my-org/custom-model-sync"), + false, + "internal model-sync discovery must not reclassify response-only custom rows" + ); +}); diff --git a/tests/unit/provider-models-discovery-split.test.ts b/tests/unit/provider-models-discovery-split.test.ts index 3f3168c201..9dd3502575 100644 --- a/tests/unit/provider-models-discovery-split.test.ts +++ b/tests/unit/provider-models-discovery-split.test.ts @@ -24,7 +24,10 @@ import { isNamedOpenAIStyleProvider, } from "../../src/app/api/providers/[id]/models/discovery/providerSets.ts"; import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts"; +import { isCodexDiscoveryModelExcluded as isSharedCodexDiscoveryModelExcluded } from "../../src/shared/services/codexDiscoveryPolicy.ts"; import { + applyCodexDiscoveryFilters, + buildCodexDiscoveryCatalog, buildCodexModelsUrl, CODEX_GITHUB_MODELS_URL, CODEX_MODELS_URL, @@ -32,9 +35,11 @@ import { enrichCodexModelsFromGithubCatalog, fetchCodexDiscoveryModels, fetchCodexGithubCatalogModels, + isCodexDiscoveryModelExcluded, mergeCodexLiveModelsWithLocalCatalog, normalizeCodexGithubCatalogResponse, normalizeCodexModelsResponse, + reconcileCuratedCodexCatalog, } from "../../src/app/api/providers/[id]/models/discovery/codex.ts"; // ── helpers leaf ───────────────────────────────────────────────────────────── @@ -228,7 +233,7 @@ test("codex.normalizeCodexGithubCatalogResponse parses current client catalog me description: "Latest frontier agentic coding model.", visibility: "list", supported_in_api: true, - minimal_client_version: "0.142.0", + minimal_client_version: "0.144.0", context_window: 372000, input_modalities: ["text", "image"], supported_reasoning_levels: [{ effort: "low" }, { effort: "ultra" }], @@ -299,31 +304,119 @@ test("codex.enrichCodexModelsFromGithubCatalog keeps live entitlement list autho assert.equal(enriched[0]?.supportsVision, true); }); -test("codex.mergeCodexLiveModelsWithLocalCatalog preserves static effort aliases", () => { +test("codex.mergeCodexLiveModelsWithLocalCatalog auto-includes remote-only models", () => { const merged = mergeCodexLiveModelsWithLocalCatalog( [ { - id: "gpt-5.6", - name: "GPT 5.6 Live", + id: "future-codex-model", + name: "Future Codex Model", owned_by: "codex", apiFormat: "responses", supportedEndpoints: ["responses"], - inputTokenLimit: 512000, + }, + { + id: "gpt-5.6-sol", + name: "Live Sol", + owned_by: "codex", + apiFormat: "responses", + supportedEndpoints: ["responses"], + inputTokenLimit: 999999, + supportsVision: true, }, ], [ - { id: "gpt-5.6", name: "GPT 5.6 Static", contextLength: 400000 }, - { id: "gpt-5.6-low", name: "GPT 5.6 Low", contextLength: 400000 }, + { + id: "gpt-5.6-sol", + name: "GPT 5.6 Sol", + contextLength: 500000, + maxInputTokens: 372000, + maxOutputTokens: 128000, + }, + { id: "gpt-5.6-sol-low", name: "GPT 5.6 Sol (Low)", contextLength: 500000 }, ] ); + const ids = merged.map((model) => model.id); + assert.ok(ids.includes("future-codex-model")); + assert.ok(ids.includes("gpt-5.6-sol")); + assert.ok(ids.includes("gpt-5.6-sol-low")); + const sol = merged.find((model) => model.id === "gpt-5.6-sol"); + // Local catalog enriches known IDs; live fields win on overlap via merge order. + assert.equal(sol?.inputTokenLimit, 999999); + assert.equal(sol?.supportsVision, true); + assert.equal(sol?.outputTokenLimit, 128000); +}); + +test("codex discovery filters drop the GPT-5.4 family but keep other remote models", () => { + assert.equal(isCodexDiscoveryModelExcluded({ id: "gpt-5.4", name: "x" }), true); + assert.equal(isCodexDiscoveryModelExcluded({ id: "gpt-5.4-mini", name: "x" }), true); + assert.equal(isCodexDiscoveryModelExcluded({ id: "gpt-5.6-sol", name: "x" }), false); + + const filtered = applyCodexDiscoveryFilters([ + { id: "gpt-5.4", name: "Retired" }, + { id: "gpt-5.4-pro", name: "Retired Pro" }, + { id: "future-codex-model", name: "Future" }, + { id: "gpt-5.6-sol", name: "Sol" }, + ]); assert.deepEqual( - merged.map((model) => model.id), - ["gpt-5.6", "gpt-5.6-low"] + filtered.map((model) => model.id), + ["future-codex-model", "gpt-5.6-sol"] + ); +}); + +test("shared Codex discovery policy only matches explicit GPT-5.4 family boundaries", () => { + for (const id of ["GPT-5.4", "gpt-5.4-mini", "gpt-5.4_preview", "gpt-5.4.1"]) { + assert.equal(isSharedCodexDiscoveryModelExcluded({ id }), true, id); + } + for (const id of ["gpt-5.40", "gpt-5.4x", "future-codex-model"]) { + assert.equal(isSharedCodexDiscoveryModelExcluded({ id }), false, id); + } +}); + +test("codex.buildCodexDiscoveryCatalog merges then filters in one step", () => { + const catalog = buildCodexDiscoveryCatalog( + [ + { id: "gpt-5.4", name: "Retired Live" }, + { id: "brand-new-codex", name: "Brand New" }, + { + id: "gpt-5.6-sol", + name: "Live Sol", + inputTokenLimit: 111, + supportsVision: true, + }, + ], + [ + { + id: "gpt-5.6-sol", + name: "GPT 5.6 Sol", + maxInputTokens: 372000, + maxOutputTokens: 128000, + }, + { id: "gpt-5.6-sol-max", name: "GPT 5.6 Sol Max" }, + ] + ); + const ids = catalog.map((model) => model.id); + assert.ok(ids.includes("brand-new-codex")); + assert.ok(ids.includes("gpt-5.6-sol")); + assert.ok(ids.includes("gpt-5.6-sol-max")); + assert.equal( + ids.some((id) => String(id).startsWith("gpt-5.4")), + false + ); + + // Optional curated helper still available for diagnostics only. + const curated = reconcileCuratedCodexCatalog( + [{ id: "brand-new-codex", name: "Brand New" }], + [{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }] + ); + assert.deepEqual( + curated.models.map((model) => model.id), + ["gpt-5.6-sol"] + ); + assert.deepEqual( + curated.candidateModels.map((model) => model.id), + ["brand-new-codex"] ); - assert.equal(merged.find((model) => model.id === "gpt-5.6")?.name, "GPT 5.6 Live"); - assert.equal(merged.find((model) => model.id === "gpt-5.6")?.inputTokenLimit, 512000); - assert.equal(merged.find((model) => model.id === "gpt-5.6-low")?.inputTokenLimit, 400000); }); test("codex.normalizeCodexModelsResponse drops entries without an id", () => { @@ -410,7 +503,7 @@ test("codex.fetchCodexGithubCatalogModels fetches the OpenAI Codex repo catalog" display_name: "GPT-5.6-Terra", visibility: "list", supported_in_api: true, - minimal_client_version: "0.142.0", + minimal_client_version: "0.144.0", }, ], }); @@ -443,7 +536,7 @@ test("codex.fetchCodexGithubCatalogModels reuses cached catalog with ETags", asy display_name: "GPT-5.6-Luna", visibility: "list", supported_in_api: true, - minimal_client_version: "0.142.0", + minimal_client_version: "0.144.0", }, ], }, diff --git a/tests/unit/provider-models-route-codex.test.ts b/tests/unit/provider-models-route-codex.test.ts index b03b1ee38b..c9b3824793 100644 --- a/tests/unit/provider-models-route-codex.test.ts +++ b/tests/unit/provider-models-route-codex.test.ts @@ -31,6 +31,7 @@ type RouteBody = { source?: string; warning?: string; intentional?: boolean; + discoveredCandidateCount?: number; }; type ProviderOverrides = { @@ -81,7 +82,7 @@ test.after(async () => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -test("provider models route discovers live Codex models and preserves static aliases", async () => { +test("provider models route merges live Codex models with the local catalog then filters denylist", async () => { const connection = await seedCodexConnection({ accessToken: "codex-access-token", providerSpecificData: { chatgptAccountId: "account-123" }, @@ -105,8 +106,8 @@ test("provider models route discovers live Codex models and preserves static ali return Response.json({ models: [ { - slug: "gpt-5.6", - display_name: "GPT 5.6 GitHub", + slug: "gpt-5.6-sol", + display_name: "GPT 5.6 Sol GitHub", visibility: "list", supported_in_api: true, minimal_client_version: "0.144.0", @@ -114,6 +115,12 @@ test("provider models route discovers live Codex models and preserves static ali input_modalities: ["text", "image"], supported_reasoning_levels: [{ effort: "low" }, { effort: "high" }], }, + { + slug: "gpt-5.4", + display_name: "Retired GPT 5.4 GitHub", + visibility: "list", + supported_in_api: true, + }, ], }); } @@ -121,12 +128,18 @@ test("provider models route discovers live Codex models and preserves static ali models: [ { slug: "codex-auto-review", visibility: "hide", supported_in_api: true }, { - slug: "gpt-5.6", - display_name: "GPT 5.6", + slug: "gpt-5.6-sol", + display_name: "GPT 5.6 Sol Live", + visibility: "list", + supported_in_api: true, + max_input_tokens: 999999, + max_output_tokens: 999999, + }, + { + slug: "gpt-5.4", + display_name: "Retired GPT 5.4 Live", visibility: "list", supported_in_api: true, - max_input_tokens: 272000, - max_output_tokens: 128000, }, { id: "", name: "missing-id" }, ], @@ -136,20 +149,21 @@ test("provider models route discovers live Codex models and preserves static ali const response = await callRoute(connection.id, "?refresh=true"); const body = (await response.json()) as RouteBody; const modelIds = new Set(body.models?.map((model) => model.id)); - const liveModel = body.models?.find((model) => model.id === "gpt-5.6"); + const liveModel = body.models?.find((model) => model.id === "gpt-5.6-sol"); const syncedModels = await modelsDb.getSyncedAvailableModelsForConnection("codex", connection.id); const syncedIds = new Set(syncedModels.map((model) => model.id)); assert.equal(response.status, 200); assert.equal(body.provider, "codex"); assert.equal(body.source, "api"); + assert.equal(body.discoveredCandidateCount, undefined); assert.deepEqual(seenRequests, [ { - url: "https://chatgpt.com/backend-api/codex/models?client_version=0.144.0", + url: "https://chatgpt.com/backend-api/codex/models?client_version=0.144.1", authorization: "Bearer codex-access-token", workspaceId: "account-123", originator: "codex_cli_rs", - userAgent: "codex-cli/0.144.0 (Windows 10.0.26200; x64)", + userAgent: "codex-cli/0.144.1 (Windows 10.0.26200; x64)", }, { url: "https://raw.githubusercontent.com/openai/codex/refs/heads/main/codex-rs/models-manager/models.json", @@ -159,18 +173,29 @@ test("provider models route discovers live Codex models and preserves static ali userAgent: null, }, ]); - assert.equal(liveModel?.name, "GPT 5.6"); - assert.equal(liveModel?.inputTokenLimit, 272000); - assert.equal(liveModel?.outputTokenLimit, 128000); + assert.ok(modelIds.has("gpt-5.6-sol")); + assert.ok(modelIds.has("gpt-5.6-sol-ultra")); + assert.ok(modelIds.has("gpt-5.6-sol-max")); + // Live payload wins on overlapping fields; local catalog supplies local-only variants. + assert.equal(liveModel?.name, "GPT 5.6 Sol Live"); + assert.equal(liveModel?.inputTokenLimit, 999999); + assert.equal(liveModel?.outputTokenLimit, 999999); assert.equal(liveModel?.apiFormat, "responses"); assert.deepEqual(liveModel?.supportedEndpoints, ["responses"]); assert.equal(liveModel?.supportsThinking, true); assert.equal(liveModel?.supportsVision, true); assert.ok(modelIds.has("gpt-5.5-low")); - assert.ok(modelIds.has("gpt-5.4-xhigh")); - assert.ok(syncedIds.has("gpt-5.6")); + assert.equal( + [...modelIds].some((id) => String(id).startsWith("gpt-5.4")), + false + ); + assert.ok(syncedIds.has("gpt-5.6-sol")); assert.ok(syncedIds.has("gpt-5.5-low")); - assert.ok(syncedIds.has("gpt-5.4-xhigh")); + assert.equal( + [...syncedIds].some((id) => String(id).startsWith("gpt-5.4")), + false + ); + // Stale cache-only ids are replaced when a fresh discovery response is persisted. assert.equal(modelIds.has("stale-codex-model"), false); assert.equal(syncedIds.has("stale-codex-model"), false); }); @@ -193,6 +218,12 @@ test("provider models route uses the GitHub Codex catalog when live discovery fa minimal_client_version: "0.144.0", context_window: 372000, }, + { + slug: "gpt-5.4", + display_name: "Retired GPT-5.4", + visibility: "list", + supported_in_api: true, + }, ], }); } @@ -208,18 +239,30 @@ test("provider models route uses the GitHub Codex catalog when live discovery fa assert.equal(body.source, "api"); assert.equal(body.intentional, undefined); assert.equal(body.warning, "Codex live catalog unavailable — using GitHub model catalog"); + assert.equal(body.discoveredCandidateCount, undefined); assert.ok(seenUrls.some((url) => url.includes("backend-api/codex/models"))); assert.ok(seenUrls.some((url) => url.includes("raw.githubusercontent.com/openai/codex"))); assert.ok(modelIds.has("gpt-5.6-sol")); assert.ok(modelIds.has("gpt-5.5-low")); + assert.equal( + [...modelIds].some((id) => String(id).startsWith("gpt-5.4")), + false + ); }); test("provider models route returns cached Codex models when refresh discovery fails", async () => { const connection = await seedCodexConnection({ accessToken: "codex-access-token" }); await modelsDb.replaceSyncedAvailableModelsForConnection("codex", connection.id, [ { - id: "cached-live-codex", - name: "Cached Live Codex", + id: "gpt-5.4", + name: "Retired Cached GPT 5.4", + source: "imported", + apiFormat: "responses", + supportedEndpoints: ["responses"], + }, + { + id: "gpt-5.6-sol", + name: "Cached GPT 5.6 Sol", source: "imported", apiFormat: "responses", supportedEndpoints: ["responses"], @@ -235,15 +278,65 @@ test("provider models route returns cached Codex models when refresh discovery f assert.equal(body.provider, "codex"); assert.equal(body.source, "cache"); assert.equal(body.warning, "Codex live catalog unavailable — using cached catalog"); - assert.deepEqual(body.models, [ - { - id: "cached-live-codex", - name: "Cached Live Codex", - source: "imported", - apiFormat: "responses", - supportedEndpoints: ["responses"], - }, - ]); + assert.equal(body.discoveredCandidateCount, undefined); + const modelIds = new Set((body.models || []).map((model) => model.id)); + assert.ok(modelIds.has("gpt-5.6-sol")); + assert.ok(modelIds.has("gpt-5.6-sol-ultra")); + assert.equal( + [...modelIds].some((id) => String(id).startsWith("gpt-5.4")), + false + ); + const syncedModels = await modelsDb.getSyncedAvailableModelsForConnection("codex", connection.id); + const syncedIds = new Set(syncedModels.map((model) => model.id)); + assert.ok(syncedIds.has("gpt-5.6-sol-ultra")); + assert.equal(syncedIds.has("gpt-5.4"), false); +}); + +test("provider models route auto-includes remote-only Codex models after merge", async () => { + const connection = await seedCodexConnection({ accessToken: "codex-access-token" }); + + globalThis.fetch = async (url) => { + const requestUrl = String(url); + if (requestUrl.includes("raw.githubusercontent.com/openai/codex")) { + return Response.json({ models: [] }); + } + return Response.json({ + models: [ + { + slug: "future-codex-experimental", + display_name: "Future Codex Experimental", + visibility: "list", + supported_in_api: true, + }, + { + slug: "gpt-5.6-sol", + display_name: "GPT 5.6 Sol Live", + visibility: "list", + supported_in_api: true, + }, + { + slug: "gpt-5.4", + display_name: "Retired GPT 5.4 Live", + visibility: "list", + supported_in_api: true, + }, + ], + }); + }; + + const response = await callRoute(connection.id, "?refresh=true"); + const body = (await response.json()) as RouteBody; + const modelIds = new Set((body.models || []).map((model) => model.id)); + const syncedModels = await modelsDb.getSyncedAvailableModelsForConnection("codex", connection.id); + const syncedIds = new Set(syncedModels.map((model) => model.id)); + + assert.equal(response.status, 200); + assert.equal(body.source, "api"); + assert.ok(modelIds.has("future-codex-experimental")); + assert.ok(modelIds.has("gpt-5.6-sol")); + assert.equal(modelIds.has("gpt-5.4"), false); + assert.ok(syncedIds.has("future-codex-experimental")); + assert.equal(syncedIds.has("gpt-5.4"), false); }); test("provider models route falls back to local Codex catalog when live and GitHub fail", async () => { @@ -259,10 +352,15 @@ test("provider models route falls back to local Codex catalog when live and GitH assert.equal(body.source, "local_catalog"); assert.equal(body.intentional, true); assert.equal(body.warning, "Codex live and GitHub catalogs unavailable — using local catalog"); + assert.ok(body.models?.some((model) => model.id === "gpt-5.6-sol")); assert.ok(body.models?.some((model) => model.id === "gpt-5.5")); + assert.equal( + body.models?.some((model) => model.id.startsWith("gpt-5.4")), + false + ); }); -test("provider models route returns codex gpt-5.4 effort variants when auto-fetch is disabled", async () => { +test("provider models route returns curated GPT-5.6 variants when auto-fetch is disabled", async () => { const connection = await seedCodexConnection({ apiKey: null, accessToken: "codex-access", @@ -276,9 +374,12 @@ test("provider models route returns codex gpt-5.4 effort variants when auto-fetc assert.equal(response.status, 200); assert.equal(body.provider, "codex"); assert.equal(body.source, "local_catalog"); - assert.ok(modelIds.has("gpt-5.4")); - assert.ok(modelIds.has("gpt-5.4-low")); - assert.ok(modelIds.has("gpt-5.4-medium")); - assert.ok(modelIds.has("gpt-5.4-high")); - assert.ok(modelIds.has("gpt-5.4-xhigh")); + assert.ok(modelIds.has("gpt-5.6-sol-ultra")); + assert.ok(modelIds.has("gpt-5.6-sol-max")); + assert.ok(modelIds.has("gpt-5.6-terra-ultra")); + assert.ok(modelIds.has("gpt-5.6-luna-max")); + assert.equal( + [...modelIds].some((id) => String(id).startsWith("gpt-5.4")), + false + ); }); diff --git a/tests/unit/provider-page-helpers-3501.test.ts b/tests/unit/provider-page-helpers-3501.test.ts index 762272aa9d..fdb2740818 100644 --- a/tests/unit/provider-page-helpers-3501.test.ts +++ b/tests/unit/provider-page-helpers-3501.test.ts @@ -172,8 +172,7 @@ test("getProtoSlice returns custom compat over override", () => { test("CODEX_REASONING_STRENGTH_OPTIONS has expected values", () => { const values = CODEX_REASONING_STRENGTH_OPTIONS.map((o) => o.value); - assert.ok(values.includes("none")); - assert.ok(values.includes("high")); + assert.deepEqual(values, ["none", "low", "medium", "high", "xhigh", "max"]); }); test("CODEX_ACCOUNT_SERVICE_TIER_VALUES contains expected tiers", () => { diff --git a/tests/unit/provider-specific-data-schema.test.ts b/tests/unit/provider-specific-data-schema.test.ts index 85536f305d..e00a9ca395 100644 --- a/tests/unit/provider-specific-data-schema.test.ts +++ b/tests/unit/provider-specific-data-schema.test.ts @@ -117,6 +117,22 @@ test("provider schemas accept Codex default priority and flex service tiers", () } }); +test("provider schemas accept max but reject ultra as a server-side Codex default", () => { + const max = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { + requestDefaults: { reasoningEffort: "max" }, + }, + }); + const ultra = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { + requestDefaults: { reasoningEffort: "ultra" }, + }, + }); + + assert.equal(max.success, true); + assert.equal(ultra.success, false); +}); + test("provider schemas reject unknown Codex service tiers", () => { const created = createProviderSchema.safeParse({ provider: "codex", diff --git a/tests/unit/providers-autosync-ssrf-323.test.ts b/tests/unit/providers-autosync-ssrf-323.test.ts index 1df4c4dcf2..2d9eb921a4 100644 --- a/tests/unit/providers-autosync-ssrf-323.test.ts +++ b/tests/unit/providers-autosync-ssrf-323.test.ts @@ -21,6 +21,22 @@ const routeSrc = readFileSync( join(import.meta.dirname, "../../src/app/api/providers/route.ts"), "utf8" ); +const syncInitializeRouteSrc = readFileSync( + join(import.meta.dirname, "../../src/app/api/sync/initialize/route.ts"), + "utf8" +); +const syncModelsRouteSrc = readFileSync( + join(import.meta.dirname, "../../src/app/api/providers/[id]/sync-models/route.ts"), + "utf8" +); +const codexProfileSyncSrc = readFileSync( + join(import.meta.dirname, "../../src/lib/cli-helper/codexProfileAutoSync.ts"), + "utf8" +); +const claudeProfileSyncSrc = readFileSync( + join(import.meta.dirname, "../../src/lib/cli-helper/claudeProfileAutoSync.ts"), + "utf8" +); test("POST /api/providers auto-sync uses the trusted internal origin (not request.url) — #323", () => { assert.ok( @@ -32,4 +48,38 @@ test("POST /api/providers auto-sync uses the trusted internal origin (not reques /const\s+internalOrigin\s*=\s*new URL\(request\.url\)\.origin/, "auto-sync origin must NOT be derived from the client-controlled request.url/Host (SSRF, CodeQL js/request-forgery #323)" ); + assert.match( + routeSrc, + /fetchModelSyncInternal\(syncUrl,\s*\{[^}]*redirect:\s*["']error["']/s, + "credential-bearing auto-sync self-fetch must reject redirects" + ); +}); + +test("POST /api/sync/initialize never forwards the client Origin to model sync", () => { + assert.doesNotMatch( + syncInitializeRouteSrc, + /request\.headers\.get\(["']origin["']\)/, + "client-controlled Origin must not become the credential-bearing model-sync base URL" + ); + assert.doesNotMatch( + syncInitializeRouteSrc, + /startModelSyncScheduler\(origin\)/, + "model-sync scheduler must resolve its own trusted loopback origin" + ); +}); + +test("credential-forwarding CLI profile self-fetches reject redirects", () => { + for (const source of [codexProfileSyncSrc, claudeProfileSyncSrc]) { + assert.match(source, /redirect:\s*["']error["']/); + } +}); + +test("nested model-sync self-fetches use the shared dashboard resolver and reject redirects", () => { + assert.ok( + syncModelsRouteSrc.match(/getModelSyncInternalBaseUrl\(\)/g)?.length >= 2, + "readiness and nested model discovery must share the trusted dashboard resolver" + ); + assert.match(syncModelsRouteSrc, /fetchModelSyncInternal\(/); + assert.doesNotMatch(syncModelsRouteSrc, /const\s+(?:incomingUrl|loopbackPort)\s*=/); + assert.match(syncModelsRouteSrc, /redirect:\s*["']error["']/); }); diff --git a/tests/unit/quota-catalog-filter.test.ts b/tests/unit/quota-catalog-filter.test.ts index 519f66ea03..d7a0820e25 100644 --- a/tests/unit/quota-catalog-filter.test.ts +++ b/tests/unit/quota-catalog-filter.test.ts @@ -12,7 +12,7 @@ import { filterModelsToQuotaPools } from "../../src/lib/quota/quotaCombos.js"; describe("filterModelsToQuotaPools", () => { const models = [ { id: "qtSd/times/codex/gpt-5.5" }, - { id: "qtSd/times/codex/gpt-5.4" }, + { id: "qtSd/times/codex/gpt-5.6-sol" }, { id: "cx/gpt-5.5" }, { id: "qtSd/other/codex/m" }, ]; @@ -21,7 +21,7 @@ describe("filterModelsToQuotaPools", () => { const result = filterModelsToQuotaPools(models, ["times"]); assert.deepEqual(result, [ { id: "qtSd/times/codex/gpt-5.5" }, - { id: "qtSd/times/codex/gpt-5.4" }, + { id: "qtSd/times/codex/gpt-5.6-sol" }, ]); }); @@ -40,7 +40,7 @@ describe("filterModelsToQuotaPools", () => { const result = filterModelsToQuotaPools(models, ["times", "other"]); assert.deepEqual(result, [ { id: "qtSd/times/codex/gpt-5.5" }, - { id: "qtSd/times/codex/gpt-5.4" }, + { id: "qtSd/times/codex/gpt-5.6-sol" }, { id: "qtSd/other/codex/m" }, ]); }); @@ -51,9 +51,7 @@ describe("filterModelsToQuotaPools", () => { { id: "cx/gpt-5.5", object: "model", owned_by: "cx" }, ]; const result = filterModelsToQuotaPools(richModels, ["times"]); - assert.deepEqual(result, [ - { id: "qtSd/times/cx/gpt-5.5", object: "model", owned_by: "combo" }, - ]); + assert.deepEqual(result, [{ id: "qtSd/times/cx/gpt-5.5", object: "model", owned_by: "combo" }]); }); it("does not match a model from a different group when only one slug is provided", () => { diff --git a/tests/unit/request-defaults-store-session.test.ts b/tests/unit/request-defaults-store-session.test.ts index 602178cf56..45122e0465 100644 --- a/tests/unit/request-defaults-store-session.test.ts +++ b/tests/unit/request-defaults-store-session.test.ts @@ -5,10 +5,16 @@ const { buildOpenAIStoreSessionId, ensureOpenAIStoreSessionFallback, getClaudeCodeCompatibleRequestDefaults, + normalizeCodexReasoningEffort, normalizeProviderSpecificData, sanitizeProviderSpecificDataForResponse, } = await import("../../src/lib/providers/requestDefaults.ts"); +test("Codex request defaults accept max but leave ultra to the Codex client", () => { + assert.equal(normalizeCodexReasoningEffort("max"), "max"); + assert.equal(normalizeCodexReasoningEffort("ultra"), undefined); +}); + test("buildOpenAIStoreSessionId normalizes external and generated session ids", () => { assert.equal( buildOpenAIStoreSessionId("ext:client session/abc"), diff --git a/tests/unit/settings-schema-routing-strategies.test.ts b/tests/unit/settings-schema-routing-strategies.test.ts index 964fe19e6b..4ba3152d05 100644 --- a/tests/unit/settings-schema-routing-strategies.test.ts +++ b/tests/unit/settings-schema-routing-strategies.test.ts @@ -91,7 +91,7 @@ test("settings schemas accept global Codex fast tier setting", () => { codexServiceTier: { enabled: true, tier: "flex", - supportedModels: ["gpt-5.5", "gpt-5.4"], + supportedModels: ["gpt-5.6-sol", "gpt-5.6-terra"], }, }; const routeParsed = settingsRouteSchema.parse(payload); diff --git a/tests/unit/specialty-model-catalog-routes.test.ts b/tests/unit/specialty-model-catalog-routes.test.ts index 790592ab20..01ab26691c 100644 --- a/tests/unit/specialty-model-catalog-routes.test.ts +++ b/tests/unit/specialty-model-catalog-routes.test.ts @@ -65,7 +65,10 @@ test("image catalog GET uses the unified active-credential model list", async () const ids = await listedIds(imageRoute, "/v1/images/generations"); - 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("openai/gpt-image-2")); }); diff --git a/tests/unit/t12-pricing-updates.test.ts b/tests/unit/t12-pricing-updates.test.ts index d8d5aca520..799f7cec76 100644 --- a/tests/unit/t12-pricing-updates.test.ts +++ b/tests/unit/t12-pricing-updates.test.ts @@ -4,11 +4,20 @@ import assert from "node:assert/strict"; import { getDefaultPricing } from "../../src/shared/constants/pricing.ts"; import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; -test("T12: pricing table includes MiniMax, GLM, Kimi and gpt-5.4 mini entries", () => { +test("T12: pricing table includes current Codex, MiniMax, GLM and Kimi entries", () => { const pricing = getDefaultPricing(); - assert.ok(pricing.cx["gpt-5.4"], "missing cx/gpt-5.4"); - assert.ok(pricing.cx["gpt-5.4-mini"], "missing cx/gpt-5.4-mini"); + assert.ok(pricing.cx["gpt-5.6-sol-ultra"], "missing cx/gpt-5.6-sol-ultra"); + assert.ok(pricing.cx["gpt-5.6-terra-max"], "missing cx/gpt-5.6-terra-max"); + assert.ok(pricing.cx["gpt-5.6-luna-max"], "missing cx/gpt-5.6-luna-max"); + assert.equal(pricing.cx["gpt-5.6-sol"].input, 5); + assert.equal(pricing.cx["gpt-5.6-sol"].output, 30); + assert.equal(pricing.cx["gpt-5.6-terra"].input, 2.5); + assert.equal(pricing.cx["gpt-5.6-terra"].output, 15); + assert.equal(pricing.cx["gpt-5.6-luna"].input, 1); + assert.equal(pricing.cx["gpt-5.6-luna"].output, 6); + assert.equal(pricing.cx["gpt-5.4"], undefined); + assert.equal(pricing.cx["gpt-5.4-mini"], undefined); assert.ok(pricing.minimax["minimax-m2.5"], "missing minimax/minimax-m2.5"); assert.ok(pricing.minimax["minimax-m2.7"], "missing minimax/minimax-m2.7"); diff --git a/tests/unit/t40-opencode-cli-tools-integration.test.ts b/tests/unit/t40-opencode-cli-tools-integration.test.ts index 580b08f5ee..feaacf8256 100644 --- a/tests/unit/t40-opencode-cli-tools-integration.test.ts +++ b/tests/unit/t40-opencode-cli-tools-integration.test.ts @@ -46,10 +46,7 @@ test("T40: OpenCode config path resolves per-platform", () => { { APPDATA: "C:\\Users\\dev\\AppData\\Roaming" }, "C:\\Users\\dev" ); - assert.equal( - windowsPath, - path.join("C:\\Users\\dev", ".config", "opencode", "opencode.json") - ); + assert.equal(windowsPath, path.join("C:\\Users\\dev", ".config", "opencode", "opencode.json")); // Windows still honors XDG_CONFIG_HOME when set. const windowsXdg = resolveOpencodeConfigPath( @@ -149,8 +146,8 @@ test("T40: OpenCode merge preserves unrelated config and updates only provider.o { baseUrl: "http://localhost:20128/v1", apiKey: "sk_test_opencode", - models: ["cx/gpt-5.4"], - modelLabels: { "cx/gpt-5.4": "GPT-5.4" }, + models: ["cx/gpt-5.6-sol"], + modelLabels: { "cx/gpt-5.6-sol": "GPT-5.6 Sol" }, } ); @@ -159,7 +156,7 @@ test("T40: OpenCode merge preserves unrelated config and updates only provider.o github: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] }, }); assert.deepEqual(mergedConfig.provider.omniroute.models, { - "cx/gpt-5.4": { name: "GPT-5.4" }, + "cx/gpt-5.6-sol": { name: "GPT-5.6 Sol" }, }); }); diff --git a/tests/unit/usage-analytics-route.test.ts b/tests/unit/usage-analytics-route.test.ts index 89ea451f6a..a74e4e34f7 100644 --- a/tests/unit/usage-analytics-route.test.ts +++ b/tests/unit/usage-analytics-route.test.ts @@ -220,22 +220,32 @@ test("GET /api/usage/analytics does not report flex savings for non-Codex provid assert.equal(flexTier.usageSavingsTokens, 0); }); -test("GET /api/usage/analytics applies Codex GPT-5.4 Fast multiplier", async () => { +test("GET /api/usage/analytics applies Codex GPT-5.6 Sol Fast multiplier", async () => { await localDb.updatePricing({ - codex: { "gpt-5.4": { input: 5, output: 30 } }, + codex: { "gpt-5.6-sol": { input: 5, output: 30 } }, }); const db = core.getDbInstance(); db.prepare( `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, service_tier, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).run("codex", "gpt-5.4", "codex-fast", 1000, 500, 1, 250, "priority", new Date().toISOString()); + ).run( + "codex", + "gpt-5.6-sol", + "codex-fast", + 1000, + 500, + 1, + 250, + "priority", + new Date().toISOString() + ); const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics")); const body = await response.json(); assert.equal(response.status, 200); - assertClose(body.summary.totalCost, 0.04); - assertClose(body.summary.fastCost, 0.04); + assertClose(body.summary.totalCost, 0.03); + assertClose(body.summary.fastCost, 0.03); }); test("GET /api/usage/analytics maps Codex auto-review usage to GPT-5.5 pricing", async () => { diff --git a/tests/unit/usage-analytics.test.ts b/tests/unit/usage-analytics.test.ts index ea4aec0eae..ff7e486ccc 100644 --- a/tests/unit/usage-analytics.test.ts +++ b/tests/unit/usage-analytics.test.ts @@ -15,7 +15,8 @@ const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); const usageStats = await import("../../src/lib/usage/usageStats.ts"); const legacyUsageAnalytics = await import("../../src/lib/usageAnalytics.ts"); const callLogs = await import("../../src/lib/usage/callLogs.ts"); -const { calculateCost } = await import("../../src/lib/usage/costCalculator.ts"); +const { calculateCost, getCodexFastCostMultiplier } = + await import("../../src/lib/usage/costCalculator.ts"); // Use the official clearPendingRequests export instead of manual cleanup const clearPendingRequests = usageHistory.clearPendingRequests; @@ -398,11 +399,11 @@ test("computeAnalytics groups renamed API key usage by stable ID", async () => { assert.equal(analytics.byApiKey[0].completionTokens, 15); }); -test("Codex Fast service tier applies documented GPT-5.5 and GPT-5.4 cost multipliers", async () => { +test("Codex Fast service tier applies GPT-5.5 and GPT-5.6 credit multipliers", async () => { await localDb.updatePricing({ codex: { "gpt-5.5": { input: 5, output: 30 }, - "gpt-5.4": { input: 5, output: 30 }, + "gpt-5.6-sol": { input: 5, output: 30 }, }, }); @@ -411,7 +412,12 @@ test("Codex Fast service tier applies documented GPT-5.5 and GPT-5.4 cost multip assert.equal(await calculateCost("codex", "gpt-5.5", tokens), 0.02); assert.equal(await calculateCost("codex", "gpt-5.5", tokens, { serviceTier: "priority" }), 0.05); assert.equal(await calculateCost("codex", "gpt-5.5", tokens, { serviceTier: "flex" }), 0.01); - assert.equal(await calculateCost("codex", "gpt-5.4-high", tokens, { serviceTier: "fast" }), 0.04); + assert.equal( + await calculateCost("codex", "gpt-5.6-sol-high", tokens, { serviceTier: "fast" }), + 0.03 + ); + assert.equal(getCodexFastCostMultiplier("cx", "gpt-5.6-terra-ultra", "fast"), 1.5); + assert.equal(getCodexFastCostMultiplier("codex", "gpt-5.6-luna-max", "priority"), 1.5); assert.equal(await calculateCost("openai", "gpt-5.5", tokens, { serviceTier: "priority" }), 0.02); assert.equal(await calculateCost("openai", "gpt-5.5", tokens, { serviceTier: "flex" }), 0.02); }); diff --git a/tests/unit/vscode-shared-metadata.test.ts b/tests/unit/vscode-shared-metadata.test.ts index 9d8e1d0047..4ad453793d 100644 --- a/tests/unit/vscode-shared-metadata.test.ts +++ b/tests/unit/vscode-shared-metadata.test.ts @@ -15,30 +15,35 @@ const rawReasoningMetadata = test("vscode raw and tokenized family-first helpers share behavior", () => { assert.equal( - familyFirstModelIds.resolveFamilyFirstPublishedModelId("gpt-5.4__provider_cx__tier_priority"), - "cx/gpt-5.4__tier_priority" + familyFirstModelIds.resolveFamilyFirstPublishedModelId( + "gpt-5.6-sol__provider_cx__tier_priority" + ), + "cx/gpt-5.6-sol__tier_priority" ); assert.deepEqual( - rawFamilyFirstModelIds.getFamilyFirstModelCandidates("cx/gpt-5.4__tier_flex", "gpt-5.4"), - familyFirstModelIds.getFamilyFirstModelCandidates("cx/gpt-5.4__tier_flex", "gpt-5.4") + rawFamilyFirstModelIds.getFamilyFirstModelCandidates( + "cx/gpt-5.6-sol__tier_flex", + "gpt-5.6-sol" + ), + familyFirstModelIds.getFamilyFirstModelCandidates("cx/gpt-5.6-sol__tier_flex", "gpt-5.6-sol") ); }); test("vscode raw and tokenized service tier helpers share behavior", () => { const tokenizedPayload = serviceTierVariants.resolveVscodeServiceTierRequest({ - model: "gpt-5.4__provider_cx__tier_flex", + model: "gpt-5.6-sol__provider_cx__tier_flex", }); const rawPayload = rawServiceTierVariants.resolveVscodeServiceTierRequest({ - model: "gpt-5.4__provider_cx__tier_flex", + model: "gpt-5.6-sol__provider_cx__tier_flex", }); assert.deepEqual(rawPayload, tokenizedPayload); assert.deepEqual( serviceTierVariants.expandVscodeServiceTierModels([ - { id: "cx/gpt-5.4", name: "cx/gpt-5.4", owned_by: "codex" }, + { id: "cx/gpt-5.6-sol", name: "cx/gpt-5.6-sol", owned_by: "codex" }, ]), rawServiceTierVariants.expandVscodeServiceTierModels([ - { id: "cx/gpt-5.4", name: "cx/gpt-5.4", owned_by: "codex" }, + { id: "cx/gpt-5.6-sol", name: "cx/gpt-5.6-sol", owned_by: "codex" }, ]) ); }); @@ -61,3 +66,49 @@ test("vscode raw and tokenized reasoning helpers share behavior", () => { rawReasoningMetadata.buildReasoningConfigSchema(["none", "high"], "high") ); }); + +test("vscode reasoning metadata supports GPT-5.6 Max and Ultra without splitting legacy slugs", () => { + const sol = { + id: "cx/gpt-5.6-sol", + owned_by: "codex", + capabilities: { reasoning: true }, + }; + const luna = { + id: "cx/gpt-5.6-luna", + owned_by: "codex", + capabilities: { reasoning: true }, + }; + + assert.deepEqual(reasoningMetadata.getReasoningEffortValues(sol), [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ]); + assert.deepEqual(reasoningMetadata.getReasoningEffortValues(luna), [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + ]); + assert.equal( + reasoningMetadata.inferSelectedReasoningEffort( + { ...sol, id: "cx/gpt-5.6-sol-ultra" }, + reasoningMetadata.getReasoningEffortValues(sol) + ), + "ultra" + ); + assert.equal( + reasoningMetadata.getReasoningVariantBaseModelId("cx/gpt-5.6-sol-max"), + "cx/gpt-5.6-sol" + ); + assert.equal( + reasoningMetadata.getReasoningVariantBaseModelId("cx/gpt-5.1-codex-max"), + "cx/gpt-5.1-codex-max" + ); +}); diff --git a/tests/unit/vscode-token-routes-gpt56.test.ts b/tests/unit/vscode-token-routes-gpt56.test.ts new file mode 100644 index 0000000000..293e312a7c --- /dev/null +++ b/tests/unit/vscode-token-routes-gpt56.test.ts @@ -0,0 +1,134 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-vscode-token-routes-gpt56-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "vscode-token-routes-gpt56-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const vscodeRawModelsRoute = + await import("../../src/app/api/v1/vscode/raw/[token]/models/route.ts"); + +interface RawModel { + id: string; + [key: string]: unknown; +} + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("vscode raw models route exposes native GPT-5.6 IDs and effort tiers", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "codex-vscode-raw-models", + accessToken: "codex-test-token", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + const key = await apiKeysDb.createApiKey( + "vscode-raw-models-codex", + "machine-vscode-raw-models-codex" + ); + + const response = await vscodeRawModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/models`) + ); + const body = (await response.json()) as { data?: RawModel[] }; + const models = body.data ?? []; + const importedIds = new Set(models.map((entry) => entry.id)); + const findModel = (id: string) => models.find((entry) => entry.id === id); + const defaultModel = findModel("cx/gpt-5.6-sol"); + const fastModel = findModel("cx/gpt-5.6-sol__tier_priority"); + const flexModel = findModel("cx/gpt-5.6-sol__tier_flex"); + + assert.equal(response.status, 200); + assert.ok(defaultModel, "missing cx/gpt-5.6-sol in raw VS Code models route"); + assert.ok(fastModel, "missing cx/gpt-5.6-sol__tier_priority in raw VS Code models route"); + assert.ok(flexModel, "missing cx/gpt-5.6-sol__tier_flex in raw VS Code models route"); + assert.equal(importedIds.size, models.length, "raw VS Code models route must not duplicate ids"); + assert.ok(!importedIds.has("gpt-5.6-sol__provider_cx")); + assert.ok(!importedIds.has("gpt-5.6-sol__provider_cx__tier_priority")); + assert.ok(!importedIds.has("gpt-5.6-sol__provider_cx__tier_flex")); + assert.equal(defaultModel.object, "model"); + assert.equal(typeof defaultModel.created, "number"); + assert.equal(defaultModel.owned_by, "codex"); + assert.equal(defaultModel.name, "Codex GPT 5.6 Sol"); + assert.equal(typeof defaultModel.context_length, "number"); + assert.equal(typeof defaultModel.max_output_tokens, "number"); + assert.equal(typeof defaultModel.max_input_tokens, "number"); + assert.deepEqual(defaultModel.capabilities, { + vision: true, + tool_calling: true, + reasoning: true, + thinking: true, + supportsThinking: true, + effort_tiers: ["none", "low", "medium", "high", "xhigh", "max", "ultra"], + }); + for (const field of [ + "url", + "toolCalling", + "vision", + "family", + "supportsReasoningEffort", + "supportedReasoningEfforts", + "defaultReasoningEffort", + "configurationSchema", + "configSchema", + "maxInputTokens", + ]) { + assert.equal(defaultModel[field], undefined); + } + + const lowModel = findModel("cx/gpt-5.6-sol-low"); + const mediumModel = findModel("cx/gpt-5.6-sol-medium"); + const highModel = findModel("cx/gpt-5.6-sol-high"); + const lowFastModel = findModel("cx/gpt-5.6-sol-low__tier_priority"); + const mediumFastModel = findModel("cx/gpt-5.6-sol-medium__tier_priority"); + const highFastModel = findModel("cx/gpt-5.6-sol-high__tier_priority"); + + assert.ok(lowModel, "missing cx/gpt-5.6-sol-low in raw VS Code models route"); + assert.ok(mediumModel, "missing cx/gpt-5.6-sol-medium in raw VS Code models route"); + assert.ok(highModel, "missing cx/gpt-5.6-sol-high in raw VS Code models route"); + assert.ok(lowFastModel, "missing cx/gpt-5.6-sol-low__tier_priority in raw VS Code models route"); + assert.ok( + mediumFastModel, + "missing cx/gpt-5.6-sol-medium__tier_priority in raw VS Code models route" + ); + assert.ok( + highFastModel, + "missing cx/gpt-5.6-sol-high__tier_priority in raw VS Code models route" + ); + assert.equal(lowModel.name, "Codex GPT 5.6 Sol (Low)"); + assert.equal(lowFastModel.name, "Codex GPT 5.6 Sol (Low) (Fast)"); + assert.equal(mediumFastModel.name, "Codex GPT 5.6 Sol (Medium) (Fast)"); + assert.equal(highFastModel.name, "Codex GPT 5.6 Sol (High) (Fast)"); +}); diff --git a/tests/unit/vscode-token-routes.test.ts b/tests/unit/vscode-token-routes.test.ts index 0a0cb4f8b3..24dfebb4fe 100644 --- a/tests/unit/vscode-token-routes.test.ts +++ b/tests/unit/vscode-token-routes.test.ts @@ -112,11 +112,11 @@ test("vscode tokenized root route exposes friendly model names alongside ids", a new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/`) ); const body = (await response.json()) as any; - const model = (body.data || []).find((entry: any) => entry.id === "gpt-5.4__provider_cx"); + const model = (body.data || []).find((entry: any) => entry.id === "gpt-5.6-sol__provider_cx"); assert.equal(response.status, 200); - assert.ok(model, "missing gpt-5.4__provider_cx in tokenized VS Code root route"); - assert.equal(model.name, "Codex GPT 5.4 (Default)"); + assert.ok(model, "missing gpt-5.6-sol__provider_cx in tokenized VS Code root route"); + assert.equal(model.name, "Codex GPT 5.6 Sol (Default)"); }); test("vscode tokenized models route accepts path-scoped API keys", async () => { @@ -151,7 +151,7 @@ test("vscode tokenized combos route exposes configured combos via token alias", await combosDb.createCombo({ name: "test-combo", strategy: "priority", - models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + models: [{ kind: "model", model: "codex/gpt-5.6-sol-high", providerId: "codex" }], }); const combosRoute = @@ -204,7 +204,7 @@ test("vscode combos route exposes combos through Ollama api/tags", async () => { await combosDb.createCombo({ name: "tags-combo", strategy: "priority", - models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + models: [{ kind: "model", model: "codex/gpt-5.6-sol-high", providerId: "codex" }], }); const combosRoute = @@ -236,7 +236,7 @@ test("vscode combos route resolves combo names through Ollama api/show", async ( await combosDb.createCombo({ name: "show-combo", strategy: "priority", - models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + models: [{ kind: "model", model: "codex/gpt-5.6-sol-high", providerId: "codex" }], }); const combosRoute = @@ -255,7 +255,7 @@ test("vscode combos route resolves combo names through Ollama api/show", async ( assert.equal(body.model, "show-combo"); assert.equal(body.modelfile, "FROM show-combo"); assert.equal(body.details.family, "show-combo"); - assert.equal(body.model_info.context_length, 200000); + assert.equal(body.model_info.context_length, 500000); assert.deepEqual(body.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); assert.equal(body.model_info.capabilities.reasoning, true); }); @@ -275,7 +275,7 @@ test("vscode tokenized combos root route exposes importable combo metadata", asy await combosDb.createCombo({ name: "balanced-load", strategy: "reset-aware", - models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + models: [{ kind: "model", model: "codex/gpt-5.6-sol-high", providerId: "codex" }], }); const combosRoute = @@ -290,8 +290,7 @@ test("vscode tokenized combos root route exposes importable combo metadata", asy assert.equal(response.status, 200); assert.ok(combo, "expected balanced-load in combo root response"); assert.equal(combo.url.includes("/responses#models.ai.azure.com"), true); - assert.equal(combo.maxInputTokens, 200000); - assert.equal(combo.maxOutputTokens, 131072); + assert.equal(combo.maxInputTokens, 372000); assert.equal(combo.toolCalling, true); assert.deepEqual(combo.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); }); @@ -354,28 +353,47 @@ test("vscode tokenized models route keeps xhigh for codex models that advertise new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/models`) ); const body = (await response.json()) as any; - const model = (body.data || []).find((entry: any) => entry.id === "gpt-5.4__provider_cx"); + const model = (body.data || []).find((entry: any) => entry.id === "gpt-5.6-sol__provider_cx"); const fastModel = (body.data || []).find( - (entry: any) => entry.id === "gpt-5.4__provider_cx__tier_priority" + (entry: any) => entry.id === "gpt-5.6-sol__provider_cx__tier_priority" ); const flexModel = (body.data || []).find( - (entry: any) => entry.id === "gpt-5.4__provider_cx__tier_flex" + (entry: any) => entry.id === "gpt-5.6-sol__provider_cx__tier_flex" ); assert.equal(response.status, 200); - assert.ok(model, "missing gpt-5.4__provider_cx in tokenized VS Code models route"); + assert.ok(model, "missing gpt-5.6-sol__provider_cx in tokenized VS Code models route"); assert.ok( fastModel, - "missing gpt-5.4__provider_cx__tier_priority in tokenized VS Code models route" + "missing gpt-5.6-sol__provider_cx__tier_priority in tokenized VS Code models route" ); - assert.ok(flexModel, "missing gpt-5.4__provider_cx__tier_flex in tokenized VS Code models route"); - assert.equal(model.name, "Codex GPT 5.4 (Default)"); - assert.equal(fastModel.name, "Codex GPT 5.4 (Fast)"); - assert.equal(flexModel.name, "Codex GPT 5.4 (Flex)"); + assert.ok( + flexModel, + "missing gpt-5.6-sol__provider_cx__tier_flex in tokenized VS Code models route" + ); + assert.equal(model.name, "Codex GPT 5.6 Sol (Default)"); + assert.equal(fastModel.name, "Codex GPT 5.6 Sol (Fast)"); + assert.equal(flexModel.name, "Codex GPT 5.6 Sol (Flex)"); assert.equal(model.toolCalling, true); assert.equal(model.vision, true); - assert.deepEqual(model.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); - assert.deepEqual(model.supportedReasoningEfforts, ["none", "low", "medium", "high", "xhigh"]); + assert.deepEqual(model.supportsReasoningEffort, [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ]); + assert.deepEqual(model.supportedReasoningEfforts, [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ]); assert.equal(model.defaultReasoningEffort, "none"); assert.deepEqual(model.configSchema?.properties?.reasoningEffort?.enum, [ "none", @@ -383,135 +401,34 @@ test("vscode tokenized models route keeps xhigh for codex models that advertise "medium", "high", "xhigh", + "max", + "ultra", ]); assert.equal(model.configSchema?.properties?.reasoningEffort?.default, "none"); const importedIds = new Set((body.data || []).map((entry: any) => entry.id)); - assert.ok(!importedIds.has("cx/gpt-5.4")); - assert.ok(!importedIds.has("cx/gpt-5.4__tier_priority")); - assert.ok(!importedIds.has("cx/gpt-5.4__tier_flex")); - assert.ok(!importedIds.has("codex/gpt-5.4")); - assert.ok(!importedIds.has("cx/gpt-5.4-low")); - assert.ok(!importedIds.has("cx/gpt-5.4-medium")); - assert.ok(!importedIds.has("cx/gpt-5.4-high")); - assert.ok(!importedIds.has("cx/gpt-5.4-xhigh")); - assert.ok(!importedIds.has("cx/gpt-5.4-low__tier_priority")); - assert.ok(!importedIds.has("cx/gpt-5.4-medium__tier_priority")); - assert.ok(!importedIds.has("cx/gpt-5.4-xhigh__tier_flex")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol__tier_priority")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol__tier_flex")); + assert.ok(!importedIds.has("codex/gpt-5.6-sol")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol-low")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol-medium")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol-high")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol-xhigh")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol-max")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol-ultra")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol-low__tier_priority")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol-medium__tier_priority")); + assert.ok(!importedIds.has("cx/gpt-5.6-sol-ultra__tier_flex")); + assert.equal( + [...importedIds].some((id) => String(id).includes("gpt-5.4__provider_cx")), + false + ); assert.equal( model.url, `http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/responses#models.ai.azure.com` ); }); -test("vscode tokenized raw models route exposes provider-native ids without family-first grouping", async () => { - await settingsDb.updateSettings({ - requireLogin: true, - password: "hashed-password", - requireAuthForModels: true, - }); - await seedConnection("codex", { name: "codex-vscode-raw-models" }); - const key = await apiKeysDb.createApiKey( - "vscode-raw-models-codex", - "machine-vscode-raw-models-codex" - ); - - const response = await vscodeRawModelsRoute.GET( - new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/models`) - ); - const body = (await response.json()) as any; - const importedIds = new Set((body.data || []).map((entry: any) => entry.id)); - const defaultModel = (body.data || []).find((entry: any) => entry.id === "cx/gpt-5.4"); - const fastModel = (body.data || []).find( - (entry: any) => entry.id === "cx/gpt-5.4__tier_priority" - ); - const flexModel = (body.data || []).find((entry: any) => entry.id === "cx/gpt-5.4__tier_flex"); - - assert.equal(response.status, 200); - assert.ok(defaultModel, "missing cx/gpt-5.4 in raw VS Code models route"); - assert.ok(fastModel, "missing cx/gpt-5.4__tier_priority in raw VS Code models route"); - assert.ok(flexModel, "missing cx/gpt-5.4__tier_flex in raw VS Code models route"); - assert.equal( - importedIds.size, - (body.data || []).length, - "raw VS Code models route should not duplicate model ids" - ); - assert.ok(!importedIds.has("gpt-5.4__provider_cx")); - assert.ok(!importedIds.has("gpt-5.4__provider_cx__tier_priority")); - assert.ok(!importedIds.has("gpt-5.4__provider_cx__tier_flex")); - assert.equal(defaultModel.object, "model"); - assert.equal(typeof defaultModel.created, "number"); - assert.equal(defaultModel.owned_by, "codex"); - assert.equal(defaultModel.name, "Codex GPT 5.4"); - assert.equal(typeof defaultModel.context_length, "number"); - assert.equal(typeof defaultModel.max_output_tokens, "number"); - assert.equal(typeof defaultModel.max_input_tokens, "number"); - assert.deepEqual(defaultModel.capabilities, { - vision: true, - tool_calling: true, - reasoning: true, - thinking: true, - supportsThinking: true, - effort_tiers: ["none", "low", "medium", "high", "xhigh"], - }); - assert.equal(defaultModel.url, undefined); - assert.equal(defaultModel.toolCalling, undefined); - assert.equal(defaultModel.vision, undefined); - assert.equal(defaultModel.family, undefined); - assert.equal(defaultModel.supportsReasoningEffort, undefined); - assert.equal(defaultModel.supportedReasoningEfforts, undefined); - assert.equal(defaultModel.defaultReasoningEffort, undefined); - assert.equal(defaultModel.configurationSchema, undefined); - assert.equal(defaultModel.configSchema, undefined); - assert.equal(defaultModel.maxInputTokens, undefined); - - const lowModel = (body.data || []).find((entry: any) => entry.id === "cx/gpt-5.4-low"); - const mediumModel = (body.data || []).find((entry: any) => entry.id === "cx/gpt-5.4-medium"); - const highModel = (body.data || []).find((entry: any) => entry.id === "cx/gpt-5.4-high"); - const lowFastModel = (body.data || []).find( - (entry: any) => entry.id === "cx/gpt-5.4-low__tier_priority" - ); - const mediumFastModel = (body.data || []).find( - (entry: any) => entry.id === "cx/gpt-5.4-medium__tier_priority" - ); - const highFastModel = (body.data || []).find( - (entry: any) => entry.id === "cx/gpt-5.4-high__tier_priority" - ); - - assert.ok(lowModel, "missing cx/gpt-5.4-low in raw VS Code models route"); - assert.ok(mediumModel, "missing cx/gpt-5.4-medium in raw VS Code models route"); - assert.ok(highModel, "missing cx/gpt-5.4-high in raw VS Code models route"); - assert.ok(lowFastModel, "missing cx/gpt-5.4-low__tier_priority in raw VS Code models route"); - assert.ok( - mediumFastModel, - "missing cx/gpt-5.4-medium__tier_priority in raw VS Code models route" - ); - assert.ok(highFastModel, "missing cx/gpt-5.4-high__tier_priority in raw VS Code models route"); - assert.equal(lowModel.name, "Codex GPT 5.4 (Low)"); - assert.equal(lowFastModel.name, "Codex GPT 5.4 (Low) (Fast)"); - assert.equal(mediumFastModel.name, "Codex GPT 5.4 (Medium) (Fast)"); - assert.equal(highFastModel.name, "Codex GPT 5.4 (High) (Fast)"); - assert.equal(defaultModel.url, undefined); - assert.equal(defaultModel.toolCalling, undefined); - assert.equal(defaultModel.vision, undefined); - assert.equal(defaultModel.family, undefined); - assert.equal(defaultModel.supportsReasoningEffort, undefined); - assert.equal(defaultModel.supportedReasoningEfforts, undefined); - assert.equal(defaultModel.defaultReasoningEffort, undefined); - assert.equal(defaultModel.configurationSchema, undefined); - assert.equal(defaultModel.configSchema, undefined); - assert.equal(defaultModel.maxInputTokens, undefined); - assert.equal(typeof defaultModel.max_output_tokens, "number"); - assert.equal(typeof defaultModel.max_input_tokens, "number"); - assert.deepEqual(defaultModel.capabilities, { - vision: true, - tool_calling: true, - reasoning: true, - thinking: true, - supportsThinking: true, - effort_tiers: ["none", "low", "medium", "high", "xhigh"], - }); -}); - test("vscode tokenized raw root route mirrors the raw models catalog", async () => { await settingsDb.updateSettings({ requireLogin: true, @@ -532,8 +449,8 @@ test("vscode tokenized raw root route mirrors the raw models catalog", async () assert.equal(response.status, 200); assert.ok(Array.isArray(body.data)); - assert.ok(body.data.some((entry: any) => entry.id === "cx/gpt-5.4")); - assert.ok(body.data.some((entry: any) => entry.id === "cx/gpt-5.4-low__tier_priority")); + assert.ok(body.data.some((entry: any) => entry.id === "cx/gpt-5.6-sol")); + assert.ok(body.data.some((entry: any) => entry.id === "cx/gpt-5.6-sol-low__tier_priority")); }); test("vscode tokenized raw routes do not publish combo entries", async () => { @@ -583,7 +500,7 @@ test("vscode tokenized raw tags route does not publish combo entries", async () await combosDb.createCombo({ name: "raw-tags-hidden-combo", strategy: "priority", - models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + models: [{ kind: "model", model: "codex/gpt-5.6-sol-high", providerId: "codex" }], }); const response = await vscodeRawTagsRoute.GET( @@ -616,15 +533,15 @@ test("vscode tokenized raw show route resolves reasoning and service-tier varian new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/api/show`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "cx/gpt-5.4-low__tier_priority" }), + body: JSON.stringify({ name: "cx/gpt-5.6-sol-low__tier_priority" }), }), { params: { token: key.key } } ); const body = (await response.json()) as any; assert.equal(response.status, 200); - assert.equal(body.model, "cx/gpt-5.4-low__tier_priority"); - assert.equal(body.remote_model, "Codex GPT 5.4 (Low) (Fast)"); + assert.equal(body.model, "cx/gpt-5.6-sol-low__tier_priority"); + assert.equal(body.remote_model, "Codex GPT 5.6 Sol (Low) (Fast)"); assert.equal(body.selectedReasoningEffort, "low"); assert.equal(body.selected_reasoning_effort, "low"); assert.equal(body.details.selectedReasoningEffort, "low"); @@ -645,7 +562,7 @@ test("vscode tokenized raw api/show does not resolve combo names", async () => { await combosDb.createCombo({ name: "raw-show-hidden-combo", strategy: "priority", - models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + models: [{ kind: "model", model: "codex/gpt-5.6-sol-high", providerId: "codex" }], }); const response = await vscodeRawShowRoute.POST( @@ -729,23 +646,49 @@ test("vscode tokenized tags route exposes reasoning metadata for codex models", new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/tags`) ); const body = (await response.json()) as any; - const model = (body.models || []).find((entry: any) => entry.name === "gpt-5.4__provider_cx"); + const model = (body.models || []).find((entry: any) => entry.name === "gpt-5.6-sol__provider_cx"); assert.equal(response.status, 200); - assert.ok(model, "missing gpt-5.4__provider_cx in tokenized VS Code tags route"); - assert.deepEqual(model.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); - assert.deepEqual(model.supports_reasoning_effort, ["none", "low", "medium", "high", "xhigh"]); - assert.deepEqual(model.supportedReasoningEfforts, ["none", "low", "medium", "high", "xhigh"]); + assert.ok(model, "missing gpt-5.6-sol__provider_cx in tokenized VS Code tags route"); + assert.deepEqual(model.supportsReasoningEffort, [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ]); + assert.deepEqual(model.supports_reasoning_effort, [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ]); + assert.deepEqual(model.supportedReasoningEfforts, [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ]); assert.equal(model.defaultReasoningEffort, "none"); assert.equal(model.selectedReasoningEffort, "none"); assert.equal(model.selected_reasoning_effort, "none"); - assert.equal(model.details.family, "gpt-5.4"); + assert.equal(model.details.family, "gpt-5.6-sol"); assert.deepEqual(model.configurationSchema?.properties?.reasoningEffort?.enum, [ "none", "low", "medium", "high", "xhigh", + "max", + "ultra", ]); assert.equal(model.configurationSchema?.properties?.reasoningEffort?.default, "none"); assert.deepEqual(model.details.configurationSchema?.properties?.reasoningEffort?.enum, [ @@ -754,6 +697,8 @@ test("vscode tokenized tags route exposes reasoning metadata for codex models", "medium", "high", "xhigh", + "max", + "ultra", ]); assert.deepEqual(model.details.supports_reasoning_effort, [ "none", @@ -761,21 +706,25 @@ test("vscode tokenized tags route exposes reasoning metadata for codex models", "medium", "high", "xhigh", + "max", + "ultra", ]); assert.equal(model.details.selected_reasoning_effort, "none"); assert.ok( - !(body.models || []).some((entry: any) => entry.name === "cx/gpt-5.4-low"), + !(body.models || []).some((entry: any) => entry.name === "cx/gpt-5.6-sol-low"), "reasoning variant leaked into grouped VS Code tags route" ); assert.ok( - !(body.models || []).some((entry: any) => entry.name === "cx/gpt-5.4-low__tier_priority"), + !(body.models || []).some((entry: any) => entry.name === "cx/gpt-5.6-sol-low__tier_priority"), "tier reasoning variant leaked into grouped VS Code tags route" ); assert.ok( - (body.models || []).some((entry: any) => entry.name === "gpt-5.4__provider_cx__tier_priority") + (body.models || []).some( + (entry: any) => entry.name === "gpt-5.6-sol__provider_cx__tier_priority" + ) ); assert.ok( - (body.models || []).some((entry: any) => entry.name === "gpt-5.4__provider_cx__tier_flex") + (body.models || []).some((entry: any) => entry.name === "gpt-5.6-sol__provider_cx__tier_flex") ); }); @@ -870,7 +819,7 @@ test("vscode tokenized grouped tags route does not publish combo entries", async await combosDb.createCombo({ name: "grouped-hidden-combo", strategy: "priority", - models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + models: [{ kind: "model", model: "codex/gpt-5.6-sol-high", providerId: "codex" }], }); const response = await vscodeTagsRoute.GET( @@ -1016,7 +965,7 @@ test("vscode tokenized grouped api/show does not resolve combo names", async () await combosDb.createCombo({ name: "grouped-show-hidden-combo", strategy: "priority", - models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + models: [{ kind: "model", model: "codex/gpt-5.6-sol-high", providerId: "codex" }], }); const response = await vscodeShowRoute.POST( @@ -1081,18 +1030,42 @@ test("vscode tokenized api/show route exposes explicit reasoning effort metadata new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "gpt-5.4__provider_cx" }), + body: JSON.stringify({ name: "gpt-5.6-sol__provider_cx" }), }) ); const body = (await response.json()) as any; assert.equal(response.status, 200); - assert.equal(body.model, "gpt-5.4__provider_cx"); - assert.equal(body.remote_model, "Codex GPT 5.4 (Default)"); - assert.equal(body.details.family, "gpt-5.4"); - assert.deepEqual(body.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); - assert.deepEqual(body.supports_reasoning_effort, ["none", "low", "medium", "high", "xhigh"]); - assert.deepEqual(body.supportedReasoningEfforts, ["none", "low", "medium", "high", "xhigh"]); + assert.equal(body.model, "gpt-5.6-sol__provider_cx"); + assert.equal(body.remote_model, "Codex GPT 5.6 Sol (Default)"); + assert.equal(body.details.family, "gpt-5.6-sol"); + assert.deepEqual(body.supportsReasoningEffort, [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ]); + assert.deepEqual(body.supports_reasoning_effort, [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ]); + assert.deepEqual(body.supportedReasoningEfforts, [ + "none", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", + ]); assert.equal(body.defaultReasoningEffort, "none"); assert.equal(body.selectedReasoningEffort, "none"); assert.equal(body.selected_reasoning_effort, "none"); @@ -1102,17 +1075,21 @@ test("vscode tokenized api/show route exposes explicit reasoning effort metadata "medium", "high", "xhigh", + "max", + "ultra", ]); assert.equal(body.configurationSchema?.properties?.reasoningEffort?.default, "none"); - assert.equal(body.model_info["general.basename"], "Codex GPT 5.4 (Default)"); + assert.equal(body.model_info["general.basename"], "Codex GPT 5.6 Sol (Default)"); assert.equal(body.model_info["general.architecture"], "codex"); - assert.equal(body.model_info["codex.context_length"], 200000); + assert.equal(body.model_info["codex.context_length"], 500000); assert.deepEqual(body.model_info.supports_reasoning_effort, [ "none", "low", "medium", "high", "xhigh", + "max", + "ultra", ]); assert.equal(body.model_info.selected_reasoning_effort, "none"); assert.deepEqual(body.model_info.capabilities.supports_reasoning_effort, [ @@ -1121,6 +1098,8 @@ test("vscode tokenized api/show route exposes explicit reasoning effort metadata "medium", "high", "xhigh", + "max", + "ultra", ]); }); @@ -1140,23 +1119,23 @@ test("vscode tokenized api/show route exposes service tier variants with suffixe new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "gpt-5.4__provider_cx__tier_priority" }), + body: JSON.stringify({ name: "gpt-5.6-sol__provider_cx__tier_priority" }), }) ); const body = (await response.json()) as any; assert.equal(response.status, 200); - assert.equal(body.model, "gpt-5.4__provider_cx__tier_priority"); - assert.equal(body.remote_model, "Codex GPT 5.4 (Fast)"); - assert.equal(body.details.family, "gpt-5.4"); + assert.equal(body.model, "gpt-5.6-sol__provider_cx__tier_priority"); + assert.equal(body.remote_model, "Codex GPT 5.6 Sol (Fast)"); + assert.equal(body.details.family, "gpt-5.6-sol"); }); test("vscode tokenized chat routes rewrite family-first ids back to the codex provider id", async () => { const payload = serviceTierVariants.resolveVscodeServiceTierRequest({ - model: "gpt-5.4__provider_cx__tier_priority", + model: "gpt-5.6-sol__provider_cx__tier_priority", }); - assert.equal(payload.model, "cx/gpt-5.4"); + assert.equal(payload.model, "cx/gpt-5.6-sol"); assert.equal(payload.service_tier, "priority"); }); @@ -1176,7 +1155,7 @@ test("vscode tokenized /chat/completions route applies the path token and codex method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - model: "gpt-5.4__provider_cx__tier_priority", + model: "gpt-5.6-sol__provider_cx__tier_priority", messages: [{ role: "user", content: "hi" }], max_tokens: 1, stream: false, @@ -1209,7 +1188,7 @@ test("vscode tokenized /responses route applies the path token and codex tier re method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - model: "gpt-5.4__provider_cx__tier_priority", + model: "gpt-5.6-sol__provider_cx__tier_priority", input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], max_output_tokens: 1, stream: false, @@ -1240,7 +1219,7 @@ test("vscode tokenized api/show route preserves the selected reasoning effort fo new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "cx/gpt-5.4-low" }), + body: JSON.stringify({ name: "cx/gpt-5.6-sol-low" }), }) ); const body = (await response.json()) as any; @@ -1266,14 +1245,14 @@ test("vscode tokenized api/show route resolves canonical family aliases", async new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "gpt-5.4" }), + body: JSON.stringify({ name: "gpt-5.6-sol" }), }) ); const body = (await response.json()) as any; assert.equal(response.status, 200); - assert.equal(body.model, "gpt-5.4"); - assert.equal(body.details.family, "gpt-5.4"); + assert.equal(body.model, "gpt-5.6-sol"); + assert.equal(body.details.family, "gpt-5.6-sol"); }); test("vscode tokenized v1 chat route is exposed under the tokenized base path", async () => {