From fb23cfc8d7a538502587d8e147c2899c3b77512b Mon Sep 17 00:00:00 2001 From: Ravi Tharuma Date: Thu, 20 Aug 2026 10:48:37 +0200 Subject: [PATCH 01/17] docs(docker): clarify latest tracks published stable SemVer `:latest` follows the highest published SemVer, not git main. GitOps should pin X.Y.Z. Release checklist requires promoting latest with the SemVer digest. Fixes #10317 --- README.md | 2 ++ .../10317-latest-tracks-highest-stable.md | 1 + docs/getting-started/QUICK-START.md | 2 ++ docs/guides/DOCKER_GUIDE.md | 15 ++++++++++++--- docs/ops/RELEASE_CHECKLIST.md | 9 +++++++++ 5 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 changelog.d/maintenance/10317-latest-tracks-highest-stable.md diff --git a/README.md b/README.md index 43341e9895e6..fc66a61a7159 100644 --- a/README.md +++ b/README.md @@ -988,6 +988,8 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \ -p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest ``` +`:latest` follows the highest **published** stable SemVer. It does not track git `main`. Pin `:X.Y.Z` for GitOps. See [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels). + > **Pre-release Docker channel:** `diegosouzapw/omniroute:next` and > `diegosouzapw/omniroute:next-web` follow the current default `release/v*` > branch. These mutable tags are intended only for testing unreleased fixes and diff --git a/changelog.d/maintenance/10317-latest-tracks-highest-stable.md b/changelog.d/maintenance/10317-latest-tracks-highest-stable.md new file mode 100644 index 000000000000..9fdb4d2c81c7 --- /dev/null +++ b/changelog.d/maintenance/10317-latest-tracks-highest-stable.md @@ -0,0 +1 @@ +- **docs(docker):** spell out that `:latest` tracks the highest **published** stable SemVer (not git `main`), and that GitOps should pin `X.Y.Z` ([#10317](https://github.com/diegosouzapw/OmniRoute/issues/10317)) diff --git a/docs/getting-started/QUICK-START.md b/docs/getting-started/QUICK-START.md index d33693443669..bed9fcb71e35 100644 --- a/docs/getting-started/QUICK-START.md +++ b/docs/getting-started/QUICK-START.md @@ -26,6 +26,8 @@ npm install -g omniroute docker run -d --name omniroute -p 20128:20128 diegosouzapw/omniroute:latest ``` +`:latest` is the highest **published** stable SemVer. It does **not** track git `main`. Pin `diegosouzapw/omniroute:X.Y.Z` for GitOps. See [Image Tags / Release Channels](../guides/DOCKER_GUIDE.md#release-channels). + ### Option C: From Source ```bash diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 464aa5531931..1ae16d5ebacd 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -409,8 +409,8 @@ Endpoint tunnel panels (Cloudflare, Tailscale, ngrok) can be shown or hidden fro | Image | Tag | Size | Description | | ------------------------ | -------- | ------ | --------------------- | -| `diegosouzapw/omniroute` | `latest` | ~250MB | Latest stable release | -| `diegosouzapw/omniroute` | `3.8.0` | ~250MB | Current version | +| `diegosouzapw/omniroute` | `latest` | ~250MB | Highest **published** stable SemVer (not git `main`) | +| `diegosouzapw/omniroute` | `3.8.0` | ~250MB | Pin this class of tag for GitOps | Multi-platform manifest: `linux/amd64` + `linux/arm64` native (Apple Silicon, AWS Graviton, Raspberry Pi). Docker selects the matching architecture automatically; pass `--platform linux/amd64` if you need to force AMD64 emulation on ARM hosts. @@ -421,7 +421,7 @@ OmniRoute publishes separate Docker channels for stable releases, active release | Channel | Source | Mutability | Recommended use | | ------------------------------- | ----------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- | | `:` / `:-web` | Signed/versioned release | Immutable | Production deployments that pin an exact release | -| `:latest` / `:latest-web` | Highest stable release | Mutable stable pointer | Production deployments that intentionally follow stable releases | +| `:latest` / `:latest-web` | Highest **published** stable SemVer | Mutable stable pointer | Follows stable releases **after** a SemVer publish job — does **not** track `main` or unreleased `release/v*` commits | | `:next` / `:next-web` | Current default `release/v*` branch | Mutable pre-release pointer | Testing fixes that have landed on the active release branch but are not yet in a stable release | | `:main` / `:main-web` | `main` branch | Mutable development pointer | Development and integration testing only | @@ -465,6 +465,15 @@ docker compose up -d A release-branch build can never move `latest`; only an eligible stable semantic version may promote the stable pointer. The `next` images retain the release image inspection and blocking CRITICAL-vulnerability gate. +**`latest` is not a currency guarantee for git.** Merged fixes on `main` or on the active `release/v*` branch are **not** in `:latest` until a stable SemVer image is published and the publish job promotes `:latest` (same digest as that SemVer). If `latest` looks frozen while GitHub already shows the fix, pull `:next` to test the release branch or wait for the SemVer tag. + +| You want | Use | +| --- | --- | +| GitOps / production that must not drift | Pin `:X.Y.Z` (or the image digest) | +| Follow published stables and accept a recreate on each release | `:latest` | +| Test unreleased `release/v*` commits | `:next` (not production) | +| Test `main` | `:main` (not production) | + ## Important Notes - **SQLite WAL Mode:** `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`. The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40`. diff --git a/docs/ops/RELEASE_CHECKLIST.md b/docs/ops/RELEASE_CHECKLIST.md index b800d6fc22ae..310bd5cf84a3 100644 --- a/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/ops/RELEASE_CHECKLIST.md @@ -66,6 +66,15 @@ as the default reflex (minutes, reversible); `npm unpublish` only inside the 72h window and never as the first move. Docker: never rewrite a version tag — rollback is repointing `latest` to the last good digest. +**Docker Hub `latest` (required on every stable SemVer publish):** the +`docker-publish` workflow must tag **both** `X.Y.Z` and, when +`should-promote-latest.sh` agrees this is the highest stable SemVer, `:latest` +with the **same digest**. After the job: Hub `latest` digest equals the new +SemVer digest and `last_updated` moved. Do not leave `:latest` on an older +build while release notes talk about fixes that only exist on git. Compose +quickstarts use `:latest`; GitOps should keep pinning `X.Y.Z`. See +[Docker release channels](../guides/DOCKER_GUIDE.md#release-channels) and #10317. + ## Hotfix Fast-Lane (label `hotfix`) A PR labeled `hotfix` skips the heavy CI matrix (9-shard E2E, coverage ratchet, From a352c23bad8f5ce3a0148b9016ce1ba649d94902 Mon Sep 17 00:00:00 2001 From: backryun Date: Thu, 20 Aug 2026 23:31:48 +0900 Subject: [PATCH 02/17] fix(chatgpt-web): refresh current model catalog (#10637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — locally validated (123/123 focused chatgpt-web tests, typecheck:core clean, file-size/changelog gates green). Thanks! --- open-sse/config/freeModelCatalog.data.ts | 4 +- .../providers/registry/chatgpt-web/index.ts | 85 ++- open-sse/config/providers/shared.ts | 6 + open-sse/executors/chatgpt-web.ts | 133 +---- open-sse/executors/chatgpt-web/models.ts | 138 ++--- src/lib/providers/modelListingCapability.ts | 2 +- src/sse/services/model.ts | 31 +- tests/unit/chatgpt-web-citations.test.ts | 20 +- tests/unit/chatgpt-web-handoff-resume.test.ts | 8 +- .../chatgpt-web-max-thinking-effort.test.ts | 165 +----- tests/unit/chatgpt-web-models-split.test.ts | 8 +- tests/unit/chatgpt-web-tools-5240.test.ts | 19 +- tests/unit/chatgpt-web-tools-7679.test.ts | 6 +- tests/unit/chatgpt-web.test.ts | 511 +++++------------- ...-model-catalog-reconciliation-8926.test.ts | 42 ++ .../model-listing-capability-5420.test.ts | 2 + 16 files changed, 420 insertions(+), 760 deletions(-) diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 3d1777ed653b..fe30a37325dd 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -16,9 +16,11 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts"; * rewrites file timestamps on every deploy, which would report a months-old * catalog as "updated today". Bump this whenever the entries below change. */ -export const FREE_CATALOG_CURATED_AT = "2026-08-16"; +export const FREE_CATALOG_CURATED_AT = "2026-08-18"; export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ + { provider: "chatgpt-web", modelId: "gpt-5.6-luna-free", displayName: "GPT-5.6 Luna (Free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" }, + { provider: "chatgpt-web", modelId: "gpt-5.6-luna-free-thinking", displayName: "GPT-5.6 Luna (Free, Think)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" }, { provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agentrouter", modelId: "claude-opus-5", displayName: "Claude Opus 5", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, { provider: "agentrouter", modelId: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" }, diff --git a/open-sse/config/providers/registry/chatgpt-web/index.ts b/open-sse/config/providers/registry/chatgpt-web/index.ts index bb57c7f31c26..16c1522e2b68 100644 --- a/open-sse/config/providers/registry/chatgpt-web/index.ts +++ b/open-sse/config/providers/registry/chatgpt-web/index.ts @@ -9,12 +9,83 @@ export const chatgpt_webProvider: RegistryEntry = { authType: "apikey", authHeader: "cookie", models: [ - { id: "gpt-5.6-pro", name: "GPT-5.6 Pro", toolCalling: false }, // pro tier only, standard effort - { id: "gpt-5.6-thinking", name: "GPT-5.6 Thinking", toolCalling: false }, // plus, pro tier - { id: "gpt-5.5-pro-extended", name: "GPT-5.5 Pro Extended", toolCalling: false }, // pro tier only, extended effort - { id: "gpt-5.5-pro", name: "GPT-5.5 Pro", toolCalling: false }, // pro tier only, standard effort - { id: "gpt-5.5-thinking", name: "GPT-5.5 Thinking", toolCalling: false }, // plus, pro tier - { id: "gpt-5.5", name: "GPT-5.5 Instant", toolCalling: false }, // free, plus, pro tier - { id: "o3", name: "o3", toolCalling: false }, // plus ~ tier + { + id: "gpt-5.6-sol-pro", + name: "GPT-5.6 Sol (Pro)", + liveCatalogIds: ["gpt-5-6-pro"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-xhigh", + name: "GPT-5.6 Sol (Xhigh)", + liveCatalogIds: ["gpt-5-6-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-high", + name: "GPT-5.6 Sol (High)", + liveCatalogIds: ["gpt-5-6-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-medium", + name: "GPT-5.6 Sol (Medium)", + liveCatalogIds: ["gpt-5-6-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.6-sol-instant", + name: "GPT-5.6 Sol (Instant)", + liveCatalogIds: ["gpt-5-6"], + toolCalling: false, + }, + { + id: "gpt-5.6-luna-free-thinking", + name: "GPT-5.6 Luna (Free, Think)", + liveCatalogIds: ["gpt-5-6"], + toolCalling: false, + }, + { + id: "gpt-5.6-luna-free", + name: "GPT-5.6 Luna (Free)", + liveCatalogIds: ["gpt-5-6"], + toolCalling: false, + }, + { + id: "gpt-5.5-pro-extended", + name: "GPT-5.5 (Pro Extended)", + liveCatalogIds: ["gpt-5-5-pro"], + toolCalling: false, + }, + { + id: "gpt-5.5-pro", + name: "GPT-5.5 (Pro)", + liveCatalogIds: ["gpt-5-5-pro"], + toolCalling: false, + }, + { + id: "gpt-5.5-xhigh", + name: "GPT-5.5 (Xhigh)", + liveCatalogIds: ["gpt-5-5-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.5-high", + name: "GPT-5.5 (High)", + liveCatalogIds: ["gpt-5-5-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.5-medium", + name: "GPT-5.5 (Medium)", + liveCatalogIds: ["gpt-5-5-thinking"], + toolCalling: false, + }, + { + id: "gpt-5.5-instant", + name: "GPT-5.5 (Instant)", + liveCatalogIds: ["gpt-5-5"], + toolCalling: false, + }, ], }; diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 88fff0fef732..94e39b29a084 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -46,6 +46,12 @@ export interface RegistryModel { id: string; name: string; aliases?: readonly string[]; + /** + * Upstream model IDs that prove this static model is live when the provider + * has an authoritative synchronized catalog. Needed for curated IDs whose + * public name differs from the ID sent to the upstream service. + */ + liveCatalogIds?: readonly string[]; toolCalling?: boolean; supportsReasoning?: boolean; supportedThinkingEfforts?: readonly string[]; diff --git a/open-sse/executors/chatgpt-web.ts b/open-sse/executors/chatgpt-web.ts index 0168d07de2eb..438565b45cf1 100644 --- a/open-sse/executors/chatgpt-web.ts +++ b/open-sse/executors/chatgpt-web.ts @@ -32,7 +32,11 @@ import { __resetChatGptImageCacheForTesting, type ChatGptImageConversationContext, } from "../services/chatgptImageCache.ts"; -import { isThinkingCapableModel, resolveChatGptModel } from "./chatgpt-web/models.ts"; +import { + resolveChatGptModel, + resolveChatGptSystemHints, + type ChatGptThinkingEffort, +} from "./chatgpt-web/models.ts"; import { cleanChatGptText } from "./chatgpt-web/citations.ts"; import { resumeChatGptHandoff, type FinalAssistantAnswer } from "./chatgpt-web/handoff.ts"; @@ -43,7 +47,6 @@ const SESSION_URL = `${CHATGPT_BASE}/api/auth/session`; const SENTINEL_PREPARE_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements/prepare`; const SENTINEL_CR_URL = `${CHATGPT_BASE}/backend-api/sentinel/chat-requirements`; const CONV_URL = `${CHATGPT_BASE}/backend-api/f/conversation`; -const USER_LAST_USED_MODEL_CONFIG_URL = `${CHATGPT_BASE}/backend-api/settings/user_last_used_model_config`; const DEFAULT_PRO_POLL_TIMEOUT_MS = 20 * 60_000; const DEFAULT_PRO_POLL_INTERVAL_MS = 4_000; @@ -81,10 +84,8 @@ function deviceIdFor(cookie: string): string { return id; } -// 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. +// OmniRoute model IDs select a GPT-5.6 Sol performance lane. Captured browser +// requests use one of `gpt-5-6`, `gpt-5-6-thinking`, or `gpt-5-6-pro`. // ─── Browser-like default headers ────────────────────────────────────────── @@ -408,25 +409,6 @@ async function runSessionWarmup( } } -// ─── Thinking-effort preference (PATCH user_last_used_model_config) ──────── -// chatgpt.com has two thinking levels for its dedicated thinking-models: -// • standard — default, faster -// • extended — longer reasoning budget -// The browser sets the level by PATCHing `/backend-api/settings/user_last_used_model_config` -// once, then issues the conversation request — the conversation endpoint itself -// has no `thinking_effort` field; the server reads the user's stored preference -// at routing time. We mirror that handshake when an OpenAI-style request -// includes `reasoning_effort` (or a direct `providerSpecificData.thinkingEffort` -// override). -// -// Cached per (cookie, slug, effort): the preference persists server-side, so -// re-PATCHing the same combination is wasted bytes. Refreshed on TTL expiry or -// whenever the caller switches efforts. - -const thinkingEffortCache = new Map(); -const THINKING_EFFORT_TTL_MS = 5 * 60 * 1000; -const THINKING_EFFORT_CACHE_MAX = 400; - function configuredProPollTimeoutMs(): number { const raw = Number(process.env.OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS); if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_PRO_POLL_TIMEOUT_MS; @@ -439,73 +421,6 @@ function configuredProPollIntervalMs(): number { return Math.floor(raw); } -async function setUserThinkingEffort( - modelSlug: string, - effort: "standard" | "extended" | "max", - accessToken: string, - accountId: string | null, - sessionId: string, - deviceId: string, - cookie: string, - signal: AbortSignal | null | undefined, - log: - | { - debug?: (tag: string, msg: string) => void; - warn?: (tag: string, msg: string) => void; - } - | null - | undefined -): Promise { - const cacheKey = `${cookieKey(cookie)}:${modelSlug}:${effort}`; - const now = Date.now(); - const last = thinkingEffortCache.get(cacheKey); - if (last && now - last < THINKING_EFFORT_TTL_MS) { - log?.debug?.("CGPT-WEB", `thinking_effort cached (${modelSlug}=${effort}) — skip PATCH`); - return; - } - if (thinkingEffortCache.size >= THINKING_EFFORT_CACHE_MAX && !thinkingEffortCache.has(cacheKey)) { - const first = thinkingEffortCache.keys().next().value; - if (first) thinkingEffortCache.delete(first); - } - - const url = - `${USER_LAST_USED_MODEL_CONFIG_URL}` + - `?model_slug=${encodeURIComponent(modelSlug)}` + - `&thinking_effort=${encodeURIComponent(effort)}`; - const headers: Record = { - ...browserHeaders(), - ...oaiHeaders(sessionId, deviceId), - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - Cookie: buildSessionCookieHeader(cookie), - Priority: "u=4", - }; - if (accountId) headers["chatgpt-account-id"] = accountId; - - try { - const r = await tlsFetchChatGpt(url, { - method: "PATCH", - headers, - timeoutMs: 15_000, - signal, - }); - if (r.status >= 400) { - log?.warn?.( - "CGPT-WEB", - `thinking_effort PATCH ${r.status} for ${modelSlug}=${effort} (continuing)` - ); - return; - } - thinkingEffortCache.set(cacheKey, now); - log?.debug?.("CGPT-WEB", `thinking_effort PATCH OK (${modelSlug}=${effort})`); - } catch (err) { - log?.warn?.( - "CGPT-WEB", - `thinking_effort PATCH failed: ${err instanceof Error ? err.message : String(err)}` - ); - } -} - async function prepareChatRequirements( accessToken: string, accountId: string | null, @@ -889,6 +804,7 @@ interface ChatGptMessage { id: string; author: { role: string }; content: { content_type: "text"; parts: string[] }; + metadata?: Record; } /** @@ -984,7 +900,8 @@ function buildConversationBody( // chatgpt.com history. Disable Temporary Chat only when ChatGPT needs a // durable image conversation (image generation/editing). persistConversation: boolean; - thinkingEffort: "standard" | "extended" | "max" | null; + thinkingEffort: ChatGptThinkingEffort | null; + systemHints: readonly string[]; continuation?: ChatGptImageConversationContext | null; } ): Record { @@ -1021,6 +938,8 @@ function buildConversationBody( }); } + const systemHints = options.systemHints; + const currentUserContent = hasOpenWebUIImageContext(parsed) ? "Briefly acknowledge the image result described in the system context. Do not generate, edit, or request another image." : parsed.currentMsg || ""; @@ -1029,6 +948,7 @@ function buildConversationBody( id: randomUUID(), author: { role: "user" }, content: { content_type: "text", parts: [currentUserContent] }, + ...(systemHints.length > 0 ? { metadata: { system_hints: [...systemHints] } } : {}), }); return { @@ -1051,6 +971,7 @@ function buildConversationBody( supports_buffering: true, force_parallel_switch: "auto", paragen_cot_summary_display_override: "allow", + ...(systemHints.length > 0 ? { system_hints: [...systemHints] } : {}), ...(options.thinkingEffort ? { thinking_effort: options.thinkingEffort } : {}), }; } @@ -2920,24 +2841,6 @@ export class ChatGptWebExecutor extends BaseExecutor { log ); - // 2a''. Apply thinking-effort preference for thinking models. - // Dedicated thinking models mirror the browser's user-config PATCH; - // GPT-5.5 Pro effort is sent with the conversation body. - const requestedEffort = resolvedModel.effort; - if (requestedEffort && isThinkingCapableModel(model, modelSlug)) { - await setUserThinkingEffort( - modelSlug, - requestedEffort, - tokenEntry.accessToken, - tokenEntry.accountId, - sessionId, - deviceId, - cookie, - signal, - log - ); - } - // 2b. Sentinel chat-requirements let reqs: ChatRequirements; try { @@ -3019,7 +2922,7 @@ export class ChatGptWebExecutor extends BaseExecutor { } // Toggle Temporary Chat off only when ChatGPT needs a durable image - // conversation. Text requests, including GPT-5.5 Pro, stay temporary so + // conversation. Text requests, including GPT-5.6 Sol Pro, stay temporary so // they do not show up in the user's chatgpt.com sidebar/history. const imageEdit = looksLikeImageEditRequest(parsed); const continuation = imageEdit ? parsed.latestImageContext : null; @@ -3033,13 +2936,14 @@ export class ChatGptWebExecutor extends BaseExecutor { : "Image-gen intent detected — disabling Temporary Chat for this turn" ); } else if (resolvedModel.isPro) { - log?.debug?.("CGPT-WEB", "GPT-5.5 Pro text request — keeping Temporary Chat enabled"); + log?.debug?.("CGPT-WEB", "GPT-5.6 Sol Pro text request — keeping Temporary Chat enabled"); } const parentMessageId = continuation?.parentMessageId ?? randomUUID(); const cgptBody = buildConversationBody(parsed, modelSlug, parentMessageId, { persistConversation, - thinkingEffort: requestedEffort, + thinkingEffort: resolvedModel.effort, + systemHints: resolveChatGptSystemHints(model), continuation, }); @@ -3230,7 +3134,6 @@ function stringToStream(text: string): ReadableStream { export function __resetChatGptWebCachesForTesting(): void { tokenCache.clear(); warmupCache.clear(); - thinkingEffortCache.clear(); deviceIdCache.clear(); __resetChatGptImageCacheForTesting(); dplCache = null; diff --git a/open-sse/executors/chatgpt-web/models.ts b/open-sse/executors/chatgpt-web/models.ts index 87abd04880dc..1917437baaa7 100644 --- a/open-sse/executors/chatgpt-web/models.ts +++ b/open-sse/executors/chatgpt-web/models.ts @@ -3,106 +3,60 @@ 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": "gpt-5-6", "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-6-pro": "gpt-5-6-pro", "gpt-5-5": "gpt-5-5", - "gpt-5-3": "gpt-5-3", - "gpt-5-3-mini": "gpt-5-3-mini", + "gpt-5-5-thinking": "gpt-5-5-thinking", + "gpt-5-5-pro": "gpt-5-5-pro", + + // Free accounts leave Luna selection to ChatGPT's server-side auto router. + "gpt-5.6-luna-free": "auto", + "gpt-5.6-luna-free-thinking": "auto", - // 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", + // Captured from a real ChatGPT v2 picker conversation. The visible + // performance levels select distinct backend model/effort pairs. + "gpt-5.6-sol-instant": "gpt-5-6", + "gpt-5.6-sol-medium": "gpt-5-6-thinking", + "gpt-5.6-sol-high": "gpt-5-6-thinking", + "gpt-5.6-sol-xhigh": "gpt-5-6-thinking", + "gpt-5.6-sol-pro": "gpt-5-6-pro", + + "gpt-5.5-instant": "gpt-5-5", + "gpt-5.5-medium": "gpt-5-5-thinking", + "gpt-5.5-high": "gpt-5-5-thinking", + "gpt-5.5-xhigh": "gpt-5-5-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", + // Compatibility alias for existing chatgpt-web image integrations. It is + // intentionally absent from the provider's visible curated model list. "gpt-5.5": "gpt-5-5", - "gpt-5.3-instant": "gpt-5-3-instant", - "gpt-5.3": "gpt-5-3", - "gpt-5.3-mini": "gpt-5-3-mini", - o3: "o3", }; export type ChatGptThinkingEffort = "standard" | "extended" | "max"; -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", +export const MODEL_FORCED_EFFORT: Record = { + "gpt-5.6-sol-instant": null, + "gpt-5.6-sol-medium": "standard", + "gpt-5.6-sol-high": "extended", + "gpt-5.6-sol-xhigh": "max", + "gpt-5.6-sol-pro": "standard", + "gpt-5.5-instant": null, + "gpt-5.5-medium": "standard", + "gpt-5.5-high": "extended", + "gpt-5.5-xhigh": "max", "gpt-5.5-pro": "standard", "gpt-5.5-pro-extended": "extended", }; -/** Set of chatgpt.com slugs that the user_last_used_model_config endpoint - * accepts a `thinking_effort` value for, derived from MODEL_MAP so adding a - * new thinking entry there automatically extends this set. - * - * 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. */ -export const THINKING_CAPABLE_SLUGS: ReadonlySet = new Set( - Object.entries(MODEL_MAP) - .filter(([k]) => k.includes("thinking") || k === "o3") - .map(([, v]) => v) -); - -/** chatgpt.com only exposes the thinking-effort toggle on dedicated thinking - * 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). - * - * 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") || - modelId === "o3" || - slug.includes("thinking") || - THINKING_CAPABLE_SLUGS.has(slug) || - THINKING_CAPABLE_SLUGS.has(modelId) - ); -} - -/** Map either a chatgpt.com-native value (`standard`/`extended`/`max`) or the - * OpenAI Chat Completions `reasoning_effort` field to the value the - * `user_last_used_model_config` endpoint expects. - * - * minimal | low | medium | standard → standard - * high | extended → extended - * xhigh | max → max - * - * `xhigh` remains a compatibility alias for the highest ChatGPT Web tier. - * Returns null for absent/unknown inputs. */ -export function normalizeThinkingEffort(input: unknown): ChatGptThinkingEffort | null { - if (typeof input !== "string") return null; - const v = input.trim().toLowerCase(); - if (v === "max" || v === "xhigh") return "max"; - if (v === "extended" || v === "high") return "extended"; - if (v === "standard" || v === "low" || v === "medium" || v === "minimal") { - return "standard"; - } - return null; -} +const MODEL_SYSTEM_HINTS: Record = { + // Captured from the Free-account Think toggle. ChatGPT sends this both at + // the request root and on the user message metadata. + "gpt-5.6-luna-free-thinking": ["reason"], +}; -/** Resolve the requested effort for this turn. - * Order: `providerSpecificData.thinkingEffort` (raw override, takes native - * `standard`/`extended`/`max` values) > `body.reasoning_effort` (top-level - * OpenAI Chat Completions field) > `body.reasoning.effort` (Responses-API - * nesting). Returns null when the caller did not request one. */ -export function resolveThinkingEffort( - body: unknown, - providerSpecificData: Record | undefined -): ChatGptThinkingEffort | null { - if (providerSpecificData && providerSpecificData.thinkingEffort !== undefined) { - return normalizeThinkingEffort(providerSpecificData.thinkingEffort); - } - const b = (body as Record | null) ?? null; - if (!b) return null; - const top = normalizeThinkingEffort(b.reasoning_effort); - if (top) return top; - const nested = (b.reasoning as Record | undefined)?.effort; - return normalizeThinkingEffort(nested); +export function resolveChatGptSystemHints(model: string): string[] { + return [...(MODEL_SYSTEM_HINTS[model] ?? [])]; } export interface ResolvedChatGptModel { @@ -113,12 +67,16 @@ export interface ResolvedChatGptModel { export function resolveChatGptModel( model: string, - body: unknown, - providerSpecificData: Record | undefined + _body?: unknown, + _providerSpecificData?: Record ): ResolvedChatGptModel { const slug = MODEL_MAP[model] ?? model; - const forcedEffort = MODEL_FORCED_EFFORT[model] ?? null; - const effort = forcedEffort ?? resolveThinkingEffort(body, providerSpecificData); - const isPro = slug === "gpt-5-6-pro" || slug === "gpt-5-5-pro"; + const effort = MODEL_FORCED_EFFORT[model] ?? null; + const isPro = + model === "gpt-5.6-sol-pro" || + model === "gpt-5.5-pro" || + model === "gpt-5.5-pro-extended" || + slug === "gpt-5-6-pro" || + slug === "gpt-5-5-pro"; return { slug, effort, isPro }; } diff --git a/src/lib/providers/modelListingCapability.ts b/src/lib/providers/modelListingCapability.ts index 070520dc6cb2..8887a1efc75b 100644 --- a/src/lib/providers/modelListingCapability.ts +++ b/src/lib/providers/modelListingCapability.ts @@ -11,7 +11,7 @@ const TOOL_ONLY_SERVICE_KINDS = new Set(["webSearch", "webFetch"]); /** Providers whose registry catalog is the complete, intentional model list. */ -const CURATED_MODEL_ONLY_PROVIDERS = new Set(["kimi-web", "zai-web"]); +const CURATED_MODEL_ONLY_PROVIDERS = new Set(["chatgpt-web", "kimi-web", "zai-web"]); export function providerUsesCuratedModelsOnly(providerId: string): boolean { return CURATED_MODEL_ONLY_PROVIDERS.has(providerId.trim().toLowerCase()); diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 53e065d8ef19..7a84606cd11f 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -206,6 +206,24 @@ function findSyncedModelMeta(models: unknown, modelId: string): any { return Array.isArray(models) ? models.find((model: any) => model.id === modelId) : undefined; } +function findLiveCatalogModelMeta( + providerId: string, + requestedModelId: string, + resolvedModelId: string, + syncedModels: unknown +): any { + const directMatch = findSyncedModelMeta(syncedModels, resolvedModelId); + if (directMatch || !Array.isArray(syncedModels)) return directMatch; + + const registryModel = findRegistryModel(providerId, requestedModelId); + const liveCatalogIds = registryModel?.liveCatalogIds; + if (!Array.isArray(liveCatalogIds) || liveCatalogIds.length === 0) return undefined; + + return syncedModels.find( + (model: any) => typeof model?.id === "string" && liveCatalogIds.includes(model.id) + ); +} + function resolveRuntimeFormats(customMatch: any, syncedMatch: any): RuntimeModelMeta { const apiFormat = customMatch?.apiFormat === "responses" || syncedMatch?.apiFormat === "responses" @@ -228,8 +246,10 @@ function copySyncedThinkingMetadata(metadata: RuntimeModelMeta, syncedMatch: any // Only let a non-empty synced effort list override the static registry fallback; // an empty array from an incomplete synced discovery must not erase registry-declared // tiers (#9485 review). - if (Array.isArray(syncedMatch?.supportedThinkingEfforts) && - syncedMatch.supportedThinkingEfforts.length > 0) { + if ( + Array.isArray(syncedMatch?.supportedThinkingEfforts) && + syncedMatch.supportedThinkingEfforts.length > 0 + ) { metadata.supportedThinkingEfforts = syncedMatch.supportedThinkingEfforts; } if (typeof syncedMatch?.defaultThinkingEffort === "string") { @@ -300,7 +320,12 @@ async function lookupModelMeta( // Custom models remain explicit operator overrides even when live discovery // is authoritative for the provider. const customMatch = findCustomModelMeta(customModels, resolvedModelId); - const syncedMatch = findSyncedModelMeta(syncedModels, resolvedModelId); + const syncedMatch = findLiveCatalogModelMeta( + providerId, + modelId, + resolvedModelId, + syncedModels + ); const registryMatch = findRegistryModel(providerId, resolvedModelId); const effortBaseModelId = getRegisteredProviderEffortBaseModelId(providerId, modelId); diff --git a/tests/unit/chatgpt-web-citations.test.ts b/tests/unit/chatgpt-web-citations.test.ts index 9141d2755c29..736e61381486 100644 --- a/tests/unit/chatgpt-web-citations.test.ts +++ b/tests/unit/chatgpt-web-citations.test.ts @@ -10,12 +10,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import( - "../../open-sse/executors/chatgpt-web.ts" -); -const { __setTlsFetchOverrideForTesting } = await import( - "../../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"); // ─── Minimal TLS-fetch mock ────────────────────────────────────────────────── // Tailored to the citation flow: root/DPL, session→accessToken, sentinel→token @@ -81,7 +79,7 @@ function installMockFetch({ if (u.includes("/sentinel/chat-requirements")) { return json({ token: "req-token", proofofwork: { required: false } }); } - // /backend-api/conversation/ — detail poll used by GPT-5.5 Pro handoff. + // /backend-api/conversation/ — detail poll used by GPT-5.6 Sol Pro handoff. if (conversationDetail) { const m1 = u.match(/\/backend-api\/conversation\/([^/?#]+)$/); if (m1) { @@ -171,7 +169,7 @@ test("Non-streaming: resolves ChatGPT web citation markers into markdown links", try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "latest Tesla FSD in Australia" }] }, stream: false, credentials: { apiKey: "test" }, @@ -260,7 +258,7 @@ test("Streaming: buffers split ChatGPT citation markers until metadata can link try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "latest Tesla FSD in Australia" }], stream: true, @@ -297,7 +295,7 @@ test("Streaming: buffers split ChatGPT citation markers until metadata can link } }); -test("GPT-5.5 Pro non-streaming: stream_handoff polls conversation detail for final answer", async () => { +test("GPT-5.6 Sol Pro non-streaming: stream_handoff polls conversation detail for final answer", async () => { __resetChatGptWebCachesForTesting(); const citationMarker = "citeturn0search0"; const m = installMockFetch({ @@ -369,7 +367,7 @@ test("GPT-5.5 Pro non-streaming: stream_handoff polls conversation detail for fi try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }] }, stream: false, credentials: { apiKey: "cookie-pro-poll" }, diff --git a/tests/unit/chatgpt-web-handoff-resume.test.ts b/tests/unit/chatgpt-web-handoff-resume.test.ts index 29b549c628a3..bb309e995269 100644 --- a/tests/unit/chatgpt-web-handoff-resume.test.ts +++ b/tests/unit/chatgpt-web-handoff-resume.test.ts @@ -164,8 +164,8 @@ function installHandoffMock( }; } -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"]) { +test("ChatGPT Web GPT-5.6 Sol Pro resumes Temporary Chat handoffs through native SSE", async (t) => { + for (const model of ["gpt-5.6-sol-pro"]) { await t.test(model, async () => { __resetChatGptWebCachesForTesting(); const expected = `RESUMED_${model}`; @@ -204,7 +204,7 @@ test("ChatGPT Web handoff retries the next resume offset after a 404", async () try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.6-pro", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }] }, stream: false, credentials: { apiKey: "cookie-offset" }, @@ -231,7 +231,7 @@ test("ChatGPT Web streaming appends the native resumed Pro answer", async () => try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }], stream: true }, stream: true, credentials: { apiKey: "cookie-stream" }, diff --git a/tests/unit/chatgpt-web-max-thinking-effort.test.ts b/tests/unit/chatgpt-web-max-thinking-effort.test.ts index f245c8aad2c6..8d42972b38f9 100644 --- a/tests/unit/chatgpt-web-max-thinking-effort.test.ts +++ b/tests/unit/chatgpt-web-max-thinking-effort.test.ts @@ -2,158 +2,33 @@ import test from "node:test"; import assert from "node:assert/strict"; import { - normalizeThinkingEffort, resolveChatGptModel, + resolveChatGptSystemHints, } from "../../open-sse/executors/chatgpt-web/models.ts"; -import { - ChatGptWebExecutor, - __resetChatGptWebCachesForTesting, -} from "../../open-sse/executors/chatgpt-web.ts"; -import { __setTlsFetchOverrideForTesting } from "../../open-sse/services/chatgptTlsClient.ts"; - -function mockResponse(status: number, body: unknown, contentType = "application/json") { - return { - status, - headers: new Headers({ "Content-Type": contentType }), - text: typeof body === "string" ? body : JSON.stringify(body), - body: null, - }; -} - -function installMockFetch() { - const calls: { - userConfigUrl: string | null; - userConfigMethod: string | null; - conversationBody: string | null; - } = { - userConfigUrl: null, - userConfigMethod: null, - conversationBody: null, - }; - - __setTlsFetchOverrideForTesting(async (url, opts = {}) => { - const u = String(url); - - if (u === "https://chatgpt.com/" || u === "https://chatgpt.com") { - return mockResponse( - 200, - '', - "text/html" - ); - } - - if (u.includes("/api/auth/session")) { - return mockResponse(200, { - accessToken: "jwt-test", - expires: new Date(Date.now() + 3_600_000).toISOString(), - user: { id: "user-test" }, - }); - } - - if (u.includes("/backend-api/settings/user_last_used_model_config")) { - calls.userConfigUrl = u; - calls.userConfigMethod = (opts.method || "GET").toUpperCase(); - return mockResponse(200, { is_disabled: false }); - } - if (u.includes("/backend-api/sentinel/chat-requirements")) { - return mockResponse(200, { - token: "requirements-test", - proofofwork: { required: false }, - }); - } - - if (u.endsWith("/backend-api/f/conversation")) { - calls.conversationBody = opts.body ?? null; - return mockResponse( - 200, - [ - `data: ${JSON.stringify({ - conversation_id: "conv-test", - message: { - id: "msg-test", - author: { role: "assistant" }, - content: { content_type: "text", parts: ["ok"] }, - status: "finished_successfully", - }, - })}`, - "", - "data: [DONE]", - "", - ].join("\r\n"), - "text/event-stream" - ); - } - - // Browser-like warmup endpoints are best-effort. Returning a normal 200 - // keeps this focused test independent from their response details. - return mockResponse(200, {}); - }); - - return { - calls, - restore() { - __setTlsFetchOverrideForTesting(null); - }, - }; -} - -test("ChatGPT Web thinking effort aliases map to the three native tiers", () => { +test("ChatGPT Web performance lanes use their native model and effort pairs", () => { const cases = [ - ["minimal", "standard"], - ["low", "standard"], - ["medium", "standard"], - ["standard", "standard"], - ["high", "extended"], - ["extended", "extended"], - ["xhigh", "max"], - ["max", "max"], + ["gpt-5.6-luna-free", "auto", null, false], + ["gpt-5.6-luna-free-thinking", "auto", null, false], + ["gpt-5.6-sol-instant", "gpt-5-6", null, false], + ["gpt-5.6-sol-medium", "gpt-5-6-thinking", "standard", false], + ["gpt-5.6-sol-high", "gpt-5-6-thinking", "extended", false], + ["gpt-5.6-sol-xhigh", "gpt-5-6-thinking", "max", false], + ["gpt-5.6-sol-pro", "gpt-5-6-pro", "standard", true], + ["gpt-5.5-instant", "gpt-5-5", null, false], + ["gpt-5.5-medium", "gpt-5-5-thinking", "standard", false], + ["gpt-5.5-high", "gpt-5-5-thinking", "extended", false], + ["gpt-5.5-xhigh", "gpt-5-5-thinking", "max", false], + ["gpt-5.5-pro", "gpt-5-5-pro", "standard", true], + ["gpt-5.5-pro-extended", "gpt-5-5-pro", "extended", true], ] as const; - for (const [input, expected] of cases) { - assert.equal(normalizeThinkingEffort(input), expected, input); + for (const [model, slug, effort, isPro] of cases) { + assert.deepEqual(resolveChatGptModel(model), { slug, effort, isPro }, model); } }); -test("providerSpecificData can request native max with highest precedence", () => { - const resolved = resolveChatGptModel( - "gpt-5.6-thinking", - { reasoning_effort: "low" }, - { thinkingEffort: "max" } - ); - assert.equal(resolved.effort, "max"); +test("ChatGPT Web Free Luna Think uses the captured reason system hint", () => { + assert.deepEqual(resolveChatGptSystemHints("gpt-5.6-luna-free"), []); + assert.deepEqual(resolveChatGptSystemHints("gpt-5.6-luna-free-thinking"), ["reason"]); }); - -for (const effort of ["xhigh", "max"] as const) { - test(`ChatGPT Web executor sends ${effort} as thinking_effort=max`, async () => { - __resetChatGptWebCachesForTesting(); - const mock = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - const result = await executor.execute({ - model: "gpt-5.6-thinking", - body: { - messages: [{ role: "user", content: "hi" }], - reasoning_effort: effort, - }, - stream: false, - credentials: { apiKey: `cookie-${effort}` }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - - assert.equal(result.response.status, 200); - assert.equal(mock.calls.userConfigMethod, "PATCH"); - assert.ok(mock.calls.userConfigUrl); - const settingsUrl = new URL(mock.calls.userConfigUrl); - assert.equal(settingsUrl.searchParams.get("model_slug"), "gpt-5-6-thinking"); - assert.equal(settingsUrl.searchParams.get("thinking_effort"), "max"); - - assert.ok(mock.calls.conversationBody); - const conversationBody = JSON.parse(mock.calls.conversationBody) as Record; - assert.equal(conversationBody.thinking_effort, "max"); - } finally { - mock.restore(); - } - }); -} diff --git a/tests/unit/chatgpt-web-models-split.test.ts b/tests/unit/chatgpt-web-models-split.test.ts index f25f78556db7..ef9ff79a778d 100644 --- a/tests/unit/chatgpt-web-models-split.test.ts +++ b/tests/unit/chatgpt-web-models-split.test.ts @@ -5,16 +5,16 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; // Split-guard for the chatgpt-web model-mapping extraction. -// The static model maps + pure thinking-effort resolvers live in the pure leaf -// chatgpt-web/models.ts (no module state). Host imports the two it uses back. +// The static model maps + pure model resolver live in the pure leaf +// chatgpt-web/models.ts (no module state). Host imports it back. const HERE = dirname(fileURLToPath(import.meta.url)); const EXE = join(HERE, "../../open-sse/executors"); const HOST = join(EXE, "chatgpt-web.ts"); const LEAF = join(EXE, "chatgpt-web/models.ts"); -test("leaf hosts the model maps + resolvers and does not import the host", () => { +test("leaf hosts the model maps + resolver and does not import the host", () => { const src = readFileSync(LEAF, "utf8"); - for (const sym of ["MODEL_MAP", "resolveChatGptModel", "resolveThinkingEffort"]) { + for (const sym of ["MODEL_MAP", "MODEL_FORCED_EFFORT", "resolveChatGptModel"]) { assert.match(src, new RegExp(`export (const|function) ${sym}\\b`)); } assert.doesNotMatch(src, /from "\.\.\/chatgpt-web\.ts"/); diff --git a/tests/unit/chatgpt-web-tools-5240.test.ts b/tests/unit/chatgpt-web-tools-5240.test.ts index 5fc9ec06c39e..ac6bc9046afa 100644 --- a/tests/unit/chatgpt-web-tools-5240.test.ts +++ b/tests/unit/chatgpt-web-tools-5240.test.ts @@ -8,15 +8,13 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { ChatGptWebExecutor, __resetChatGptWebCachesForTesting } = await import( - "../../open-sse/executors/chatgpt-web.ts" -); -const { __setTlsFetchOverrideForTesting } = await import( - "../../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"); // ─── Minimal TLS-fetch mock ────────────────────────────────────────────────── -// Tailored to the tool-call flow (gpt-5.3-instant, non-thinking): root/DPL, +// Tailored to the tool-call flow (gpt-5.5, non-thinking): root/DPL, // session→accessToken, sentinel→token (no PoW), conv→SSE. Warmup GETs fall // through to 404, which the executor tolerates. @@ -68,7 +66,10 @@ function installMockFetch(convEvents: unknown[]) { body: null, }); - if ((u === "https://chatgpt.com/" || u === "https://chatgpt.com") && (opts.method || "GET") === "GET") { + if ( + (u === "https://chatgpt.com/" || u === "https://chatgpt.com") && + (opts.method || "GET") === "GET" + ) { return { status: 200, headers: makeHeaders({ "Content-Type": "text/html" }), @@ -127,7 +128,7 @@ const TOOL_CALL_TEXT = '{"name":"get_weather","arguments":{"location":"Tok function baseOpts(extra: Record) { return { - model: "gpt-5.3-instant", + model: "gpt-5.5", credentials: { apiKey: "test" }, signal: AbortSignal.timeout(10_000), log: null, diff --git a/tests/unit/chatgpt-web-tools-7679.test.ts b/tests/unit/chatgpt-web-tools-7679.test.ts index f042f9ab7d9b..0ce60ba21fe9 100644 --- a/tests/unit/chatgpt-web-tools-7679.test.ts +++ b/tests/unit/chatgpt-web-tools-7679.test.ts @@ -1,6 +1,6 @@ -// Tool contract serialization for chatgpt-web thinking models (#7679). +// Tool contract serialization for ChatGPT Web performance models (#7679). // -// GPT-5.6 Thinking via chatgpt-web ignores the injected `` pseudo-contract +// GPT-5.6 Sol via chatgpt-web ignores the injected `` pseudo-contract // and replies in prose claiming tools are unavailable. This test covers the // nonce-bound serialization that clearly describes client-side tools and places // the full contract at the tail of the effective message list. @@ -161,7 +161,7 @@ test("parseToolCallsFromText returns null when hardened text has no tool blocks }); test("parseToolCallsFromText handles blocks line-boundary crossing in hardened text (#7679)", () => { - // Some thinking models may emit the tool block adjacent to explanatory text + // Some high-performance lanes may emit the tool block adjacent to explanatory text // with no preceding newline const text = [ 'I will use the weather tool. {"name":"get_weather","arguments":{"location":"Paris"}}', diff --git a/tests/unit/chatgpt-web.test.ts b/tests/unit/chatgpt-web.test.ts index cc97ecd69d96..267c0eb6dc31 100644 --- a/tests/unit/chatgpt-web.test.ts +++ b/tests/unit/chatgpt-web.test.ts @@ -81,13 +81,11 @@ type MockFetchOptions = { attachmentDownload?: MockTlsConfig; conversationDetail?: MockTlsConfig | MockTlsConfig[]; signedDownload?: MockTlsConfig; - userConfig?: MockTlsConfig; onSession?: (opts: TlsFetchOptions) => void; onSentinel?: (opts: TlsFetchOptions) => void; onConv?: (opts: TlsFetchOptions) => void; onFileDownload?: (opts: TlsFetchOptions, fileId: string) => void; onAttachmentDownload?: (opts: TlsFetchOptions, fileId: string) => void; - onUserConfig?: (opts: TlsFetchOptions, url: string) => void; }; type MockFetchCalls = { @@ -99,9 +97,6 @@ type MockFetchCalls = { attachmentDownload: number; conversationDetail: number; signedDownload: number; - userConfig: number; - userConfigUrls: string[]; - userConfigMethods: string[]; urls: string[]; headers: Array | undefined>; bodies: Array; @@ -118,13 +113,11 @@ function installMockFetch({ attachmentDownload, conversationDetail, signedDownload, - userConfig, onSession, onSentinel, onConv, onFileDownload, onAttachmentDownload, - onUserConfig, }: MockFetchOptions = {}) { const calls: MockFetchCalls = { session: 0, @@ -135,9 +128,6 @@ function installMockFetch({ attachmentDownload: 0, conversationDetail: 0, signedDownload: 0, - userConfig: 0, - userConfigUrls: [], - userConfigMethods: [], urls: [], headers: [], bodies: [], @@ -188,22 +178,6 @@ function installMockFetch({ }; } - // /backend-api/settings/user_last_used_model_config?model_slug=...&thinking_effort=... - // Match before sentinel since /settings/* is its own surface. - if (u.includes("/backend-api/settings/user_last_used_model_config")) { - calls.userConfig++; - calls.userConfigUrls.push(u); - calls.userConfigMethods.push((opts.method || "GET").toUpperCase()); - if (onUserConfig) onUserConfig(opts, u); - const cfg = userConfig ?? { status: 200, body: { is_disabled: false } }; - return { - status: cfg.status, - headers: makeHeaders({ "Content-Type": "application/json" }), - text: typeof cfg.body === "string" ? cfg.body : JSON.stringify(cfg.body || {}), - body: null, - }; - } - if (u.includes("/sentinel/chat-requirements")) { calls.sentinel++; if (onSentinel) onSentinel(opts); @@ -288,7 +262,7 @@ function installMockFetch({ }; } - // /backend-api/conversation/ — detail poll used by GPT-5.5 Pro handoff. + // /backend-api/conversation/ — detail poll used by GPT-5.6 Sol Pro handoff. { const m1 = u.match(/\/backend-api\/conversation\/([^/?#]+)$/); if (m1) { @@ -480,7 +454,7 @@ test("Token exchange: cookie sent to /api/auth/session, accessToken used as Bear try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "my-cookie-value" }, @@ -518,7 +492,7 @@ test("Token cache: two calls within TTL only hit /api/auth/session once", async try { const executor = new ChatGptWebExecutor(); const opts = { - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-v1" }, @@ -552,7 +526,7 @@ test("Refreshed cookie: surfaced via onCredentialsRefreshed callback", async () let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "old-cookie" }, @@ -585,7 +559,7 @@ test("Sentinel: chat-requirements is hit before /backend-api/conversation", asyn try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -606,7 +580,7 @@ test("Sentinel: chat-requirements token forwarded on conv request", async () => try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -635,7 +609,7 @@ test("PoW: when required, proof token is sent with valid prefix", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -670,7 +644,7 @@ test("Turnstile: required flag does NOT block — conv endpoint accepts requests try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -692,7 +666,7 @@ test("Non-streaming: returns OpenAI chat.completion JSON", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -743,7 +717,7 @@ test("Streaming: produces valid SSE chunks ending with [DONE]", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }], stream: true }, stream: true, credentials: { apiKey: "test" }, @@ -807,7 +781,7 @@ test("Streaming: cumulative parts are diffed into non-overlapping deltas", async try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }], stream: true }, stream: true, credentials: { apiKey: "test" }, @@ -835,7 +809,7 @@ test("Streaming: cumulative parts are diffed into non-overlapping deltas", async } }); -test("GPT-5.5 Pro streaming: preserves interim reasoning and appends final polled answer", async () => { +test("GPT-5.6 Sol Pro streaming: preserves interim reasoning and appends final polled answer", async () => { reset(); const m = installMockFetch({ conv: { @@ -878,7 +852,7 @@ test("GPT-5.5 Pro streaming: preserves interim reasoning and appends final polle try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-pro", body: { messages: [{ role: "user", content: "hard problem" }], stream: true }, stream: true, credentials: { apiKey: "cookie-pro-stream" }, @@ -907,7 +881,7 @@ test("Error: 401 on /api/auth/session returns 401 with re-paste hint", async () try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "expired-cookie" }, @@ -928,7 +902,7 @@ test("Error: 200 with no accessToken returns 401", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "stale-cookie" }, @@ -948,7 +922,7 @@ test("Error: 403 from sentinel returns 403 SENTINEL_BLOCKED", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -970,7 +944,7 @@ test("Error: 429 from conversation returns 429 with rate-limit message", async ( try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -991,7 +965,7 @@ test("Error: empty messages returns 400 without any fetch", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [] }, stream: false, credentials: { apiKey: "test" }, @@ -1011,7 +985,7 @@ test("Error: missing apiKey returns 401 without any fetch", async () => { try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: {}, @@ -1033,7 +1007,7 @@ test("Cookie: bare value gets prepended with cookie name", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "rawValue" }, @@ -1052,7 +1026,7 @@ test("Cookie: unchunked cookie line is passed through verbatim", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "__Secure-next-auth.session-token=actualvalue" }, @@ -1071,7 +1045,7 @@ test("Cookie: chunked .0/.1 cookies are passed through verbatim (NextAuth reasse try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1096,7 +1070,7 @@ test("Cookie: 'Cookie: ' DevTools prefix is stripped", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1127,7 +1101,7 @@ test("Session continuity: each call starts a fresh conversation (Temporary Chat try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "First question" }] }, stream: false, credentials: { apiKey: "test" }, @@ -1135,7 +1109,7 @@ test("Session continuity: each call starts a fresh conversation (Temporary Chat log: null, }); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "user", content: "First question" }, @@ -1178,7 +1152,7 @@ test("Request: conversation POST has correct browser-like headers", async () => try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -1204,7 +1178,7 @@ test("Request: payload has correct ChatGPT shape", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "system", content: "Be concise" }, @@ -1219,7 +1193,7 @@ test("Request: payload has correct ChatGPT shape", async () => { const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); assert.equal(body.action, "next"); - assert.equal(body.model, "gpt-5-3-instant"); + assert.equal(body.model, "gpt-5-5"); // Plain text request → Temporary Chat stays ON. We disable it only for // image-gen prompts (see "Image gen: image-intent prompts" tests below). assert.equal(body.history_and_training_disabled, true); @@ -1245,26 +1219,23 @@ 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); - // Retired GPT-5.4 and older entries stay out of the advertised catalog. + // Free accounts expose Luna with an optional Think toggle; paid accounts + // expose five GPT-5.6 Sol performance lanes plus GPT-5.5. assert.deepEqual(ids, [ - "gpt-5.6-pro", - "gpt-5.6-thinking", + "gpt-5.6-sol-pro", + "gpt-5.6-sol-xhigh", + "gpt-5.6-sol-high", + "gpt-5.6-sol-medium", + "gpt-5.6-sol-instant", + "gpt-5.6-luna-free-thinking", + "gpt-5.6-luna-free", "gpt-5.5-pro-extended", "gpt-5.5-pro", - "gpt-5.5-thinking", - "gpt-5.5", - "o3", + "gpt-5.5-xhigh", + "gpt-5.5-high", + "gpt-5.5-medium", + "gpt-5.5-instant", ]); - 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 () => { @@ -1273,19 +1244,22 @@ test("Executor MODEL_MAP: OmniRoute IDs translate to ChatGPT backend slugs", asy try { const cases: Array<[string, string]> = [ // Public catalog ids. - ["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.5", "gpt-5-5"], + ["gpt-5.6-luna-free", "auto"], + ["gpt-5.6-luna-free-thinking", "auto"], + ["gpt-5.6-sol-instant", "gpt-5-6"], + ["gpt-5.6-sol-medium", "gpt-5-6-thinking"], + ["gpt-5.6-sol-high", "gpt-5-6-thinking"], + ["gpt-5.6-sol-xhigh", "gpt-5-6-thinking"], + ["gpt-5.6-sol-pro", "gpt-5-6-pro"], + ["gpt-5.5-instant", "gpt-5-5"], + ["gpt-5.5-medium", "gpt-5-5-thinking"], + ["gpt-5.5-high", "gpt-5-5-thinking"], + ["gpt-5.5-xhigh", "gpt-5-5-thinking"], ["gpt-5.5-pro", "gpt-5-5-pro"], ["gpt-5.5-pro-extended", "gpt-5-5-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-6-pro", "gpt-5-6-pro"], - ["gpt-5-5-pro", "gpt-5-5-pro"], - ["gpt-5-5-pro-extended", "gpt-5-5-pro"], + ["gpt-5-6", "gpt-5-6"], + ["gpt-5-5", "gpt-5-5"], ]; for (const [omniId, expectedSlug] of cases) { m.calls.urls.length = 0; @@ -1308,18 +1282,58 @@ test("Executor MODEL_MAP: OmniRoute IDs translate to ChatGPT backend slugs", asy } }); +test("GPT-5.6 Luna Free Think sends the captured auto-router reason hints", async () => { + reset(); + const m = installMockFetch(); + try { + const executor = new ChatGptWebExecutor(); + for (const [model, expectedHints] of [ + ["gpt-5.6-luna-free", undefined], + ["gpt-5.6-luna-free-thinking", ["reason"]], + ] as const) { + m.calls.urls.length = 0; + m.calls.bodies.length = 0; + await executor.execute({ + model, + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "cookie-free-luna" }, + signal: AbortSignal.timeout(10_000), + log: null, + }); + const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); + const body = JSON.parse(m.calls.bodies[convIdx]); + const userMessage = body.messages.find( + (message: { author?: { role?: string } }) => message.author?.role === "user" + ); + + assert.equal(body.model, "auto"); + assert.deepEqual(body.system_hints, expectedHints); + assert.deepEqual(userMessage?.metadata?.system_hints, expectedHints); + } + } finally { + m.restore(); + } +}); + test("MODEL_MAP drift guard: every advertised catalog id reaches ChatGPT as a backend slug", async () => { reset(); const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); const ids = (getRegistryEntry("chatgpt-web")?.models || []).map((m) => m.id); const expectedSlugById: Record = { - "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.6-luna-free": "auto", + "gpt-5.6-luna-free-thinking": "auto", + "gpt-5.6-sol-instant": "gpt-5-6", + "gpt-5.6-sol-medium": "gpt-5-6-thinking", + "gpt-5.6-sol-high": "gpt-5-6-thinking", + "gpt-5.6-sol-xhigh": "gpt-5-6-thinking", + "gpt-5.6-sol-pro": "gpt-5-6-pro", + "gpt-5.5-instant": "gpt-5-5", + "gpt-5.5-medium": "gpt-5-5-thinking", + "gpt-5.5-high": "gpt-5-5-thinking", + "gpt-5.5-xhigh": "gpt-5-5-thinking", "gpt-5.5-pro": "gpt-5-5-pro", - "gpt-5.5-thinking": "gpt-5-5-thinking", - "gpt-5.5": "gpt-5-5", - o3: "o3", + "gpt-5.5-pro-extended": "gpt-5-5-pro", }; const m = installMockFetch(); try { @@ -1348,15 +1362,15 @@ test("MODEL_MAP drift guard: every advertised catalog id reaches ChatGPT as a ba } }); -// ─── thinking_effort PATCH user_last_used_model_config ───────────────────── +// ─── GPT-5.6 Sol picker request contract ────────────────────────────────── -test("GPT-5.5 Pro Extended sends base slug with extended effort and Temporary Chat", async () => { +test("GPT-5.6 Sol XHigh sends the captured thinking-model/max pair", async () => { reset(); const m = installMockFetch(); try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-xhigh", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-pro-extended" }, @@ -1366,26 +1380,25 @@ test("GPT-5.5 Pro Extended sends base slug with extended effort and Temporary Ch assert.equal(result.response.status, 200); const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); - assert.equal(body.model, "gpt-5-5-pro"); - assert.equal(body.thinking_effort, "extended"); + assert.equal(body.model, "gpt-5-6-thinking"); + assert.equal(body.thinking_effort, "max"); assert.equal(body.history_and_training_disabled, true); - assert.equal( - m.calls.userConfig, - 0, - "Pro effort is sent with the turn, not PATCHed as a thinking-model preference" + assert.ok( + !m.calls.urls.some((url) => url.includes("/settings/user_last_used_model_config")), + "the captured browser request uses no settings PATCH" ); } finally { m.restore(); } }); -test("GPT-5.5 Pro standard sends standard effort", async () => { +test("GPT-5.6 Sol High sends the captured thinking-model/extended pair", async () => { reset(); const m = installMockFetch(); try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.5-pro", + model: "gpt-5.6-sol-high", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-pro-standard" }, @@ -1394,21 +1407,21 @@ test("GPT-5.5 Pro standard sends standard effort", async () => { }); const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); - assert.equal(body.model, "gpt-5-5-pro"); - assert.equal(body.thinking_effort, "standard"); + assert.equal(body.model, "gpt-5-6-thinking"); + assert.equal(body.thinking_effort, "extended"); assert.equal(body.history_and_training_disabled, true); } finally { m.restore(); } }); -test("GPT-5.5 Pro store:false keeps Temporary Chat enabled for background utility calls", async () => { +test("GPT-5.6 Sol XHigh store:false keeps Temporary Chat enabled", async () => { reset(); const m = installMockFetch(); try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.5-pro-extended", + model: "gpt-5.6-sol-xhigh", body: { store: false, messages: [ @@ -1424,8 +1437,8 @@ test("GPT-5.5 Pro store:false keeps Temporary Chat enabled for background utilit assert.equal(result.response.status, 200); const convIdx = m.calls.urls.findIndex((u) => u.endsWith("/backend-api/f/conversation")); const body = JSON.parse(m.calls.bodies[convIdx]); - assert.equal(body.model, "gpt-5-5-pro"); - assert.equal(body.thinking_effort, "extended"); + assert.equal(body.model, "gpt-5-6-thinking"); + assert.equal(body.thinking_effort, "max"); assert.equal(body.history_and_training_disabled, true); assert.equal( m.calls.conversationDetail, @@ -1437,242 +1450,6 @@ test("GPT-5.5 Pro store:false keeps Temporary Chat enabled for background utilit } }); -test("thinking_effort: high → PATCH user_last_used_model_config with extended", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.5-thinking", - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - stream: false, - credentials: { apiKey: "cookie-1" }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 1, "exactly one PATCH issued"); - assert.equal(m.calls.userConfigMethods[0], "PATCH"); - const u = m.calls.userConfigUrls[0]; - assert.match(u, /model_slug=gpt-5-5-thinking/); - assert.match(u, /thinking_effort=extended/); - } finally { - m.restore(); - } -}); - -test("thinking_effort: low/medium → PATCH with standard", async () => { - for (const effort of ["low", "medium", "minimal"]) { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.6-thinking", - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: effort }, - stream: false, - credentials: { apiKey: `cookie-${effort}` }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - 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-6-thinking/); - } finally { - m.restore(); - } - } -}); - -test("thinking_effort: instant model never triggers PATCH even with reasoning_effort", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.3-instant", - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - stream: false, - credentials: { apiKey: "cookie-instant" }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 0, "instant slug must not PATCH thinking_effort"); - } finally { - m.restore(); - } -}); - -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 { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: bareSlug, - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - stream: false, - credentials: { apiKey: `cookie-bare-${bareSlug}` }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal( - m.calls.userConfig, - 1, - `bare slug ${bareSlug} must trigger thinking_effort PATCH` - ); - assert.ok( - m.calls.userConfigUrls[0].includes(`model_slug=${bareSlug}`), - `URL should contain model_slug=${bareSlug}` - ); - } finally { - m.restore(); - } - } -}); - -test("thinking_effort: thinking model without reasoning_effort skips PATCH", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.5-thinking", - body: { messages: [{ role: "user", content: "hi" }] }, - stream: false, - credentials: { apiKey: "cookie-noeffort" }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 0, "no effort requested → no PATCH"); - } finally { - m.restore(); - } -}); - -test("thinking_effort: providerSpecificData.thinkingEffort=extended overrides body", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.6-thinking", - body: { - messages: [{ role: "user", content: "hi" }], - reasoning_effort: "low", // would normally map to standard - }, - stream: false, - credentials: { - apiKey: "cookie-override", - providerSpecificData: { thinkingEffort: "extended" }, - }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 1); - assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-6-thinking/); - assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/); - } finally { - m.restore(); - } -}); - -test("thinking_effort: nested body.reasoning.effort=high → extended", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - await executor.execute({ - model: "gpt-5.5-thinking", - body: { - messages: [{ role: "user", content: "hi" }], - reasoning: { effort: "high" }, - }, - stream: false, - credentials: { apiKey: "cookie-nested" }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 1); - assert.match(m.calls.userConfigUrls[0], /model_slug=gpt-5-5-thinking/); - assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/); - } finally { - m.restore(); - } -}); - -test("thinking_effort: cached per (cookie, slug, effort) — second identical call skips PATCH", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - const opts = { - model: "gpt-5.5-thinking", - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - stream: false, - credentials: { apiKey: "cookie-cache" }, - signal: AbortSignal.timeout(10_000), - log: null, - }; - await executor.execute(opts); - await executor.execute(opts); - assert.equal(m.calls.userConfig, 1, "second identical request hits cache"); - } finally { - m.restore(); - } -}); - -test("thinking_effort: switching effort within TTL triggers a fresh PATCH", async () => { - reset(); - const m = installMockFetch(); - try { - const executor = new ChatGptWebExecutor(); - const base = { - model: "gpt-5.5-thinking", - stream: false, - credentials: { apiKey: "cookie-switch" }, - signal: AbortSignal.timeout(10_000), - log: null, - }; - await executor.execute({ - ...base, - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - }); - await executor.execute({ - ...base, - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "low" }, - }); - assert.equal(m.calls.userConfig, 2, "different effort key bypasses cache"); - assert.match(m.calls.userConfigUrls[0], /thinking_effort=extended/); - assert.match(m.calls.userConfigUrls[1], /thinking_effort=standard/); - } finally { - m.restore(); - } -}); - -test("thinking_effort: PATCH failure is non-fatal — conversation request still fires", async () => { - reset(); - const m = installMockFetch({ - userConfig: { status: 500, body: { error: "boom" } }, - }); - try { - const executor = new ChatGptWebExecutor(); - const result = await executor.execute({ - model: "gpt-5.5-thinking", - body: { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, - stream: false, - credentials: { apiKey: "cookie-fail" }, - signal: AbortSignal.timeout(10_000), - log: null, - }); - assert.equal(m.calls.userConfig, 1); - assert.equal(m.calls.conv, 1, "conversation still issued despite settings PATCH 500"); - assert.equal(result.response.status, 200); - } finally { - m.restore(); - } -}); - 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"); @@ -1709,7 +1486,7 @@ test("Cookie rotation: full DevTools blob keeps cf_clearance/__cf_bm/_cfuvid", a let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1764,7 +1541,7 @@ test("Cookie rotation: unchunked → chunked drops stale unchunked variant", asy let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1811,7 +1588,7 @@ test("Cookie rotation: chunked → unchunked drops stale chunks", async () => { let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { @@ -1855,7 +1632,7 @@ test("Cookie rotation: returns null when Set-Cookie has no session-token", async let refreshed = null; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "cookie-v1" }, @@ -1919,7 +1696,7 @@ test("Stream parser: echoed prior assistant turn is suppressed (streaming)", asy try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }], stream: true }, stream: true, credentials: { apiKey: "test" }, @@ -1979,7 +1756,7 @@ test("Stream parser: echoed prior assistant turn is suppressed (non-streaming)", try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2017,7 +1794,7 @@ test("Stream parser: instant single-event reply still surfaces via fallback", as try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2083,7 +1860,7 @@ test("Error: TlsClientUnavailableError returns 502 with TLS_UNAVAILABLE code", a try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2211,7 +1988,7 @@ test("Image gen: file-service:// pointer resolves to download URL and is appende try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "generate an image of a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2245,7 +2022,7 @@ test("Image gen: file-service:// pointer is appended in streaming SSE", async () try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "draw a kitten" }] }, stream: true, credentials: { apiKey: "test" }, @@ -2279,7 +2056,7 @@ test("Image gen: sediment:// pointer prefers /files//download over /attachme try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "make a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2324,7 +2101,7 @@ test("Image gen: failed download URL is dropped silently — no broken markdown" try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2348,7 +2125,7 @@ test("Image gen: image-intent prompt disables Temporary Chat", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "generate an image of a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2369,7 +2146,7 @@ test("Image gen: text-only prompt keeps Temporary Chat ON", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "what is the capital of France?" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2396,7 +2173,7 @@ test("Image gen: Open WebUI follow-up/title/tag tool prompts do NOT trigger imag try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: prompt }] }, stream: false, credentials: { apiKey: "test" }, @@ -2429,7 +2206,7 @@ test("Image gen: Open WebUI image-generation context suppresses duplicate chat i try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "system", content: context }, @@ -2477,7 +2254,7 @@ test("Image gen: heuristic catches common phrasings", async () => { try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: phrase }] }, stream: false, credentials: { apiKey: "test" }, @@ -2598,7 +2375,7 @@ test("Image gen: signed URL bytes are cached and exposed via /v1/chatgpt-web/ima try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "draw kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2648,7 +2425,7 @@ test("Image gen: prior data: image URIs are stripped from history before upstrea const assistantMsg = `Sure, here you go:\n\n![image](data:image/png;base64,${huge})\n`; const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "user", content: "draw a kitten" }, @@ -2686,7 +2463,7 @@ test("Image edit: cached OmniRoute image URL continues the saved ChatGPT convers try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { role: "user", content: "draw a kitten" }, @@ -2726,7 +2503,7 @@ test("Image edit: Open WebUI image context suppresses duplicate edit continuatio try { const executor = new ChatGptWebExecutor(); await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [ { @@ -2798,7 +2575,7 @@ test("Image gen: dedupes the same pointer across in-progress + finished events", try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2829,7 +2606,7 @@ test("Image gen: bytes-fetch failure drops markdown (no signed-URL fallback)", a try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "draw a kitten" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2903,7 +2680,7 @@ test("Image edit: file_0000XXXX (chatgpt-web edit result) falls back to /convers try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "now make it nighttime" }] }, stream: false, credentials: { apiKey: "test" }, @@ -2965,7 +2742,7 @@ test("Image gen: ChatGPT-internal tool_invoked metadata does NOT spuriously trig try { const executor = new ChatGptWebExecutor(); const result = await executor.execute({ - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { messages: [{ role: "user", content: "limitations of gpt-4o-mini?" }] }, stream: true, credentials: { apiKey: "test" }, @@ -3015,7 +2792,7 @@ test("Image edit handler: bytes-hash match drives executor with cached conversat const { handleImageEdit } = await import("../../open-sse/handlers/imageGeneration.ts"); const result = await handleImageEdit({ provider: "chatgpt-web", - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { prompt: "turn it to day time" }, imageBytes: sourceBytes, credentials: { apiKey: "test" }, @@ -3053,7 +2830,7 @@ test("Image edit handler: no cached match returns 400 (does not silently generat const foreignBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0xde, 0xad, 0xbe, 0xef]); const result = await handleImageEdit({ provider: "chatgpt-web", - model: "gpt-5.3-instant", + model: "gpt-5.5", body: { prompt: "turn it to day time" }, imageBytes: foreignBytes, credentials: { apiKey: "test" }, @@ -3082,7 +2859,7 @@ test("Image gen handler: n>4 is rejected before any upstream call", async () => try { const { handleImageGeneration } = await import("../../open-sse/handlers/imageGeneration.ts"); const result = await handleImageGeneration({ - body: { prompt: "draw a kitten", n: 5, model: "cgpt-web/gpt-5.3-instant" }, + body: { prompt: "draw a kitten", n: 5, model: "cgpt-web/gpt-5.5" }, credentials: { apiKey: "test" }, log: null, }); diff --git a/tests/unit/live-model-catalog-reconciliation-8926.test.ts b/tests/unit/live-model-catalog-reconciliation-8926.test.ts index 2796071e43a3..5294c830fc12 100644 --- a/tests/unit/live-model-catalog-reconciliation-8926.test.ts +++ b/tests/unit/live-model-catalog-reconciliation-8926.test.ts @@ -202,3 +202,45 @@ test("#8926: partial passthrough discovery remains non-authoritative", async () ["gpt-5.6-luna"] ); }); + +test("ChatGPT Web curated variants require their mapped upstream live slug", async () => { + const variants = new Map([ + ["gpt-5.6-sol-pro", "gpt-5-6-pro"], + ["gpt-5.6-sol-xhigh", "gpt-5-6-thinking"], + ["gpt-5.6-sol-high", "gpt-5-6-thinking"], + ["gpt-5.6-sol-medium", "gpt-5-6-thinking"], + ["gpt-5.6-sol-instant", "gpt-5-6"], + ["gpt-5.6-luna-free-thinking", "gpt-5-6"], + ["gpt-5.6-luna-free", "gpt-5-6"], + ["gpt-5.5-pro-extended", "gpt-5-5-pro"], + ["gpt-5.5-pro", "gpt-5-5-pro"], + ["gpt-5.5-xhigh", "gpt-5-5-thinking"], + ["gpt-5.5-high", "gpt-5-5-thinking"], + ["gpt-5.5-medium", "gpt-5-5-thinking"], + ["gpt-5.5-instant", "gpt-5-5"], + ]); + + await seedProviderCatalog( + "chatgpt-web", + "chatgpt-web-live-8926", + Array.from(new Set(variants.values())) + ); + + const catalog = await getActiveSyncedCatalog("chatgpt-web"); + assert.equal(catalog.authoritative, true); + + for (const modelId of variants.keys()) { + const resolved = await getModelInfo(`chatgpt-web/${modelId}`); + assert.equal(resolved.provider, "chatgpt-web", modelId); + assert.equal(resolved.model, modelId, modelId); + } + + await seedProviderCatalog("chatgpt-web", "chatgpt-web-live-8926", ["gpt-5-6"]); + + const available = await getModelInfo("chatgpt-web/gpt-5.6-sol-instant"); + assert.equal(available.provider, "chatgpt-web"); + + const unavailable = await getModelInfo("chatgpt-web/gpt-5.6-sol-pro"); + assert.equal(unavailable.provider, null); + assert.equal(unavailable.errorType, "model_not_found"); +}); diff --git a/tests/unit/model-listing-capability-5420.test.ts b/tests/unit/model-listing-capability-5420.test.ts index 0c7a6777e6f3..581a07e8cb1e 100644 --- a/tests/unit/model-listing-capability-5420.test.ts +++ b/tests/unit/model-listing-capability-5420.test.ts @@ -33,8 +33,10 @@ describe("providerLacksModelListing (#5420)", () => { it("keeps curated web providers visible while disabling remote model import", () => { assert.equal(providerLacksModelListing("kimi-web", ["llm"]), false); assert.equal(providerLacksModelListing("zai-web", ["llm"]), false); + assert.equal(providerLacksModelListing("chatgpt-web", ["llm"]), false); assert.equal(providerUsesCuratedModelsOnly("kimi-web"), true); assert.equal(providerUsesCuratedModelsOnly("zai-web"), true); + assert.equal(providerUsesCuratedModelsOnly("chatgpt-web"), true); assert.equal(providerUsesCuratedModelsOnly("qwen-cloud"), false); assert.equal(providerUsesCuratedModelsOnly("kimi-coding"), false); }); From 5e508147c4688c3459962884eb540ec8ae97181e Mon Sep 17 00:00:00 2001 From: backryun Date: Thu, 20 Aug 2026 23:32:08 +0900 Subject: [PATCH 03/17] perf(electron): defer hidden-start renderer creation (#10327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — locally validated (6/6 focused lazy-window tests, plus the wider electron suite, typecheck:core clean, gates green). Thanks! --- electron/lib/windowLifecycle.js | 28 +++++++ electron/main.js | 68 +++++++-------- electron/package.json | 1 + tests/unit/electron-lazy-window.test.ts | 105 ++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 electron/lib/windowLifecycle.js create mode 100644 tests/unit/electron-lazy-window.test.ts diff --git a/electron/lib/windowLifecycle.js b/electron/lib/windowLifecycle.js new file mode 100644 index 000000000000..8a75a4a18dfe --- /dev/null +++ b/electron/lib/windowLifecycle.js @@ -0,0 +1,28 @@ +/** Pure helpers for deciding and driving the Electron dashboard window lifecycle. */ + +function shouldStartHidden({ argv = [], loginItemSettings = {} } = {}) { + return ( + argv.includes("--hidden") || + argv.includes("--minimized") || + loginItemSettings.wasOpenedAsHidden === true + ); +} + +function showOrCreateWindow({ appReady, getWindow, createWindow }) { + if (!appReady) return null; + + const currentWindow = getWindow(); + if (!currentWindow || currentWindow.isDestroyed()) { + return createWindow(); + } + + if (currentWindow.isMinimized()) currentWindow.restore(); + currentWindow.show(); + currentWindow.focus(); + return currentWindow; +} + +module.exports = { + shouldStartHidden, + showOrCreateWindow, +}; diff --git a/electron/main.js b/electron/main.js index 61f9e784d92a..f2e3a4e3d8d0 100644 --- a/electron/main.js +++ b/electron/main.js @@ -40,6 +40,7 @@ const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper"); const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl"); const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences"); const { buildReadinessUrl, waitForServer } = require("./lib/serverReadiness"); +const { shouldStartHidden, showOrCreateWindow } = require("./lib/windowLifecycle"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -49,11 +50,7 @@ if (!gotTheLock) { } app.on("second-instance", () => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); - } + showMainWindow(); }); // ── Environment Detection ────────────────────────────────── @@ -71,6 +68,7 @@ let nextServer = null; let serverPort = 20128; let isServerStopped = false; let remoteServerPromptWindow = null; +let keepAliveWithoutWindows = false; // ── Remote Server Mode ────────────────────────────────────── // Lets the desktop shell attach to an already-running OmniRoute server (e.g. a @@ -366,6 +364,8 @@ function setupContentSecurityPolicy() { // ── Create Window ────────────────────────────────────────── function createWindow() { + if (mainWindow && !mainWindow.isDestroyed()) return mainWindow; + // Platform-conditional options (#9) const platformWindowOptions = process.platform === "darwin" @@ -397,16 +397,10 @@ function createWindow() { mainWindow.webContents.openDevTools({ mode: "detach" }); } - // Show window when ready (unless starting minimized/hidden in tray) + // Hidden startup skips createWindow() entirely; any created dashboard is explicit. mainWindow.once("ready-to-show", () => { - const startHidden = - process.argv.includes("--hidden") || - process.argv.includes("--minimized") || - app.getLoginItemSettings().wasOpenedAsHidden; - if (!startHidden) { + if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); - } else { - console.log("[Electron] Launched hidden in background tray"); } }); @@ -437,6 +431,16 @@ function createWindow() { mainWindow.on("closed", () => { mainWindow = null; }); + + return mainWindow; +} + +function showMainWindow() { + return showOrCreateWindow({ + appReady: app.isReady(), + getWindow: () => mainWindow, + createWindow, + }); } // ── System Tray ──────────────────────────────────────────── @@ -465,12 +469,7 @@ function createTray() { const contextMenu = Menu.buildFromTemplate([ { label: "Open OmniRoute", - click: () => { - if (mainWindow) { - mainWindow.show(); - mainWindow.focus(); - } - }, + click: () => showMainWindow(), }, { label: "Open Dashboard", @@ -523,10 +522,7 @@ function createTray() { tray.setContextMenu(contextMenu); tray.on("double-click", () => { - if (mainWindow) { - mainWindow.show(); - mainWindow.focus(); - } + showMainWindow(); }); } @@ -1094,9 +1090,20 @@ app.whenReady().then(async () => { process.argv.includes("--headless") || process.argv.includes("--cli") || process.env.OMNIROUTE_HEADLESS === "true"; + const startHidden = + !isHeadless && + shouldStartHidden({ + argv: process.argv, + loginItemSettings: app.getLoginItemSettings(), + }); + keepAliveWithoutWindows = startHidden; // Fix #1: Start server and WAIT for readiness before showing window startNextServer(); + if (!isHeadless) { + createTray(); + } + let serverReady = true; if (!isDev) { // Probe the lightweight auth-exempt endpoint instead of aggregating full monitoring state. @@ -1105,9 +1112,10 @@ app.whenReady().then(async () => { if (isHeadless) { console.log("[Electron] Headless mode active — UI window and tray icon skipped"); + } else if (startHidden) { + console.log("[Electron] Launched hidden in background tray without a renderer"); } else { - createWindow(); - createTray(); + showMainWindow(); } setupIpcHandlers(); @@ -1115,7 +1123,7 @@ app.whenReady().then(async () => { // If readiness timed out (e.g. very long first-launch migrations), don't leave the // window stuck on a hanging connection — keep polling and reload once it responds (#2460). - if (!isDev && !serverReady && !isHeadless) { + if (!isDev && !serverReady && !isHeadless && !startHidden) { void waitForServer(getServerReadinessUrl(), 300000).then((ready) => { if (ready && mainWindow && !mainWindow.isDestroyed()) { mainWindow.loadURL(getServerUrl()); @@ -1133,11 +1141,7 @@ app.whenReady().then(async () => { // macOS: recreate window when dock icon clicked app.on("activate", () => { if (isHeadless) return; - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } else if (mainWindow) { - mainWindow.show(); - } + showMainWindow(); }); }); @@ -1147,7 +1151,7 @@ app.on("window-all-closed", () => { process.argv.includes("--headless") || process.argv.includes("--cli") || process.env.OMNIROUTE_HEADLESS === "true"; - if (process.platform !== "darwin" && !isHeadless) { + if (process.platform !== "darwin" && !isHeadless && !keepAliveWithoutWindows) { app.quit(); } }); diff --git a/electron/package.json b/electron/package.json index a3793de8fa40..8a895b3566c8 100644 --- a/electron/package.json +++ b/electron/package.json @@ -64,6 +64,7 @@ "remoteServerPromptRenderer.js", "lib/resolveServerEntry.js", "lib/resolveNodeHelper.js", + "lib/windowLifecycle.js", "lib/resolveRemoteServerUrl.js", "lib/remoteServerPreferences.js", "lib/serverReadiness.js", diff --git a/tests/unit/electron-lazy-window.test.ts b/tests/unit/electron-lazy-window.test.ts new file mode 100644 index 000000000000..d3a4440c8b07 --- /dev/null +++ b/tests/unit/electron-lazy-window.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createRequire } from "node:module"; +import { describe, it } from "node:test"; + +const require = createRequire(import.meta.url); +const { shouldStartHidden, showOrCreateWindow } = require("../../electron/lib/windowLifecycle"); + +describe("Electron hidden-start window lifecycle", () => { + it("detects explicit hidden flags and OS login-item hidden launches", () => { + assert.equal(shouldStartHidden({ argv: ["electron", "--hidden"] }), true); + assert.equal(shouldStartHidden({ argv: ["electron", "--minimized"] }), true); + assert.equal( + shouldStartHidden({ argv: ["electron"], loginItemSettings: { wasOpenedAsHidden: true } }), + true + ); + assert.equal(shouldStartHidden({ argv: ["electron"], loginItemSettings: {} }), false); + }); + + it("creates the dashboard only when an explicit open action has no live window", () => { + const createdWindow = { id: "created" }; + let createCalls = 0; + const result = showOrCreateWindow({ + appReady: true, + getWindow: () => null, + createWindow: () => { + createCalls += 1; + return createdWindow; + }, + }); + + assert.equal(result, createdWindow); + assert.equal(createCalls, 1); + }); + + it("restores, shows, and focuses an existing dashboard without recreating it", () => { + const calls: string[] = []; + const existingWindow = { + isDestroyed: () => false, + isMinimized: () => true, + restore: () => calls.push("restore"), + show: () => calls.push("show"), + focus: () => calls.push("focus"), + }; + + const result = showOrCreateWindow({ + appReady: true, + getWindow: () => existingWindow, + createWindow: () => { + throw new Error("must not recreate a live dashboard"); + }, + }); + + assert.equal(result, existingWindow); + assert.deepEqual(calls, ["restore", "show", "focus"]); + }); + + it("does not create a BrowserWindow before Electron is ready", () => { + let createCalls = 0; + const result = showOrCreateWindow({ + appReady: false, + getWindow: () => null, + createWindow: () => { + createCalls += 1; + }, + }); + + assert.equal(result, null); + assert.equal(createCalls, 0); + }); + + it("routes tray, second-instance, and macOS activation opens through the lazy helper", () => { + const mainSource = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + + assert.match(mainSource, /app\.on\("second-instance", \(\) => \{\s*showMainWindow\(\);/); + assert.match(mainSource, /label: "Open OmniRoute",\s*click: \(\) => showMainWindow\(\)/); + assert.match(mainSource, /tray\.on\("double-click", \(\) => \{\s*showMainWindow\(\);/); + assert.match(mainSource, /app\.on\("activate", \(\) => \{[\s\S]*?showMainWindow\(\);/); + }); + + it("keeps hidden startup renderer-free until an explicit open action", () => { + const mainSource = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + const readyBlock = mainSource.slice(mainSource.indexOf("app.whenReady().then")); + + assert.match( + readyBlock, + /startNextServer\(\);\s*if \(!isHeadless\) \{\s*createTray\(\);\s*\}/, + "the server and tray must start before the hidden/visible renderer decision" + ); + assert.match( + readyBlock, + /if \(isHeadless\)[\s\S]*?else if \(startHidden\)[\s\S]*?else \{\s*showMainWindow\(\);/ + ); + assert.doesNotMatch( + readyBlock.match(/else if \(startHidden\)[\s\S]*?\} else \{/s)?.[0] ?? "", + /createWindow\(|showMainWindow\(/ + ); + assert.match( + readyBlock, + /\}\s*setupIpcHandlers\(\);\s*setupAutoUpdater\(\);/, + "IPC and updater setup must remain active when no renderer was created" + ); + }); +}); From 8122f6b71c02a594fdc0e1321bd4cef924a3fb70 Mon Sep 17 00:00:00 2001 From: backryun Date: Thu, 20 Aug 2026 23:33:45 +0900 Subject: [PATCH 04/17] perf(electron): optionally unload renderer on close (4/8) (#10328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — locally validated (73/73 focused electron tests, typecheck:core clean, gates green) after resolving base-drift against #10327 (both landed today, real interleaved logic in createWindow/showMainWindow/window-all-closed — combined so the hidden-start lazy-open path from #10327 and the unload-on-close path from this PR both stay intact; verified by the existing test 'keeps the non-macOS app alive when unloading its last renderer'). Nice pair of electron perf PRs, thanks! --- electron/lib/remoteServerPreferences.js | 46 ++++++-- electron/lib/windowClosePolicy.js | 26 +++++ electron/main.js | 104 ++++++++++++++---- electron/package.json | 1 + tests/unit/electron-lazy-window.test.ts | 6 +- tests/unit/electron-remote-server.test.ts | 34 +++++- .../unit/electron-window-close-policy.test.ts | 87 +++++++++++++++ 7 files changed, 271 insertions(+), 33 deletions(-) create mode 100644 electron/lib/windowClosePolicy.js create mode 100644 tests/unit/electron-window-close-policy.test.ts diff --git a/electron/lib/remoteServerPreferences.js b/electron/lib/remoteServerPreferences.js index 21b683290f1d..71425e37b94d 100644 --- a/electron/lib/remoteServerPreferences.js +++ b/electron/lib/remoteServerPreferences.js @@ -5,8 +5,8 @@ const path = require("path"); /** * remoteServerPreferences.js — pure read/write helpers for the small JSON - * preferences file that persists the operator-configured remote server URL - * across app restarts (see resolveRemoteServerUrl.js for how it's consumed). + * preferences file that persists desktop-shell choices needed before the + * server-owned settings database is available. * * Deliberately a plain flat JSON file rather than the app's SQLite database: * this preference must be readable before deciding whether to spawn (or even @@ -18,19 +18,20 @@ const path = require("path"); * @param {string} prefsPath - absolute path to electron-preferences.json * @param {(p: string) => boolean} [existsSync] * @param {(p: string, enc: string) => string} [readFileSync] - * @returns {{remoteServerUrl: string|null}} + * @returns {{remoteServerUrl: string|null, closeBehavior: "keep-loaded"|"unload"}} */ function readPreferences(prefsPath, existsSync = fs.existsSync, readFileSync = fs.readFileSync) { - if (!existsSync(prefsPath)) return { remoteServerUrl: null }; + if (!existsSync(prefsPath)) return { remoteServerUrl: null, closeBehavior: "keep-loaded" }; try { const parsed = JSON.parse(readFileSync(prefsPath, "utf8")); const remoteServerUrl = typeof parsed.remoteServerUrl === "string" && parsed.remoteServerUrl.trim() ? parsed.remoteServerUrl.trim() : null; - return { remoteServerUrl }; + const closeBehavior = parsed.closeBehavior === "unload" ? "unload" : "keep-loaded"; + return { remoteServerUrl, closeBehavior }; } catch { - return { remoteServerUrl: null }; + return { remoteServerUrl: null, closeBehavior: "keep-loaded" }; } } @@ -72,4 +73,35 @@ function writeRemoteServerUrl( } } -module.exports = { readPreferences, writeRemoteServerUrl }; +/** Persist whether closing the dashboard hides it or unloads its renderer. */ +function writeCloseBehavior( + prefsPath, + closeBehavior, + { + existsSync = fs.existsSync, + readFileSync = fs.readFileSync, + writeFileSync = fs.writeFileSync, + mkdirSync = fs.mkdirSync, + } = {} +) { + try { + const dir = path.dirname(prefsPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const current = readPreferences(prefsPath, existsSync, readFileSync); + const next = { + ...current, + closeBehavior: closeBehavior === "unload" ? "unload" : "keep-loaded", + }; + writeFileSync(prefsPath, JSON.stringify(next, null, 2) + "\n", "utf8"); + } catch (err) { + console.error( + `[remoteServerPreferences] Failed to write preferences to ${prefsPath}:`, + err instanceof Error ? err.message : String(err) + ); + } +} + +module.exports = { readPreferences, writeRemoteServerUrl, writeCloseBehavior }; diff --git a/electron/lib/windowClosePolicy.js b/electron/lib/windowClosePolicy.js new file mode 100644 index 000000000000..989e380d41d8 --- /dev/null +++ b/electron/lib/windowClosePolicy.js @@ -0,0 +1,26 @@ +"use strict"; + +const CLOSE_BEHAVIOR_KEEP_LOADED = "keep-loaded"; +const CLOSE_BEHAVIOR_UNLOAD = "unload"; + +function normalizeCloseBehavior(value) { + if (value === CLOSE_BEHAVIOR_KEEP_LOADED || value === CLOSE_BEHAVIOR_UNLOAD) return value; + return null; +} + +function resolveRendererUrl(currentUrl, serverUrl) { + try { + const current = new URL(currentUrl); + const server = new URL(serverUrl); + return current.origin === server.origin ? current.href : server.href; + } catch { + return serverUrl; + } +} + +module.exports = { + CLOSE_BEHAVIOR_KEEP_LOADED, + CLOSE_BEHAVIOR_UNLOAD, + normalizeCloseBehavior, + resolveRendererUrl, +}; diff --git a/electron/main.js b/electron/main.js index f2e3a4e3d8d0..19f232226b4a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -38,9 +38,19 @@ const { killProcessTree } = require("./processTree"); const { resolveServerEntry } = require("./lib/resolveServerEntry"); const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper"); const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl"); -const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences"); +const { + readPreferences, + writeRemoteServerUrl, + writeCloseBehavior, +} = require("./lib/remoteServerPreferences"); const { buildReadinessUrl, waitForServer } = require("./lib/serverReadiness"); const { shouldStartHidden, showOrCreateWindow } = require("./lib/windowLifecycle"); +const { + CLOSE_BEHAVIOR_KEEP_LOADED, + CLOSE_BEHAVIOR_UNLOAD, + normalizeCloseBehavior, + resolveRendererUrl, +} = require("./lib/windowClosePolicy"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -50,6 +60,11 @@ if (!gotTheLock) { } app.on("second-instance", () => { + const isHeadless = + process.argv.includes("--headless") || + process.argv.includes("--cli") || + process.env.OMNIROUTE_HEADLESS === "true"; + if (isHeadless) return; showMainWindow(); }); @@ -69,6 +84,7 @@ let serverPort = 20128; let isServerStopped = false; let remoteServerPromptWindow = null; let keepAliveWithoutWindows = false; +let lastRendererUrl = null; // ── Remote Server Mode ────────────────────────────────────── // Lets the desktop shell attach to an already-running OmniRoute server (e.g. a @@ -79,6 +95,8 @@ const REMOTE_SERVER_PREFS_PATH = path.join( resolveDataDir(null, process.env), "electron-preferences.json" ); +const electronPreferences = readPreferences(REMOTE_SERVER_PREFS_PATH); +let closeBehavior = electronPreferences.closeBehavior; let remoteServerUrl = resolveRemoteServerUrl({ env: process.env, prefsPath: REMOTE_SERVER_PREFS_PATH, @@ -363,16 +381,18 @@ function setupContentSecurityPolicy() { } // ── Create Window ────────────────────────────────────────── -function createWindow() { +function createWindow({ showWhenReady = true } = {}) { if (mainWindow && !mainWindow.isDestroyed()) return mainWindow; + const rendererStartedAt = Date.now(); + // Platform-conditional options (#9) const platformWindowOptions = process.platform === "darwin" ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } } : { titleBarStyle: "default" }; - mainWindow = new BrowserWindow({ + const window = new BrowserWindow({ width: 1400, height: 900, minWidth: 1024, @@ -390,22 +410,28 @@ function createWindow() { backgroundColor: "#0a0a0a", ...platformWindowOptions, }); + mainWindow = window; // Load the Next.js app - mainWindow.loadURL(getServerUrl()); + window.loadURL(resolveRendererUrl(lastRendererUrl, getServerUrl())); if (isDev) { - mainWindow.webContents.openDevTools({ mode: "detach" }); + window.webContents.openDevTools({ mode: "detach" }); } - // Hidden startup skips createWindow() entirely; any created dashboard is explicit. - mainWindow.once("ready-to-show", () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.show(); + // Hidden startup (createWindow({ showWhenReady: false })) skips the initial + // show(); the window stays created (so tray/dock interactions work) but the + // renderer only becomes visible on the next explicit showMainWindow() call. + window.once("ready-to-show", () => { + console.log(`[Electron] Renderer ready in ${Date.now() - rendererStartedAt}ms`); + if (showWhenReady) { + window.show(); + } else { + console.log("[Electron] Launched hidden in background tray"); } }); // Handle external links — validate URL protocol to prevent RCE - mainWindow.webContents.setWindowOpenHandler(({ url }) => { + window.webContents.setWindowOpenHandler(({ url }) => { try { const parsedUrl = new URL(url); if (["http:", "https:"].includes(parsedUrl.protocol)) { @@ -419,20 +445,28 @@ function createWindow() { return { action: "deny" }; }); - // Handle window close — minimize to tray - mainWindow.on("close", (event) => { + // Keep the server alive while either hiding the renderer for a fast reopen or + // unloading it to reclaim memory, according to the persisted tray preference. + window.on("close", (event) => { if (!app.isQuitting) { event.preventDefault(); - mainWindow.hide(); + lastRendererUrl = resolveRendererUrl(window.webContents.getURL(), getServerUrl()); + if (closeBehavior === CLOSE_BEHAVIOR_UNLOAD) { + console.log("[Electron] Dashboard renderer unloaded; server remains running"); + window.destroy(); + } else { + console.log("[Electron] Dashboard hidden; renderer kept loaded"); + window.hide(); + } } return false; }); - mainWindow.on("closed", () => { - mainWindow = null; + window.on("closed", () => { + if (mainWindow === window) mainWindow = null; }); - return mainWindow; + return window; } function showMainWindow() { @@ -443,6 +477,14 @@ function showMainWindow() { }); } +function setCloseBehavior(nextBehavior) { + const normalized = normalizeCloseBehavior(nextBehavior); + if (!normalized || normalized === closeBehavior) return; + closeBehavior = normalized; + writeCloseBehavior(REMOTE_SERVER_PREFS_PATH, closeBehavior); + createTray(); +} + // ── System Tray ──────────────────────────────────────────── function createTray() { // Fix #4: Destroy old tray before recreating @@ -503,6 +545,23 @@ function createTray() { }, ], }, + { + label: "When Dashboard Closes", + submenu: [ + { + label: "Keep Loaded (Faster Reopen)", + type: "radio", + checked: closeBehavior === CLOSE_BEHAVIOR_KEEP_LOADED, + click: () => setCloseBehavior(CLOSE_BEHAVIOR_KEEP_LOADED), + }, + { + label: "Unload Renderer (Lower Memory)", + type: "radio", + checked: closeBehavior === CLOSE_BEHAVIOR_UNLOAD, + click: () => setCloseBehavior(CLOSE_BEHAVIOR_UNLOAD), + }, + ], + }, { type: "separator" }, { label: "Check for Updates", @@ -521,9 +580,7 @@ function createTray() { tray.setToolTip("OmniRoute"); tray.setContextMenu(contextMenu); - tray.on("double-click", () => { - showMainWindow(); - }); + tray.on("double-click", () => showMainWindow()); } // ── Change Port (#3: now restarts server) ────────────────── @@ -545,6 +602,7 @@ async function changePort(newPort) { await waitForServer(getServerReadinessUrl()); // Reload window and update tray + lastRendererUrl = getServerUrl(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.loadURL(getServerUrl()); } @@ -609,6 +667,7 @@ async function setRemoteServerUrl(nextUrl) { remoteServerUrl = normalized; writeRemoteServerUrl(REMOTE_SERVER_PREFS_PATH, remoteServerUrl); + lastRendererUrl = getServerUrl(); startNextServer(); try { @@ -1151,7 +1210,12 @@ app.on("window-all-closed", () => { process.argv.includes("--headless") || process.argv.includes("--cli") || process.env.OMNIROUTE_HEADLESS === "true"; - if (process.platform !== "darwin" && !isHeadless && !keepAliveWithoutWindows) { + if ( + process.platform !== "darwin" && + !isHeadless && + !keepAliveWithoutWindows && + closeBehavior !== CLOSE_BEHAVIOR_UNLOAD + ) { app.quit(); } }); diff --git a/electron/package.json b/electron/package.json index 8a895b3566c8..57789a431891 100644 --- a/electron/package.json +++ b/electron/package.json @@ -68,6 +68,7 @@ "lib/resolveRemoteServerUrl.js", "lib/remoteServerPreferences.js", "lib/serverReadiness.js", + "lib/windowClosePolicy.js", "assets/remoteServerPrompt.html", "package.json", "node_modules/**/*" diff --git a/tests/unit/electron-lazy-window.test.ts b/tests/unit/electron-lazy-window.test.ts index d3a4440c8b07..f9d3f9767751 100644 --- a/tests/unit/electron-lazy-window.test.ts +++ b/tests/unit/electron-lazy-window.test.ts @@ -73,9 +73,11 @@ describe("Electron hidden-start window lifecycle", () => { it("routes tray, second-instance, and macOS activation opens through the lazy helper", () => { const mainSource = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); - assert.match(mainSource, /app\.on\("second-instance", \(\) => \{\s*showMainWindow\(\);/); + // #10328 added a headless guard ahead of the lazy-open call; second-instance + // must still route through showMainWindow() once past that guard. + assert.match(mainSource, /app\.on\("second-instance", \(\) => \{[\s\S]*?showMainWindow\(\);/); assert.match(mainSource, /label: "Open OmniRoute",\s*click: \(\) => showMainWindow\(\)/); - assert.match(mainSource, /tray\.on\("double-click", \(\) => \{\s*showMainWindow\(\);/); + assert.match(mainSource, /tray\.on\("double-click", \(\) => showMainWindow\(\)\);/); assert.match(mainSource, /app\.on\("activate", \(\) => \{[\s\S]*?showMainWindow\(\);/); }); diff --git a/tests/unit/electron-remote-server.test.ts b/tests/unit/electron-remote-server.test.ts index 05903db78204..9595a0d6fe63 100644 --- a/tests/unit/electron-remote-server.test.ts +++ b/tests/unit/electron-remote-server.test.ts @@ -25,6 +25,7 @@ const { const { readPreferences, writeRemoteServerUrl, + writeCloseBehavior, } = require("../../electron/lib/remoteServerPreferences"); function withTempDir(fn: (dir: string) => void) { @@ -126,7 +127,10 @@ describe("remoteServerPreferences read/write", () => { withTempDir((dir) => { const prefsPath = join(dir, "electron-preferences.json"); writeRemoteServerUrl(prefsPath, "http://localhost:20128"); - assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: "http://localhost:20128" }); + assert.deepEqual(readPreferences(prefsPath), { + remoteServerUrl: "http://localhost:20128", + closeBehavior: "keep-loaded", + }); }); }); @@ -135,7 +139,10 @@ describe("remoteServerPreferences read/write", () => { const prefsPath = join(dir, "electron-preferences.json"); writeRemoteServerUrl(prefsPath, "http://localhost:20128"); writeRemoteServerUrl(prefsPath, null); - assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: null }); + assert.deepEqual(readPreferences(prefsPath), { + remoteServerUrl: null, + closeBehavior: "keep-loaded", + }); }); }); @@ -144,14 +151,32 @@ describe("remoteServerPreferences read/write", () => { const prefsPath = join(dir, "nested", "deep", "electron-preferences.json"); assert.doesNotThrow(() => writeRemoteServerUrl(prefsPath, "http://localhost:20128")); assert.equal(existsSync(prefsPath), true); - assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: "http://localhost:20128" }); + assert.deepEqual(readPreferences(prefsPath), { + remoteServerUrl: "http://localhost:20128", + closeBehavior: "keep-loaded", + }); }); }); it("reading a nonexistent prefs file returns remoteServerUrl: null", () => { withTempDir((dir) => { const prefsPath = join(dir, "electron-preferences.json"); - assert.deepEqual(readPreferences(prefsPath), { remoteServerUrl: null }); + assert.deepEqual(readPreferences(prefsPath), { + remoteServerUrl: null, + closeBehavior: "keep-loaded", + }); + }); + }); + + it("persists close behavior without discarding the remote server URL", () => { + withTempDir((dir) => { + const prefsPath = join(dir, "electron-preferences.json"); + writeRemoteServerUrl(prefsPath, "https://omniroute.example.com"); + writeCloseBehavior(prefsPath, "unload"); + assert.deepEqual(readPreferences(prefsPath), { + remoteServerUrl: "https://omniroute.example.com", + closeBehavior: "unload", + }); }); }); }); @@ -243,6 +268,7 @@ describe("Electron packaging manifest includes Remote Server Mode files", () => for (const expected of [ "lib/resolveRemoteServerUrl.js", "lib/remoteServerPreferences.js", + "lib/windowClosePolicy.js", "remoteServerPromptPreload.js", "remoteServerPromptRenderer.js", "assets/remoteServerPrompt.html", diff --git a/tests/unit/electron-window-close-policy.test.ts b/tests/unit/electron-window-close-policy.test.ts new file mode 100644 index 000000000000..1febea3b6f92 --- /dev/null +++ b/tests/unit/electron-window-close-policy.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { + CLOSE_BEHAVIOR_KEEP_LOADED, + CLOSE_BEHAVIOR_UNLOAD, + normalizeCloseBehavior, + resolveRendererUrl, +} = require("../../electron/lib/windowClosePolicy"); + +describe("Electron window close policy", () => { + it("accepts only the two deliberate close behaviors", () => { + assert.equal(normalizeCloseBehavior("keep-loaded"), CLOSE_BEHAVIOR_KEEP_LOADED); + assert.equal(normalizeCloseBehavior("unload"), CLOSE_BEHAVIOR_UNLOAD); + assert.equal(normalizeCloseBehavior("destroy"), null); + assert.equal(normalizeCloseBehavior(undefined), null); + }); + + it("preserves same-origin dashboard navigation when recreating the renderer", () => { + assert.equal( + resolveRendererUrl( + "http://localhost:20128/dashboard/settings?tab=providers", + "http://localhost:20128" + ), + "http://localhost:20128/dashboard/settings?tab=providers" + ); + }); + + it("falls back to the active server for invalid or cross-origin URLs", () => { + assert.equal( + resolveRendererUrl("https://example.com/dashboard", "http://localhost:20128"), + "http://localhost:20128/" + ); + assert.equal( + resolveRendererUrl("not a url", "http://localhost:20128"), + "http://localhost:20128" + ); + }); +}); + +describe("Electron main-process close policy wiring", () => { + const mainSrc = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + + it("defaults to keeping the renderer loaded and exposes both policies in the tray", () => { + assert.match(mainSrc, /electronPreferences\.closeBehavior/); + assert.match(mainSrc, /Keep Loaded \(Faster Reopen\)/); + assert.match(mainSrc, /Unload Renderer \(Lower Memory\)/); + assert.match(mainSrc, /writeCloseBehavior\(REMOTE_SERVER_PREFS_PATH, closeBehavior\)/); + }); + + it("destroys only the renderer in unload mode and otherwise hides the window", () => { + const closeHandler = mainSrc.slice( + mainSrc.indexOf('window.on("close"'), + mainSrc.indexOf('window.on("closed"') + ); + assert.ok(closeHandler.includes("closeBehavior === CLOSE_BEHAVIOR_UNLOAD")); + assert.ok(closeHandler.includes("window.destroy()")); + assert.ok(closeHandler.includes("window.hide()")); + assert.ok(!closeHandler.includes("stopNextServer")); + }); + + it("recreates the renderer from every explicit reopen path", () => { + const secondInstanceHandler = mainSrc.slice( + mainSrc.indexOf('app.on("second-instance"'), + mainSrc.indexOf("// ── Environment Detection") + ); + assert.ok(secondInstanceHandler.includes("showMainWindow()")); + assert.ok(secondInstanceHandler.includes("if (isHeadless) return")); + assert.match(mainSrc, /label: "Open OmniRoute",\s*click: \(\) => showMainWindow\(\)/); + assert.match(mainSrc, /tray\.on\("double-click", \(\) => showMainWindow\(\)\)/); + assert.match( + mainSrc, + /app\.on\("activate", \(\) => \{\s*if \(isHeadless\) return;\s*showMainWindow\(\);/ + ); + }); + + it("keeps the non-macOS app alive when unloading its last renderer", () => { + assert.match( + mainSrc, + /process\.platform !== "darwin"[\s\S]*closeBehavior !== CLOSE_BEHAVIOR_UNLOAD[\s\S]*app\.quit\(\)/ + ); + }); +}); From 80a59c0ae56e8572795f39faef93ea7165fa2eed Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:47:34 +0200 Subject: [PATCH 05/17] fix(images): route bare dall-e-3 to OpenAI (#10847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- changelog.d/fixes/10832-unprefixed-dalle3.md | 1 + open-sse/config/imageRegistry.ts | 1 + tests/unit/unprefixed-dalle3-10832.test.ts | 43 ++++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 changelog.d/fixes/10832-unprefixed-dalle3.md create mode 100644 tests/unit/unprefixed-dalle3-10832.test.ts diff --git a/changelog.d/fixes/10832-unprefixed-dalle3.md b/changelog.d/fixes/10832-unprefixed-dalle3.md new file mode 100644 index 000000000000..2dfd970b13c9 --- /dev/null +++ b/changelog.d/fixes/10832-unprefixed-dalle3.md @@ -0,0 +1 @@ +- **fix(images):** register OpenAI `dall-e-3` in the image registry so unprefixed `dall-e-3` (and `openai/dall-e-3`) route to OpenAI Images instead of Microsoft Designer Web, and so the chat catalog no longer lists `openai/dall-e-3` as a 128k chat model ([#10832](https://github.com/diegosouzapw/OmniRoute/issues/10832)) diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index d50865599db2..17ce7de90eba 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -210,6 +210,7 @@ export const IMAGE_PROVIDERS: Record = { authHeader: "bearer", format: "openai", // native OpenAI format models: [ + { id: "dall-e-3", name: "DALL·E 3" }, { id: "gpt-image-2", name: "GPT Image 2" }, { id: "gpt-image-1.5", name: "GPT Image 1.5" }, { id: "gpt-image-1-mini", name: "GPT Image 1 Mini" }, diff --git a/tests/unit/unprefixed-dalle3-10832.test.ts b/tests/unit/unprefixed-dalle3-10832.test.ts new file mode 100644 index 000000000000..ae6200cbf5c2 --- /dev/null +++ b/tests/unit/unprefixed-dalle3-10832.test.ts @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + IMAGE_PROVIDERS, + parseImageModel, + getAllImageModels, + isRegisteredImageModel, +} from "../../open-sse/config/imageRegistry.ts"; + +test("#10832 unprefixed dall-e-3 routes to OpenAI, not Microsoft Designer Web", () => { + assert.deepEqual(parseImageModel("dall-e-3"), { + provider: "openai", + model: "dall-e-3", + }); + assert.deepEqual(parseImageModel("openai/dall-e-3"), { + provider: "openai", + model: "dall-e-3", + }); + assert.deepEqual(parseImageModel("microsoft-designer-web/dall-e-3"), { + provider: "microsoft-designer-web", + model: "dall-e-3", + }); + assert.deepEqual(parseImageModel("msdesigner/dall-e-3"), { + provider: "microsoft-designer-web", + model: "dall-e-3", + }); + + assert.equal(isRegisteredImageModel("openai", "dall-e-3"), true); + assert.equal(isRegisteredImageModel("microsoft-designer-web", "dall-e-3"), true); + + const openai = IMAGE_PROVIDERS.openai; + assert.ok(openai.models.some((model) => model.id === "dall-e-3")); + + const catalog = getAllImageModels(); + assert.ok(catalog.some((model) => model.id === "openai/dall-e-3" && model.provider === "openai")); + assert.ok( + catalog.some( + (model) => + model.id === "microsoft-designer-web/dall-e-3" && + model.provider === "microsoft-designer-web" + ) + ); +}); From 84d7e33c2650b5c56f0330e39bb63739dd300794 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:47:38 +0200 Subject: [PATCH 06/17] feat(resilience): warn on slow /healthz event-loop lag (#10827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- .../features/10303-healthz-event-loop-lag.md | 1 + docs/ops/MONITORING_GUIDE.md | 2 +- src/app/healthz/route.ts | 2 + src/lib/healthzLag.ts | 40 +++++++++++++++++++ tests/unit/10303-healthz-lag.test.ts | 35 ++++++++++++++++ 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/10303-healthz-event-loop-lag.md create mode 100644 src/lib/healthzLag.ts create mode 100644 tests/unit/10303-healthz-lag.test.ts diff --git a/changelog.d/features/10303-healthz-event-loop-lag.md b/changelog.d/features/10303-healthz-event-loop-lag.md new file mode 100644 index 000000000000..991c12302183 --- /dev/null +++ b/changelog.d/features/10303-healthz-event-loop-lag.md @@ -0,0 +1 @@ +- **feat(resilience):** warn when `/healthz` is served under event-loop lag ≥200ms so a slow 200 is visible as sick, not healthy ([#10303](https://github.com/diegosouzapw/OmniRoute/issues/10303)) diff --git a/docs/ops/MONITORING_GUIDE.md b/docs/ops/MONITORING_GUIDE.md index 82a66eeb499a..a25add924231 100644 --- a/docs/ops/MONITORING_GUIDE.md +++ b/docs/ops/MONITORING_GUIDE.md @@ -162,7 +162,7 @@ OmniRoute is a **single Node process** (one event loop). Stock Docker `HEALTHCHE | Probe | Recommended target | Notes | | --- | --- | --- | | **Startup** | HTTP `GET /healthz` with a long `failureThreshold` (or large `startPeriod`) | Cold start + SQLite migration can exceed a few seconds | -| **Readiness** | HTTP `GET /healthz` | Remove endpoints while starting/stopping; still flaps if the loop is CPU-blocked | +| **Readiness** | HTTP `GET /healthz` | Remove endpoints while starting/stopping; still flaps if the loop is CPU-blocked. A **200 in multiple seconds is not healthy** (#10303) — it means the event loop was starved before the 3-byte handler ran | | **Liveness** | **TCP** on the main service port (`PORT`, default `20128`), **or** HTTP `/healthz` with soft thresholds | Do **not** kill the pod on short event-loop stalls; busy ≠ dead | | **Deep health** | `GET /api/monitoring/health` from an external checker | Not for kubelet `livenessProbe` / tight `readinessProbe` | diff --git a/src/app/healthz/route.ts b/src/app/healthz/route.ts index 4ee01c32d2f1..5ac54700fd83 100644 --- a/src/app/healthz/route.ts +++ b/src/app/healthz/route.ts @@ -1,4 +1,5 @@ import { getServerLifecyclePhase } from "@/lib/serverLifecycle"; +import { observeHealthzEventLoopLag } from "@/lib/healthzLag"; export const dynamic = "force-dynamic"; @@ -23,6 +24,7 @@ function createHealthResponse(method: "GET" | "HEAD"): Response { } export function GET(): Response { + observeHealthzEventLoopLag(); return createHealthResponse("GET"); } diff --git a/src/lib/healthzLag.ts b/src/lib/healthzLag.ts new file mode 100644 index 000000000000..fb4f17567ec4 --- /dev/null +++ b/src/lib/healthzLag.ts @@ -0,0 +1,40 @@ +import { monitorEventLoopDelay } from "node:perf_hooks"; + +/** `/healthz` returning 200 after this much event-loop lag is already sick (#10303). */ +export const HEALTHZ_SLOW_LAG_MS = 200; +const WARN_EVERY_MS = 10_000; + +let lastWarnAt = 0; +let histogram: ReturnType | null = null; + +export function resetHealthzLagWarnStateForTests(): void { + lastWarnAt = 0; +} + +export function shouldWarnHealthzLag(lagMs: number, now = Date.now()): boolean { + if (!Number.isFinite(lagMs) || lagMs < HEALTHZ_SLOW_LAG_MS) return false; + if (now - lastWarnAt < WARN_EVERY_MS) return false; + lastWarnAt = now; + return true; +} + +export function formatHealthzLagWarning(lagMs: number): string { + return `GET /healthz event-loop lag ${Math.round(lagMs)}ms (HTTP 200 is not healthy; busy != ready)`; +} + +export function getEventLoopLagMs(): number { + if (!histogram) { + histogram = monitorEventLoopDelay({ resolution: 20 }); + histogram.enable(); + } + return histogram.mean / 1e6; +} + +export function observeHealthzEventLoopLag( + log: (msg: string) => void = console.warn, + lagMs = getEventLoopLagMs() +): boolean { + if (!shouldWarnHealthzLag(lagMs)) return false; + log(`[HEALTHZ] ${formatHealthzLagWarning(lagMs)}`); + return true; +} diff --git a/tests/unit/10303-healthz-lag.test.ts b/tests/unit/10303-healthz-lag.test.ts new file mode 100644 index 000000000000..afaab6e77f6c --- /dev/null +++ b/tests/unit/10303-healthz-lag.test.ts @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + HEALTHZ_SLOW_LAG_MS, + formatHealthzLagWarning, + observeHealthzEventLoopLag, + resetHealthzLagWarnStateForTests, + shouldWarnHealthzLag, +} from "../../src/lib/healthzLag.ts"; + +test("shouldWarnHealthzLag ignores sub-threshold lag", () => { + resetHealthzLagWarnStateForTests(); + assert.equal(shouldWarnHealthzLag(0), false); + assert.equal(shouldWarnHealthzLag(HEALTHZ_SLOW_LAG_MS - 1), false); +}); + +test("shouldWarnHealthzLag fires once then debounce", () => { + resetHealthzLagWarnStateForTests(); + const t0 = 1_700_000_000_000; + assert.equal(shouldWarnHealthzLag(3748, t0), true); + assert.equal(shouldWarnHealthzLag(3748, t0 + 1000), false); + assert.equal(shouldWarnHealthzLag(3748, t0 + 10_000), true); +}); + +test("observeHealthzEventLoopLag logs when injected lag is high", () => { + resetHealthzLagWarnStateForTests(); + const messages: string[] = []; + assert.equal(observeHealthzEventLoopLag((m) => messages.push(m), 12), false); + assert.equal(messages.length, 0); + assert.equal(observeHealthzEventLoopLag((m) => messages.push(m), 3748), true); + assert.equal(messages[0], `[HEALTHZ] ${formatHealthzLagWarning(3748)}`); + assert.match(messages[0], /3748ms/); + assert.match(messages[0], /not healthy/); +}); From 7ac6bbba377883a860320f4586081b1e07f43c98 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:47:43 +0200 Subject: [PATCH 07/17] docs(backend): document memory/skills/token-refresh event-loop cost (#10825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- .../maintenance/10349-optional-work-event-loop.md | 1 + docs/ops/MONITORING_GUIDE.md | 5 +++++ docs/reference/ENVIRONMENT.md | 13 +++++++++++++ 3 files changed, 19 insertions(+) create mode 100644 changelog.d/maintenance/10349-optional-work-event-loop.md diff --git a/changelog.d/maintenance/10349-optional-work-event-loop.md b/changelog.d/maintenance/10349-optional-work-event-loop.md new file mode 100644 index 000000000000..0cd695e4a7c4 --- /dev/null +++ b/changelog.d/maintenance/10349-optional-work-event-loop.md @@ -0,0 +1 @@ +- **docs(backend):** document that memory extraction, skills injection, and token refresh share the request event loop, plus dashboard kill switches ([#10349](https://github.com/diegosouzapw/OmniRoute/issues/10349)) diff --git a/docs/ops/MONITORING_GUIDE.md b/docs/ops/MONITORING_GUIDE.md index a25add924231..da1dc7d39bbd 100644 --- a/docs/ops/MONITORING_GUIDE.md +++ b/docs/ops/MONITORING_GUIDE.md @@ -197,6 +197,11 @@ livenessProbe: Related: [#10052](https://github.com/diegosouzapw/OmniRoute/issues/10052) (probes while the event loop is busy), [#9685](https://github.com/diegosouzapw/OmniRoute/issues/9685) / [#10055](https://github.com/diegosouzapw/OmniRoute/pull/10055) (catalog pricing hog), [#10117](https://github.com/diegosouzapw/OmniRoute/issues/10117) (compression token-count hog). + +### Optional request-path work (memory, skills, token refresh) + +Memory extraction, skills injection, and OAuth token refresh share the **main Node event loop** with `/healthz`. They are dashboard-toggle features (`memoryEnabled`, `skillsEnabled`), not a worker pool. See [Environment — event-loop cost](../reference/ENVIRONMENT.md#event-loop-cost-of-memory-skills-and-token-refresh-10349). + ### Provider Health > **No REST endpoint.** Provider health data is available via the MCP tool `observability_snapshot` or the dashboard `/dashboard/providers` page. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 44af6e49b4ca..11f53408d0bb 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -862,6 +862,19 @@ The logging system writes to both stdout and rotated log files. All configuratio ### Memory Engine (plan 21) +### Event-loop cost of memory, skills, and token refresh (#10349) + +OmniRoute is a **single Node process**. Memory extraction/retrieval, skills injection, and provider token refresh run on that **same event loop** as `GET /healthz` and the dashboard. They are not a worker thread. + +| Work | Code | Default | Operator control | +| --- | --- | --- | --- | +| Memory extraction / retrieval | `src/lib/memory/` | Dashboard **memoryEnabled** (default on) | Turn off **Settings → Memory**. There is no separate env kill switch beyond disabling the feature in settings. | +| Skills injection | `src/lib/skills/injection.ts` | Dashboard **skillsEnabled** (default on) | Turn off **Settings → Memory/Skills** (`skillsEnabled`). Sandbox knobs below only bound execution after injection is already on. | +| Token refresh | `src/sse/services/tokenRefresh.ts` | On for connected OAuth/web providers | Disconnect the provider or let tokens stay valid; there is no `TOKEN_REFRESH=0` env today. | + +If `/healthz` is slow on a quiet box, disable memory + skills first, then check catalog/compression load (#10303, #9685). These features yield at `await` points but still compete for the one thread. + + Embedding layer, vector store and reranking knobs for the persistent memory subsystem (`src/lib/memory/`). | Variable | Default | Description | From 6767f270110bff5c526860976329559a13d919ec Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:47:47 +0200 Subject: [PATCH 08/17] docs(db): document throttled pre-write SQLite backups (#10824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- changelog.d/maintenance/10351-pre-write-backup-throttle.md | 1 + docs/reference/ENVIRONMENT.md | 4 ++-- src/lib/db/backup.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changelog.d/maintenance/10351-pre-write-backup-throttle.md diff --git a/changelog.d/maintenance/10351-pre-write-backup-throttle.md b/changelog.d/maintenance/10351-pre-write-backup-throttle.md new file mode 100644 index 000000000000..f6141d30eaf9 --- /dev/null +++ b/changelog.d/maintenance/10351-pre-write-backup-throttle.md @@ -0,0 +1 @@ +- **docs(backend):** document that pre-write SQLite backups (including models.dev pricing) are throttled to once per 60 minutes and can be disabled with `DISABLE_SQLITE_AUTO_BACKUP` ([#10351](https://github.com/diegosouzapw/OmniRoute/issues/10351)) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 11f53408d0bb..b35d7c97d9ec 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -91,7 +91,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_DATA_DIR` | _(unset)_ | `open-sse/executors/promptql/threadSticky.ts` | **Fallback alias** for `DATA_DIR`, checked only when `DATA_DIR` is unset. Used to locate the PromptQL executor's on-disk thread-sticky session cache (`/promptql-thread-sessions.json`); if neither var is set, the cache stays in-memory only (not persisted across restarts). | | `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. | | `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. | -| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips the automatic database backup that runs before migrations on every startup. | +| `DISABLE_SQLITE_AUTO_BACKUP` | `false` | `src/lib/db/backup.ts` | When `true`, skips automatic + pre-write SQLite file backups (startup, models.dev pricing save/clear, settings writes). Manual and pre-restore backups still run. Non-manual backups are also **throttled to at most once per 60 minutes** so hourly models.dev sync does not copy the whole DB on every pricing write. Dashboard **Settings → Storage** can disable auto-backup independently. | | `OMNIROUTE_CRYPT_KEY` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** for `STORAGE_ENCRYPTION_KEY`. Accepted as a fallback when the primary variable is absent. | | `OMNIROUTE_API_KEY_BASE64` | _(unset)_ | `src/lib/db/encryption.ts` | **Legacy alias** (Base64-encoded form) accepted as a fallback. Decoded automatically before use. | | `OMNIROUTE_DB_HEALTHCHECK_INTERVAL_MS` | _(unset)_ | `src/lib/db/core.ts` | Override the periodic SQLite healthcheck interval (ms). When unset, defaults are derived from `NODE_ENV`. | @@ -983,7 +983,7 @@ desktop install. | Variable | Default | Source File | Description | | ----------------------------------- | ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MODELS_DEV_SYNC_ENABLED` | _(unset)_ | `src/lib/modelsDevSync.ts` | Hard override for models.dev pricing sync. Unset = honor Settings > AI (`modelsDevSyncEnabled`). `0`/`false`/`off`/`no` **wins over the DB** and skips both periodic sync and `getModelsDevPricing()` SQL/JSON scans (recovery when the dashboard is wedged on the same event loop). `1`/`true`/`on`/`yes` forces sync on. | +| `MODELS_DEV_SYNC_ENABLED` | _(unset)_ | `src/lib/modelsDevSync.ts` | Hard override for models.dev pricing sync. Unset = honor Settings > AI (`modelsDevSyncEnabled`). `0`/`false`/`off`/`no` **wins over the DB** and skips both periodic sync and `getModelsDevPricing()` SQL/JSON scans (recovery when the dashboard is wedged on the same event loop). `1`/`true`/`on`/`yes` forces sync on. Pricing save/clear still call `backupDbFile("pre-write")`, which is no-op under the 60-minute throttle or `DISABLE_SQLITE_AUTO_BACKUP`. | | `MODELS_DEV_SYNC_INTERVAL` | `86400` (24h) | `src/lib/modelsDevSync.ts` | Development-time model catalog sync interval in seconds. | | `CONTEXT_WINDOW_RECONCILE_INTERVAL` | `86400` (24h) | `src/lib/contextWindowResolver.ts` | Interval (seconds) for the self-correcting context-window reconciler (5004): pins provider-declared windows from `/models` discovery as `auto:discovery` overrides when they diverge from the catalog. Set to `0` to disable. Reuses already-synced data (no new fetch); never overwrites `manual` overrides. | diff --git a/src/lib/db/backup.ts b/src/lib/db/backup.ts index 3317ddb9fedd..effbaf09c02e 100644 --- a/src/lib/db/backup.ts +++ b/src/lib/db/backup.ts @@ -28,7 +28,7 @@ type CountRow = { cnt?: number }; // ──────────────── Backup Config ──────────────── let _lastBackupAt = 0; -const BACKUP_THROTTLE_MS = 60 * 60 * 1000; // 60 minutes +const BACKUP_THROTTLE_MS = 60 * 60 * 1000; // 60 minutes — high-churn pre-write (models.dev pricing) must not copy the whole SQLite file every call (#10351) const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]); // #3834: the "Keep latest backups" UI value is persisted here so it survives a page From 8b52596d7c8c7cf91480bb53d0cfa398f3daf07a Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:47:50 +0200 Subject: [PATCH 09/17] docs(auth): distinguish access tokens, API keys, and management credentials (#10823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- .../maintenance/7786-management-auth-guide.md | 1 + docs/guides/MANAGEMENT-AUTH.md | 164 +++++++++++++++--- docs/openapi.yaml | 10 +- docs/providers/ZED-DOCKER.md | 2 +- docs/reference/API_REFERENCE.md | 9 + public/openapi.yaml | 10 +- .../settings/components/AccessTokensTab.tsx | 2 +- 7 files changed, 166 insertions(+), 32 deletions(-) create mode 100644 changelog.d/maintenance/7786-management-auth-guide.md diff --git a/changelog.d/maintenance/7786-management-auth-guide.md b/changelog.d/maintenance/7786-management-auth-guide.md new file mode 100644 index 000000000000..54f84a79b94e --- /dev/null +++ b/changelog.d/maintenance/7786-management-auth-guide.md @@ -0,0 +1 @@ +- **docs(auth):** distinguish dashboard sessions, `oma_live_…` Access Tokens, manage-scoped API keys, and inference keys ([#7786](https://github.com/diegosouzapw/OmniRoute/issues/7786)) diff --git a/docs/guides/MANAGEMENT-AUTH.md b/docs/guides/MANAGEMENT-AUTH.md index 25e0d7ae5966..31391e4b7f89 100644 --- a/docs/guides/MANAGEMENT-AUTH.md +++ b/docs/guides/MANAGEMENT-AUTH.md @@ -1,47 +1,159 @@ --- title: "Management Authentication" version: 3.8.50 -lastUpdated: 2026-08-05 +lastUpdated: 2026-08-20 --- # Management Authentication -OmniRoute uses four distinct credential families for management access. This guide -distinguishes them by purpose, scope, and locality. +OmniRoute has **four credential families** that can authorize management routes. +They are not interchangeable. Inference API keys (`sk-…`) do **not** manage the +server unless they were explicitly granted `manage` or `admin` scope. -| Credential | Scope | Locality | Use Case | -|-------------------------|--------------------|---------------|-----------------------------------| -| Dashboard JWT session | Full management | Localhost | Web dashboard login | -| CLI machine-id token | Full management | Per-machine | `omniroute` CLI commands | -| Scoped `oma_` token | Configurable scope | External | Automation / CI / API access | -| Manage-scope API key | `manage` scope | External | Management API calls | +Canonical implementation: `src/lib/api/requireManagementAuth.ts`. -## Dashboard JWT Session +| Credential | Typical form | Created where | Intended use | Management capability | +|---|---|---|---|---| +| Dashboard session | `auth_token` cookie | Dashboard login | Browser UI | Full dashboard management, subject to CSRF, locality, and always-protected-route rules | +| Local CLI machine token | internal / local | CLI bootstrap (`omniroute` on the same machine) | Local CLI | Local management only | +| Scoped Access Token | `oma_live_…` | **Settings → Access Tokens** or `omniroute connect` | Remote CLI and management API | Must satisfy the route's required `read`, `write`, or `admin` scope | +| Inference API key | `sk-…` (and other API-key prefixes) | **API Manager / API Keys** | `/v1/*` inference | **None** unless the key metadata includes `manage` or `admin` | -Generated on dashboard login (`/api/auth/login`). Stored in HTTP-only cookie. -Valid for the session duration. Cannot be used from external hosts. +`oma_` credentials are management/CLI credentials. They are **not** inference API keys. -## CLI Machine-ID Token +If login/API-key auth is disabled for the server, some management routes may +accept unauthenticated calls. Local-only and always-protected routes still apply +their own rules. Presenting one of these credentials is therefore not universally +mandatory, and possessing one is not universally sufficient without the required +scope and route locality. -Created by `omniroute auth login` on first use. Stored in `~/.omniroute/auth.json`. -Used by the CLI for all management operations. Tied to the machine identity. +Related: [Remote Mode](./REMOTE-MODE.md) (how `oma_live_…` is minted for a remote CLI). -## Scoped `oma_` Access Token +--- + +## Scope matrices + +These two scope vocabularies are **different**. Do not mix them. + +### Access Token scopes (`oma_live_…`) + +| Scope | Typical operations | +|---|---| +| `read` | List/status GETs that the token is allowed to see | +| `write` | Mutations (create/update/delete) below admin | +| `admin` | Full remote CLI / connect token (password bootstrap defaults here) | + +A token with `read` cannot call a `write` route. Runtime message shape: +`Access token scope '' is insufficient; '' required.` + +### API-key management scopes + +| Scope | Meaning | +|---|---| +| (none) | Inference only. Management routes return 403. | +| `manage` | Management API (same gate as `requireManagementAuth` API-key branch) | +| `admin` | Also satisfies `hasManageScope` (treated as management-capable) | + +Enable `manage` on the key in the API Keys / API Manager UI. Do not reuse a +chat client key for automation unless you deliberately granted that scope. + +--- + +## How to create and revoke + +### Dashboard session + +1. Open `/login`, sign in with the management password (`INITIAL_PASSWORD` on first boot). +2. Cookie `auth_token` is HttpOnly. Browser dashboard uses it automatically. +3. Log out via `/api/auth/logout`. There is no long-lived secret to copy. + +### Local CLI machine token -Created via dashboard or CLI with configurable scopes (e.g., `manage`, `read`). -Format: `oma_`. Used for programmatic access from external systems. +1. Run `omniroute` on the **same host** as the server (loopback). +2. The CLI bootstraps a machine-id token under `~/.omniroute/` (chmod 600). +3. This does **not** work from another machine. Use an Access Token for remote CLI. -## Manage-Scope API Key +### Scoped Access Token (`oma_live_…`) -Standard API key with the `manage` scope enabled. Created in dashboard API Keys page. -Used for management API calls from external hosts. +1. Dashboard: **Settings → Access Tokens** → create (name + scope). **The secret is shown once.** +2. Or CLI: `omniroute connect ` (password → token). See [Remote Mode](./REMOTE-MODE.md). +3. Header: `Authorization: Bearer oma_live_…` +4. Revoke from the same Access Tokens page (or delete the CLI context). +5. Server stores only a hash. Treat the plaintext like a password. -## Header Examples +### Manage-scoped API key +1. Dashboard: **API Manager / API Keys** → create or edit a key → enable `manage` (or `admin`). +2. Header: `Authorization: Bearer sk-…` (the key's actual prefix). +3. Revoke or strip `manage` in the same UI. +4. Least privilege for automation that is not the CLI: prefer a `read` Access Token for GET-only jobs; use `manage` on an API key only when the caller must also speak `/v1` and management. + +--- + +## Header format + +```http +Authorization: Bearer oma_live_ +Authorization: Bearer sk- +Cookie: auth_token= ``` -Authorization: Bearer oma_abc123def456 -Authorization: Bearer -Cookie: omniroute_session= + +Do not put management credentials in the URL path or query string. Management +auth is header/cookie only. + +--- + +## Copy-paste examples + +Read-only (list providers). Use a `read` Access Token: + +```bash +curl -sS "$OMNIROUTE_URL/api/providers" \ + -H "Authorization: Bearer oma_live_" ``` -See `docs/reference/API_REFERENCE.md` for endpoint-specific auth requirements. +Modifying (create a provider connection). Use `write`/`admin` Access Token or a +manage-scoped API key: + +```bash +curl -sS -X POST "$OMNIROUTE_URL/api/providers" \ + -H "Authorization: Bearer oma_live_" \ + -H "Content-Type: application/json" \ + -d '{"provider":"openai","apiKey":""}' +``` + +Inference (not management). Ordinary API key, no `manage` required: + +```bash +curl -sS "$OMNIROUTE_URL/v1/models" \ + -H "Authorization: Bearer sk-" +``` + +--- + +## Current runtime errors (do not echo secrets) + +| Situation | Typical status | Message (sanitized) | +|---|---|---| +| No credential | 401 | `Authentication required` | +| Invalid/expired `oma_live_…` | 401 | `Invalid or expired access token` | +| Valid API key without `manage`/`admin` | 403 | `API key lacks 'manage' scope. Enable it in the API Keys dashboard.` | +| Invalid ordinary API key on a management route | 403 | `Invalid management token` | +| Access Token scope too low | 403 | `Access token scope '' is insufficient; '' required.` | + +"Invalid management token" means the bearer was **not** accepted as a management +credential. It does **not** tell you which family to mint. Use the table above: +inference keys need `manage` scope; remote CLI needs `oma_live_…`; the dashboard +uses the session cookie. + +--- + +## Recommended least-privilege choice + +| Caller | Use | +|---|---| +| Browser | Dashboard session | +| CLI on the server host | Machine token | +| CLI on a laptop talking to a remote server | `oma_live_…` from `omniroute connect` | +| CI / scripts (management only) | `oma_live_…` with the smallest scope that works | +| CI that must call both `/v1` and `/api` | API key with `manage` **or** two credentials | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 79cee7893bd8..54689a0a57b1 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -7329,12 +7329,18 @@ components: BearerAuth: type: http scheme: bearer - description: API key obtained from the OmniRoute dashboard + description: > + Two bearer families are accepted. Inference API keys (typically `sk-…`) + authorize `/v1/*`. Management routes also accept `oma_live_…` Access Tokens + (Settings → Access Tokens / `omniroute connect`) and API keys whose metadata + includes `manage` or `admin` scope. See docs/guides/MANAGEMENT-AUTH.md. + Bearer credentials are accepted on management routes that use this scheme; + they are not rejected solely for being Bearer. ManagementSessionAuth: type: apiKey in: cookie name: auth_token - description: Dashboard management session cookie for protected management routes + description: Dashboard management session cookie (auth_token) for protected management routes. Distinct from Bearer Access Tokens and API keys. See docs/guides/MANAGEMENT-AUTH.md. parameters: ResourceId: diff --git a/docs/providers/ZED-DOCKER.md b/docs/providers/ZED-DOCKER.md index 21b096b48c39..e3519ea70cf8 100644 --- a/docs/providers/ZED-DOCKER.md +++ b/docs/providers/ZED-DOCKER.md @@ -103,7 +103,7 @@ The manual import endpoint can also be called directly: ``` POST /api/providers/zed/manual-import Content-Type: application/json -Authorization: Bearer +Authorization: Bearer { "provider": "openai", diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index b2f5983b78c3..359ae4750eca 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -703,6 +703,10 @@ X-OmniRoute-No-Cache: true ## Dashboard & Management +Management routes (`/api/*` except public auth/login) are **not** authorized by +ordinary inference API keys. Credential families, scopes, and curl examples: +[Management Authentication](../guides/MANAGEMENT-AUTH.md). + ### Authentication | Endpoint | Method | Description | @@ -1668,9 +1672,14 @@ See [Security > Guardrails](../security/GUARDRAILS.md) for full details. ## Authentication +See [Management Authentication](../guides/MANAGEMENT-AUTH.md) for the four +credential families (dashboard session, local CLI token, `oma_live_…` Access +Token, manage-scoped API key) and how they differ from inference keys. + - Dashboard routes (`/dashboard/*`) use `auth_token` cookie - Login uses saved password hash; fallback to `INITIAL_PASSWORD` - `requireLogin` toggleable via `/api/settings/require-login` - `/v1/*` routes optionally require Bearer API key when `REQUIRE_API_KEY=true` +- "management token" / "management-scoped API key" in this reference means one of the families in that guide — not an undefined extra secret type > **Breaking change (v3.8.0)** — `/api/v1/agents/tasks/*` and the cooldown management endpoints now require **management auth** (dashboard `auth_token` cookie or a management-scoped API key). Clients that previously called these routes unauthenticated will receive `401 Unauthorized`. See commit `588a0333` (`fix(auth): require management auth for agent and cooldown APIs`). diff --git a/public/openapi.yaml b/public/openapi.yaml index ca00d2362355..caa5aac6ea1f 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -5270,12 +5270,18 @@ components: BearerAuth: type: http scheme: bearer - description: API key obtained from the OmniRoute dashboard + description: > + Two bearer families are accepted. Inference API keys (typically `sk-…`) + authorize `/v1/*`. Management routes also accept `oma_live_…` Access Tokens + (Settings → Access Tokens / `omniroute connect`) and API keys whose metadata + includes `manage` or `admin` scope. See docs/guides/MANAGEMENT-AUTH.md. + Bearer credentials are accepted on management routes that use this scheme; + they are not rejected solely for being Bearer. ManagementSessionAuth: type: apiKey in: cookie name: auth_token - description: Dashboard management session cookie for protected management routes + description: Dashboard management session cookie (auth_token) for protected management routes. Distinct from Bearer Access Tokens and API keys. See docs/guides/MANAGEMENT-AUTH.md. parameters: ResourceId: diff --git a/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx index d5eb058a737e..d4e9b88f9bc9 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AccessTokensTab.tsx @@ -126,7 +126,7 @@ export default function AccessTokensTab() {

{L( "accessTokensDescription", - "Scoped tokens that let the omniroute CLI manage this server remotely. Distinct from inference API keys. The secret is shown once." + "Scoped tokens that let the omniroute CLI manage this server remotely. Distinct from inference API keys. The secret is shown once. Automation guide: /docs/guides/MANAGEMENT-AUTH." )}

From 6f08a089e796dfe8f4a2d602a7899b71eb1022f1 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:47:53 +0200 Subject: [PATCH 10/17] feat(speech): accept response_format=ogg as an opus alias (#10822) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- .../features/10587-ogg-speech-alias.md | 1 + open-sse/handlers/audioSpeech.ts | 12 +++++- .../unit/audio-speech-ogg-alias-10587.test.ts | 42 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/10587-ogg-speech-alias.md create mode 100644 tests/unit/audio-speech-ogg-alias-10587.test.ts diff --git a/changelog.d/features/10587-ogg-speech-alias.md b/changelog.d/features/10587-ogg-speech-alias.md new file mode 100644 index 000000000000..118e2a7b4839 --- /dev/null +++ b/changelog.d/features/10587-ogg-speech-alias.md @@ -0,0 +1 @@ +- **feat(providers):** accept `response_format=ogg` on `/v1/audio/speech` as an alias for the existing Opus/Ogg encoder ([#10587](https://github.com/diegosouzapw/OmniRoute/issues/10587)) diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 32ef00311028..9dc499a1e47e 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -229,6 +229,16 @@ async function handleDeepgramSpeech(providerConfig, body, modelId, token) { return audioStreamResponse(res); } +/** + * Voice-note clients send response_format=ogg. OpenAI TTS documents opus, not ogg. + * OmniRoute already returns Ogg/Opus bytes for opus — alias ogg → opus (#10587). + */ +export function normalizeSpeechResponseFormat(fmt) { + if (typeof fmt !== "string" || !fmt) return "mp3"; + const lower = fmt.toLowerCase(); + return lower === "ogg" ? "opus" : lower; +} + /** * Handle Soniox TTS (OpenAI speech shape → Soniox /tts, returns raw audio bytes) */ @@ -963,7 +973,7 @@ export async function handleAudioSpeech({ model: modelId, input: body.input, voice: body.voice || "alloy", - response_format: body.response_format || "mp3", + response_format: normalizeSpeechResponseFormat(body.response_format), speed: body.speed || 1.0, }), }); diff --git a/tests/unit/audio-speech-ogg-alias-10587.test.ts b/tests/unit/audio-speech-ogg-alias-10587.test.ts new file mode 100644 index 000000000000..409406657e62 --- /dev/null +++ b/tests/unit/audio-speech-ogg-alias-10587.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { normalizeSpeechResponseFormat, handleAudioSpeech } = await import( + "../../open-sse/handlers/audioSpeech.ts" +); + +test("normalizeSpeechResponseFormat aliases ogg to opus (#10587)", () => { + assert.equal(normalizeSpeechResponseFormat("ogg"), "opus"); + assert.equal(normalizeSpeechResponseFormat("OGG"), "opus"); + assert.equal(normalizeSpeechResponseFormat("opus"), "opus"); + assert.equal(normalizeSpeechResponseFormat("mp3"), "mp3"); + assert.equal(normalizeSpeechResponseFormat(undefined), "mp3"); +}); + +test("OpenAI-compat speech path remaps ogg to opus before upstream", async () => { + const originalFetch = globalThis.fetch; + let captured; + globalThis.fetch = async (_url, options = {}) => { + captured = JSON.parse(String(options.body || "{}")); + return new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { "content-type": "audio/opus" }, + }); + }; + try { + const response = await handleAudioSpeech({ + body: { + model: "openai/tts-1", + input: "format check", + voice: "alloy", + response_format: "ogg", + }, + credentials: { apiKey: "openai-key" }, + }); + assert.equal(response.status, 200); + assert.equal(captured.response_format, "opus"); + assert.equal(captured.model, "tts-1"); + } finally { + globalThis.fetch = originalFetch; + } +}); From 77d75022d6d9ed50822dae1f34a06ab46da2ec4b Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:47:58 +0200 Subject: [PATCH 11/17] fix(opencode-plugin): keep bare combo ids unprefixed (#10821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- @omniroute/opencode-plugin/src/index.ts | 13 ++++--- .../tests/bare-combo-ids-10345.test.ts | 34 +++++++++++++++++++ .../fixes/10345-bare-combo-opencode-ids.md | 1 + 3 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 @omniroute/opencode-plugin/tests/bare-combo-ids-10345.test.ts create mode 100644 changelog.d/fixes/10345-bare-combo-opencode-ids.md diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index bc18518caddf..7c196aeccbe0 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -1294,10 +1294,15 @@ export function mapRawModelToModelV2( // `(providerID, modelID)`. If the raw id is already provider-prefixed // (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or // `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave - // it as-is — double-prefixing breaks OC's lookup. Otherwise prefix with - // the resolved `providerId` so a bare key like `claude-opus-4` parses as - // `(omniroute, claude-opus-4)` and the credentials resolve correctly. - id: raw.id.includes("/") ? raw.id : `${ctx.providerId}/${raw.id}`, + // it as-is — double-prefixing breaks OC's lookup. Bare **combo** ids + // (`owned_by: "combo"`, e.g. `gpt-5.6-sol`) must also stay unprefixed: + // OpenCode looks up `-m /` as model id `` under + // the plugin provider (#10345). Other bare ids still prefix with + // `providerId` so credentials resolve as `(omniroute, model)`. + id: + raw.id.includes("/") || raw.owned_by === "combo" + ? raw.id + : `${ctx.providerId}/${raw.id}`, /** * Display name. Falls back to raw.id when no enrichment is available; * the caller (`createOmniRouteProviderHook`) overlays diff --git a/@omniroute/opencode-plugin/tests/bare-combo-ids-10345.test.ts b/@omniroute/opencode-plugin/tests/bare-combo-ids-10345.test.ts new file mode 100644 index 000000000000..f7afda9ab63d --- /dev/null +++ b/@omniroute/opencode-plugin/tests/bare-combo-ids-10345.test.ts @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { mapRawModelToModelV2 } from "../src/index.ts"; + +test("mapRawModelToModelV2: bare combo ids stay unprefixed (#10345)", () => { + const combo = mapRawModelToModelV2( + { + id: "gpt-5.6-sol", + owned_by: "combo", + context_length: 272000, + max_output_tokens: 8192, + }, + { providerId: "omniroute", baseURL: "https://or.example.com/v1" } + ); + assert.equal(combo.id, "gpt-5.6-sol"); + assert.equal(combo.providerID, "omniroute"); + + const slashed = mapRawModelToModelV2( + { + id: "cx/gpt-5.6-sol", + owned_by: "combo", + context_length: 272000, + }, + { providerId: "omniroute", baseURL: "https://or.example.com/v1" } + ); + assert.equal(slashed.id, "cx/gpt-5.6-sol"); + + const ordinary = mapRawModelToModelV2( + { id: "claude-primary", context_length: 200000 }, + { providerId: "omniroute", baseURL: "https://or.example.com/v1" } + ); + assert.equal(ordinary.id, "omniroute/claude-primary"); +}); diff --git a/changelog.d/fixes/10345-bare-combo-opencode-ids.md b/changelog.d/fixes/10345-bare-combo-opencode-ids.md new file mode 100644 index 000000000000..c3a6a499ecaf --- /dev/null +++ b/changelog.d/fixes/10345-bare-combo-opencode-ids.md @@ -0,0 +1 @@ +- **fix(opencode-plugin):** publish bare combo model ids without the plugin provider prefix so OpenCode can select them ([#10345](https://github.com/diegosouzapw/OmniRoute/issues/10345)) From 2f7315882b683dde2f7868f18c2daa5f14bc4fd9 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:48:03 +0200 Subject: [PATCH 12/17] fix(auto): log empty auto-family pools once per process (#10820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- .../fixes/10346-empty-pool-warn-once.md | 1 + open-sse/services/autoCombo/virtualFactory.ts | 18 ++++++++---------- tests/unit/auto-empty-pool-warn-once.test.ts | 18 ++++++++++++------ 3 files changed, 21 insertions(+), 16 deletions(-) create mode 100644 changelog.d/fixes/10346-empty-pool-warn-once.md diff --git a/changelog.d/fixes/10346-empty-pool-warn-once.md b/changelog.d/fixes/10346-empty-pool-warn-once.md new file mode 100644 index 000000000000..e4b50ef3ffe0 --- /dev/null +++ b/changelog.d/fixes/10346-empty-pool-warn-once.md @@ -0,0 +1 @@ +- **fix(backend):** log `auto/ matched no connected models` once per process per label instead of every minute ([#10346](https://github.com/diegosouzapw/OmniRoute/issues/10346)) diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index a55a70686b63..f468f23005f3 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -44,21 +44,19 @@ export interface AutoComboSpec { family?: ModelFamily; } -/** Rate-limit empty-pool AUTO warns (same label can be resolved many times/min). */ -const emptyPoolWarnAt = new Map(); -export const EMPTY_POOL_WARN_INTERVAL_MS = 60_000; - -export function warnEmptyAutoPoolOnce(label: string, message: string, now = Date.now()): boolean { - const last = emptyPoolWarnAt.get(label) ?? 0; - if (now - last < EMPTY_POOL_WARN_INTERVAL_MS) return false; - emptyPoolWarnAt.set(label, now); +/** Once-per-process empty-pool AUTO warns (steady empty is not a metronome). */ +const emptyPoolWarned = new Set(); + +export function warnEmptyAutoPoolOnce(label: string, message: string, _now = Date.now()): boolean { + if (emptyPoolWarned.has(label)) return false; + emptyPoolWarned.add(label); log.warn("AUTO", message); return true; } -/** Test-only: reset the debounce map. */ +/** Test-only: reset the once-per-label set (also models emptiness reappearing). */ export function resetEmptyAutoPoolWarnStateForTests(): void { - emptyPoolWarnAt.clear(); + emptyPoolWarned.clear(); } /** Minimal connection shape needed for virtual auto-combo factory */ diff --git a/tests/unit/auto-empty-pool-warn-once.test.ts b/tests/unit/auto-empty-pool-warn-once.test.ts index 016b2e099701..9d2436aad103 100644 --- a/tests/unit/auto-empty-pool-warn-once.test.ts +++ b/tests/unit/auto-empty-pool-warn-once.test.ts @@ -1,18 +1,24 @@ +import test from "node:test"; import assert from "node:assert/strict"; -import { test } from "node:test"; import { - EMPTY_POOL_WARN_INTERVAL_MS, resetEmptyAutoPoolWarnStateForTests, warnEmptyAutoPoolOnce, } from "../../open-sse/services/autoCombo/virtualFactory.ts"; -test("warnEmptyAutoPoolOnce emits at most once per label per interval", () => { +test("warnEmptyAutoPoolOnce emits at most once per label per process", () => { resetEmptyAutoPoolWarnStateForTests(); - const t0 = 1_000_000; + const t0 = 1_700_000_000_000; assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0), true); assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + 1), false); - assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + EMPTY_POOL_WARN_INTERVAL_MS - 1), false); + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + 60_000), false); + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + 3_600_000), false); assert.equal(warnEmptyAutoPoolOnce("auto/other", "empty", t0 + 1), true); - assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + EMPTY_POOL_WARN_INTERVAL_MS), true); +}); + +test("resetEmptyAutoPoolWarnStateForTests allows a later warn (emptiness reappeared)", () => { + resetEmptyAutoPoolWarnStateForTests(); + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty"), true); + resetEmptyAutoPoolWarnStateForTests(); + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty"), true); }); From 56b9d00335f202a32ee16e8a976ea71be69805c8 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:48:07 +0200 Subject: [PATCH 13/17] fix(docker): warn when OMNIROUTE_MEMORY_MB disagrees with NODE_OPTIONS heap (#10818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- .../fixes/10353-memory-heap-conflict-warn.md | 1 + docs/reference/ENVIRONMENT.md | 2 +- scripts/build/runtime-env.mjs | 53 ++++++++++++++ scripts/dev/run-standalone.mjs | 14 ++-- tests/unit/10353-heap-limit-conflict.test.ts | 73 +++++++++++++++++++ 5 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixes/10353-memory-heap-conflict-warn.md create mode 100644 tests/unit/10353-heap-limit-conflict.test.ts diff --git a/changelog.d/fixes/10353-memory-heap-conflict-warn.md b/changelog.d/fixes/10353-memory-heap-conflict-warn.md new file mode 100644 index 000000000000..c52b7cc15c48 --- /dev/null +++ b/changelog.d/fixes/10353-memory-heap-conflict-warn.md @@ -0,0 +1 @@ +- **fix(docker):** warn at boot when `OMNIROUTE_MEMORY_MB` disagrees with `NODE_OPTIONS --max-old-space-size`, and document that the standalone/Docker launcher appends `OMNIROUTE_MEMORY_MB` last ([#10353](https://github.com/diegosouzapw/OmniRoute/issues/10353)) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b35d7c97d9ec..73a0c8a08db8 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -843,7 +843,7 @@ The logging system writes to both stdout and rotated log files. All configuratio | Variable | Default | Description | | -------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `OMNIROUTE_MEMORY_MB` | _auto_ | Runtime V8 heap limit (MB). When unset, calibrated dynamically (~35% of system RAM, clamped to `[512, 4096]`); `512` is only the floor when total memory can't be read. Set explicitly to override. Docker standalone and `omniroute serve` use it to set `--max-old-space-size`. | +| `OMNIROUTE_MEMORY_MB` | _auto_ | **Recommended** Docker/standalone V8 heap limit (MB). When unset, calibrated dynamically (~35% of system RAM, clamped to `[512, 4096]`); `512` is only the floor when total memory can't be read. On `run-standalone.mjs` (Docker CMD), an **explicit** value is appended as `--max-old-space-size` and **wins** over a conflicting NODE_OPTIONS heap flag (V8 last-flag). `omniroute serve` still prefers an existing NODE_OPTIONS heap (#5238). Do not set both to different numbers — the process logs a warn naming both values and the winner. | | `PROMPT_CACHE_MAX_SIZE` | `50` | Max cached system prompt entries. | | `PROMPT_CACHE_MAX_BYTES` | `2097152` (2 MB) | Max total prompt cache size. | | `PROMPT_CACHE_TTL_MS` | `300000` (5 min) | Prompt cache entry TTL. | diff --git a/scripts/build/runtime-env.mjs b/scripts/build/runtime-env.mjs index d8dbe4576543..e4eec02ed1a8 100644 --- a/scripts/build/runtime-env.mjs +++ b/scripts/build/runtime-env.mjs @@ -49,6 +49,59 @@ export function envHasExplicitHeapFlag(env) { return String(sourceEnv?.NODE_OPTIONS || "").includes(MAX_OLD_SPACE_FLAG); } +/** Last `--max-old-space-size=` value in NODE_OPTIONS, or null if absent. */ +export function parseNodeOptionsHeapMb(nodeOptions) { + const matches = [...String(nodeOptions || "").matchAll(/--max-old-space-size=(\d+)/g)]; + if (matches.length === 0) return null; + const parsed = Number.parseInt(matches[matches.length - 1][1], 10); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * True when OMNIROUTE_MEMORY_MB is an explicit in-range integer (not the + * unset/invalid fallback). Docker images set this; Compose may also set + * NODE_OPTIONS — #10353 needs to know both knobs were intentionally present. + */ +export function envHasExplicitOmnirouteMemoryMb(env) { + const sourceEnv = arguments.length === 0 ? process.env : env; + const parsed = Number.parseInt(String(sourceEnv?.OMNIROUTE_MEMORY_MB ?? ""), 10); + return Number.isFinite(parsed) && parsed >= 64 && parsed <= 16384; +} + +/** + * Docker `run-standalone.mjs` appends `--max-old-space-size` from + * OMNIROUTE_MEMORY_MB. V8 last-flag semantics mean that appended value wins + * over an earlier NODE_OPTIONS heap. Warn once when both are set and disagree + * so env dumps stop looking like NODE_OPTIONS is in effect (#10353). + * + * @returns {boolean} true when a warn was emitted + */ +export function warnConflictingHeapLimits(env, omnirouteMb, log = console.warn) { + const nodeMb = parseNodeOptionsHeapMb(env?.NODE_OPTIONS); + if (nodeMb == null || !envHasExplicitOmnirouteMemoryMb(env)) return false; + if (nodeMb === omnirouteMb) return false; + log( + `[omniroute] heap limit conflict: OMNIROUTE_MEMORY_MB=${omnirouteMb} disagrees with NODE_OPTIONS --max-old-space-size=${nodeMb}. ` + + `run-standalone.mjs / Docker appends OMNIROUTE_MEMORY_MB last, so the effective V8 heap is ${omnirouteMb} MB. ` + + `Set only OMNIROUTE_MEMORY_MB (recommended) or make both values match.` + ); + return true; +} + +/** + * NODE_OPTIONS string for Docker / run-standalone.mjs. + * Explicit OMNIROUTE_MEMORY_MB always appends (wins). Otherwise keep an + * existing NODE_OPTIONS heap flag (#5238). Otherwise append the fallback. + */ +export function buildStandaloneNodeOptions(env = process.env, omnirouteMb) { + const existing = String(env?.NODE_OPTIONS || "").trim(); + if (envHasExplicitOmnirouteMemoryMb(env)) { + return `${existing} ${MAX_OLD_SPACE_FLAG}=${omnirouteMb}`.trim(); + } + if (existing.includes(MAX_OLD_SPACE_FLAG)) return existing; + return `${existing} ${MAX_OLD_SPACE_FLAG}=${omnirouteMb}`.trim(); +} + /** * Assemble the NODE_OPTIONS string for the spawned server, preserving any flags * the user already exported. #5238: `omniroute serve` used to UNCONDITIONALLY diff --git a/scripts/dev/run-standalone.mjs b/scripts/dev/run-standalone.mjs index 531322da77ec..0f26e804adb0 100644 --- a/scripts/dev/run-standalone.mjs +++ b/scripts/dev/run-standalone.mjs @@ -5,6 +5,8 @@ import { resolveRuntimePorts, withRuntimePortEnv, resolveMaxOldSpaceMb, + warnConflictingHeapLimits, + buildStandaloneNodeOptions, spawnWithForwardedSignals, } from "../build/runtime-env.mjs"; import { bootstrapEnv } from "../build/bootstrap-env.mjs"; @@ -13,13 +15,13 @@ const env = bootstrapEnv(); const runtimePorts = resolveRuntimePorts(env); const childEnv = withRuntimePortEnv(env, runtimePorts); -// #2939: honor OMNIROUTE_MEMORY_MB (default 512), the same knob -// `omniroute serve` uses, so Docker users can control the server heap under -// load / large SQLite DBs. A trailing --max-old-space-size wins, so this -// overrides the image fallback without clobbering any other NODE_OPTIONS flags. +// #2939 / #10353: OMNIROUTE_MEMORY_MB is the Docker/standalone heap knob. +// When it is set, we append --max-old-space-size last (V8 last-flag wins). +// When it is unset and NODE_OPTIONS already pins the heap, keep NODE_OPTIONS +// (#5238). Warn when both are set and the numbers disagree. const maxOldSpaceMb = resolveMaxOldSpaceMb(childEnv.OMNIROUTE_MEMORY_MB); -childEnv.NODE_OPTIONS = - `${childEnv.NODE_OPTIONS || ""} --max-old-space-size=${maxOldSpaceMb}`.trim(); +warnConflictingHeapLimits(childEnv, maxOldSpaceMb); +childEnv.NODE_OPTIONS = buildStandaloneNodeOptions(childEnv, maxOldSpaceMb); // Prefer the WS-aware wrapper (server-ws.mjs) over the bare Next standalone // server.js: it installs the trusted peer-IP stamp (scripts/dev/peer-stamp.mjs) diff --git a/tests/unit/10353-heap-limit-conflict.test.ts b/tests/unit/10353-heap-limit-conflict.test.ts new file mode 100644 index 000000000000..c1c7a1d7c3a5 --- /dev/null +++ b/tests/unit/10353-heap-limit-conflict.test.ts @@ -0,0 +1,73 @@ +/** + * #10353 — warn when OMNIROUTE_MEMORY_MB disagrees with NODE_OPTIONS heap. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + parseNodeOptionsHeapMb, + envHasExplicitOmnirouteMemoryMb, + warnConflictingHeapLimits, + buildStandaloneNodeOptions, +} = await import("../../scripts/build/runtime-env.mjs"); + +test("parseNodeOptionsHeapMb reads the last heap flag", () => { + assert.equal(parseNodeOptionsHeapMb(""), null); + assert.equal(parseNodeOptionsHeapMb("--enable-source-maps"), null); + assert.equal(parseNodeOptionsHeapMb("--max-old-space-size=512"), 512); + assert.equal( + parseNodeOptionsHeapMb("--max-old-space-size=512 --max-old-space-size=2048"), + 2048 + ); +}); + +test("envHasExplicitOmnirouteMemoryMb requires an in-range integer", () => { + assert.equal(envHasExplicitOmnirouteMemoryMb({}), false); + assert.equal(envHasExplicitOmnirouteMemoryMb({ OMNIROUTE_MEMORY_MB: "" }), false); + assert.equal(envHasExplicitOmnirouteMemoryMb({ OMNIROUTE_MEMORY_MB: "abc" }), false); + assert.equal(envHasExplicitOmnirouteMemoryMb({ OMNIROUTE_MEMORY_MB: "32" }), false); + assert.equal(envHasExplicitOmnirouteMemoryMb({ OMNIROUTE_MEMORY_MB: "2048" }), true); +}); + +test("#10353 dual-set disagree → warn + OMNIROUTE_MEMORY_MB wins", () => { + const messages: string[] = []; + const env = { + NODE_OPTIONS: "--max-old-space-size=512", + OMNIROUTE_MEMORY_MB: "2048", + }; + assert.equal(warnConflictingHeapLimits(env, 2048, (m: string) => messages.push(m)), true); + assert.match(messages[0], /OMNIROUTE_MEMORY_MB=2048/); + assert.match(messages[0], /--max-old-space-size=512/); + assert.match(messages[0], /effective V8 heap is 2048 MB/); + assert.equal( + buildStandaloneNodeOptions(env, 2048), + "--max-old-space-size=512 --max-old-space-size=2048" + ); +}); + +test("#10353 only one knob set → no conflict warn", () => { + const messages: string[] = []; + const log = (m: string) => messages.push(m); + assert.equal( + warnConflictingHeapLimits({ NODE_OPTIONS: "--max-old-space-size=512" }, 512, log), + false + ); + assert.equal( + warnConflictingHeapLimits({ OMNIROUTE_MEMORY_MB: "2048" }, 2048, log), + false + ); + assert.equal( + warnConflictingHeapLimits( + { NODE_OPTIONS: "--max-old-space-size=1024", OMNIROUTE_MEMORY_MB: "1024" }, + 1024, + log + ), + false + ); + assert.equal(messages.length, 0); +}); + +test("#10353 unset OMNIROUTE_MEMORY_MB keeps NODE_OPTIONS heap", () => { + const env = { NODE_OPTIONS: "--max-old-space-size=8192" }; + assert.equal(buildStandaloneNodeOptions(env, 512), "--max-old-space-size=8192"); +}); From 7c6bf321861be00f7afb414c16ea6815f03e884d Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:48:10 +0200 Subject: [PATCH 14/17] docs(docker): document SQLite single-replica HA limits (#10817) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- .../10350-sqlite-single-replica-ha.md | 1 + docs/guides/DOCKER_GUIDE.md | 23 +++++++++++++++++++ docs/ops/SQLITE_RUNTIME.md | 14 +++++++++++ 3 files changed, 38 insertions(+) create mode 100644 changelog.d/maintenance/10350-sqlite-single-replica-ha.md diff --git a/changelog.d/maintenance/10350-sqlite-single-replica-ha.md b/changelog.d/maintenance/10350-sqlite-single-replica-ha.md new file mode 100644 index 000000000000..9b158d5787b3 --- /dev/null +++ b/changelog.d/maintenance/10350-sqlite-single-replica-ha.md @@ -0,0 +1 @@ +- **docs(docker):** document default SQLite as single-replica / HA-unsupported, including Recreate and HEALTHCHECK session blast radius ([#10350](https://github.com/diegosouzapw/OmniRoute/issues/10350)) diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 464aa5531931..fde782dbf9c1 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -22,6 +22,7 @@ lastUpdated: 2026-06-28 - [Docker Compose with Caddy (HTTPS)](#docker-compose-with-caddy-https-auto-tls) - [Cloudflare Quick Tunnel](#cloudflare-quick-tunnel) - [Image Tags](#image-tags) +- [Availability: default SQLite is single-replica](#availability-default-sqlite-is-single-replica) - [Important Notes](#important-notes) --- @@ -465,6 +466,28 @@ docker compose up -d A release-branch build can never move `latest`; only an eligible stable semantic version may promote the stable pointer. The `next` images retain the release image inspection and blocking CRITICAL-vulnerability gate. +## Availability: default SQLite is single-replica + +Stock Docker / Kubernetes OmniRoute is **one Node process + one SQLite writer**. High availability is **not supported** on that topology. + +| Constraint | Consequence | +| --- | --- | +| Single writer | Do **not** run multiple replicas against the same SQLite file. That corrupts the DB. | +| Recreate / restart / HEALTHCHECK kill | **Full outage** of in-flight SSE, dashboard sessions, and in-memory state. Every connected client drops. | +| Same event loop as `/healthz` | A busy catalog or compression tick can delay probes; a short timeout then restarts the **only** replica. | + +**Probe matrix** (see also [Kubernetes probe recommendations](../ops/MONITORING_GUIDE.md#kubernetes-probe-recommendations)): + +| Probe | Target | Do not use | +| --- | --- | --- | +| Liveness | TCP on `PORT` (default `20128`), or soft HTTP `/healthz` | `/api/monitoring/health` | +| Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead | +| Deep / humans | `/api/monitoring/health` | Automated kubelet liveness | + +**Upgrades:** expect every session to drop. Drain clients if you can; there is no rolling update on default SQLite. Compose `restart: unless-stopped` plus Docker `HEALTHCHECK` will also replace the only process when the container is Unhealthy — same blast radius. + +External Postgres / multi-writer HA is **not** a documented stock path. If you need HA, keep a single replica or run a topology the project has tested and documented separately. + ## Important Notes - **SQLite WAL Mode:** `docker stop` should be allowed to finish so OmniRoute can checkpoint the latest changes back into `storage.sqlite`. The bundled Compose files already set a 40s stop grace period. If you run the image directly, keep `--stop-timeout 40`. diff --git a/docs/ops/SQLITE_RUNTIME.md b/docs/ops/SQLITE_RUNTIME.md index e2e6a412da8a..ab31996fb201 100644 --- a/docs/ops/SQLITE_RUNTIME.md +++ b/docs/ops/SQLITE_RUNTIME.md @@ -80,3 +80,17 @@ Implementation: - `bin/cli/runtime/index.mjs` — startup orchestrator (`warmUpRuntimes()`) - `scripts/postinstall.mjs` — npm post-install hook (non-fatal warm-up) - `src/lib/db/core.ts` — `ensureDbInitialized()` / `getDriverInfo()` exports + +## Single-writer topology (HA unsupported) + +The driver fallback chain above still runs in **one process**. Default SQLite +OmniRoute is a **single writer**: + +- Do not attach two OmniRoute replicas to the same `storage.sqlite` file. +- A container restart, Recreate deploy, OOM kill, or HEALTHCHECK restart drops + every in-flight SSE session. There is no session drain on the stock path. +- Orchestrator liveness that treats a slow `/healthz` as dead will kill the only + replica. Prefer TCP liveness + HTTP `/healthz` readiness. See + [Docker Guide — availability](../guides/DOCKER_GUIDE.md#availability-default-sqlite-is-single-replica) + and [Kubernetes probe recommendations](./MONITORING_GUIDE.md#kubernetes-probe-recommendations). + From 9eddafff60c555cecc25f907b2f1accbddb7fe90 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:48:14 +0200 Subject: [PATCH 15/17] fix(admission): reserve Responses and Messages bodies before clone (#10814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- src/app/api/v1/antigravity/route.ts | 5 +- src/app/api/v1/api/chat/route.ts | 5 +- src/app/api/v1/completions/route.ts | 5 +- src/app/api/v1/messages/route.ts | 3 +- .../[provider]/chat/completions/route.ts | 5 +- .../api/v1/relay/chat/completions/route.ts | 5 +- src/app/api/v1/responses/[...path]/route.ts | 5 +- src/shared/middleware/withChatAdmission.ts | 48 ++++++++++ tests/unit/with-chat-admission-10786.test.ts | 94 +++++++++++++++++++ 9 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 src/shared/middleware/withChatAdmission.ts create mode 100644 tests/unit/with-chat-admission-10786.test.ts diff --git a/src/app/api/v1/antigravity/route.ts b/src/app/api/v1/antigravity/route.ts index 89f12036ae5e..aba16415ee38 100644 --- a/src/app/api/v1/antigravity/route.ts +++ b/src/app/api/v1/antigravity/route.ts @@ -1,5 +1,6 @@ import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; let initialized = false; @@ -41,7 +42,9 @@ export async function OPTIONS() { * already-registered bidirectional translators. The AgentBridge MITM proxy * (`server.cjs`) forwards the IDE's intercepted cloudcode request here. */ -export async function POST(request: Request): Promise { +async function postHandler(request: Request): Promise { await ensureInitialized(); return await handleChat(request); } + +export const POST = withChatAdmission(postHandler); diff --git a/src/app/api/v1/api/chat/route.ts b/src/app/api/v1/api/chat/route.ts index c5319111214d..d07a7509a02c 100644 --- a/src/app/api/v1/api/chat/route.ts +++ b/src/app/api/v1/api/chat/route.ts @@ -1,6 +1,7 @@ import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { transformToOllama } from "@omniroute/open-sse/utils/ollamaTransform.ts"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; let initialized = false; @@ -21,7 +22,7 @@ export async function OPTIONS() { }); } -export async function POST(request) { +async function postHandler(request) { await ensureInitialized(); const clonedReq = request.clone(); @@ -34,3 +35,5 @@ export async function POST(request) { const response = await handleChat(request); return transformToOllama(response, modelName); } + +export const POST = withChatAdmission(postHandler); diff --git a/src/app/api/v1/completions/route.ts b/src/app/api/v1/completions/route.ts index f11f7cb90489..c2106271f195 100644 --- a/src/app/api/v1/completions/route.ts +++ b/src/app/api/v1/completions/route.ts @@ -7,6 +7,7 @@ import { readCompressionRequestHeader, withCompressionHeaderEcho, } from "@/shared/utils/compressionHeaderEcho"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; let initPromise = null; const injectionGuard = createInjectionGuard(); @@ -41,7 +42,7 @@ export async function OPTIONS() { * * @see https://platform.openai.com/docs/api-reference/completions */ -export async function POST(request: Request) { +async function postHandler(request: Request) { await ensureInitialized(); // #6422 — capture the compression request header once so we can echo it back @@ -122,3 +123,5 @@ export async function POST(request: Request) { compressionRequestHeader ); } + +export const POST = withChatAdmission(postHandler); diff --git a/src/app/api/v1/messages/route.ts b/src/app/api/v1/messages/route.ts index cbbc87f2021e..97f6af1fb719 100644 --- a/src/app/api/v1/messages/route.ts +++ b/src/app/api/v1/messages/route.ts @@ -1,6 +1,7 @@ import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { withInjectionGuard } from "@/middleware/promptInjectionGuard"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; import { requireJsonContentType } from "@/shared/middleware/requireJsonContentType"; import { withEarlyStreamKeepalive, @@ -78,4 +79,4 @@ async function postHandler(request: any, context: any, preParsedBody: any = null return await handleChat(request, null, body); } -export const POST = withInjectionGuard(postHandler); +export const POST = withChatAdmission(withInjectionGuard(postHandler)); diff --git a/src/app/api/v1/providers/[provider]/chat/completions/route.ts b/src/app/api/v1/providers/[provider]/chat/completions/route.ts index f064163b9ed5..1149ef893324 100644 --- a/src/app/api/v1/providers/[provider]/chat/completions/route.ts +++ b/src/app/api/v1/providers/[provider]/chat/completions/route.ts @@ -4,6 +4,7 @@ import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; let initialized = false; @@ -31,7 +32,7 @@ export async function OPTIONS() { * Routes to the specified provider, validating model/provider match. * Full body format validation is delegated to handleChat. */ -export async function POST(request, { params }) { +async function postHandler(request, { params }) { const { provider: rawProvider } = await params; const providerEntry = getRegistryEntry(rawProvider); @@ -103,3 +104,5 @@ export async function POST(request, { params }) { return await handleChat(newRequest, () => buildClientRawRequest(request, rawBody)); } + +export const POST = withChatAdmission(postHandler); diff --git a/src/app/api/v1/relay/chat/completions/route.ts b/src/app/api/v1/relay/chat/completions/route.ts index 12ff30bea4a7..28cc5160db88 100644 --- a/src/app/api/v1/relay/chat/completions/route.ts +++ b/src/app/api/v1/relay/chat/completions/route.ts @@ -8,6 +8,7 @@ import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; import { handleChat } from "@/sse/handlers/chat"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; import { getRelayTokenByHash, checkRateLimit, recordRelayUsage } from "@/lib/db/relayProxies"; import { @@ -199,7 +200,7 @@ export async function OPTIONS() { return handleCorsOptions(); } -export async function POST(request: Request) { +async function postHandler(request: Request) { const startTime = Date.now(); const clientIp = getClientIp(request); const userAgent = sanitizeForensicHeader(request.headers.get("user-agent")); @@ -433,3 +434,5 @@ export async function POST(request: Request) { }); } } + +export const POST = withChatAdmission(postHandler); diff --git a/src/app/api/v1/responses/[...path]/route.ts b/src/app/api/v1/responses/[...path]/route.ts index e2f7062f5adb..12eb4e05b369 100644 --- a/src/app/api/v1/responses/[...path]/route.ts +++ b/src/app/api/v1/responses/[...path]/route.ts @@ -1,5 +1,6 @@ import { handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; +import { withChatAdmission } from "@/shared/middleware/withChatAdmission"; let initialized = false; @@ -25,7 +26,9 @@ export async function OPTIONS() { * Reuses the shared chat handler so native Codex passthrough can keep * arbitrary Responses suffixes all the way to the upstream provider. */ -export async function POST(request) { +async function postHandler(request) { await ensureInitialized(); return await handleChat(request); } + +export const POST = withChatAdmission(postHandler); diff --git a/src/shared/middleware/withChatAdmission.ts b/src/shared/middleware/withChatAdmission.ts new file mode 100644 index 000000000000..7a762b6f9ea1 --- /dev/null +++ b/src/shared/middleware/withChatAdmission.ts @@ -0,0 +1,48 @@ +/** + * Compose process-wide chat admission in front of a route handler. + * + * Uses the shipped `admitChatRequest` budget/fairness controller — it does not + * introduce a second admission path. Call this *outside* `withInjectionGuard` + * so a large `/v1/responses` or `/v1/messages` body is reserved (or 503-shed) + * before `request.clone()` / `.json()`. + */ +import { + admitChatRequest, + CHAT_ADMISSION_QUEUE_MAX_MS, + releaseChatAdmissionAfterHandler, + resolveSessionId, + type ChatAdmissionController, +} from "./chatBodyAdmission"; + +type RouteHandler = (request: Request, ...args: any[]) => Promise | Response; + +export function withChatAdmission( + handler: RouteHandler, + options: { + controller?: ChatAdmissionController; + queueMs?: number; + largeBodyBytes?: number; + hardMaxBytes?: number; + } = {} +): RouteHandler { + return async function admittedHandler(request: Request, ...args: any[]) { + const sessionId = resolveSessionId(request); + const admission = await admitChatRequest(request, { + sessionId, + queueMs: options.queueMs ?? CHAT_ADMISSION_QUEUE_MAX_MS, + controller: options.controller, + largeBodyBytes: options.largeBodyBytes, + hardMaxBytes: options.hardMaxBytes, + }); + if (admission.admit === false) return admission.response; + try { + return await releaseChatAdmissionAfterHandler( + Promise.resolve(handler(admission.request, ...args)), + admission.lease + ); + } catch (error) { + admission.lease?.release(); + throw error; + } + }; +} diff --git a/tests/unit/with-chat-admission-10786.test.ts b/tests/unit/with-chat-admission-10786.test.ts new file mode 100644 index 000000000000..409d1c8a8e31 --- /dev/null +++ b/tests/unit/with-chat-admission-10786.test.ts @@ -0,0 +1,94 @@ +// #10786: process-wide admitChatRequest must run before Responses/Messages clone/parse. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { ChatAdmissionController, admitChatRequest } = await import( + "../../src/shared/middleware/chatBodyAdmission.ts" +); +const { withChatAdmission } = await import("../../src/shared/middleware/withChatAdmission.ts"); + +function largeBody(n = 64): string { + return JSON.stringify({ messages: [{ role: "user", content: "x".repeat(n) }] }); +} + +function chatRequest(url: string, body: string): Request { + return new Request(url, { + method: "POST", + headers: { "content-type": "application/json", "content-length": String(body.length) }, + body, + }); +} + +test("withChatAdmission does not invoke the handler when a second large body is shed", async () => { + const controller = new ChatAdmissionController(1); + const options = { controller, largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 0 }; + const body = largeBody(); + + const first = await admitChatRequest(chatRequest("http://x/v1/responses", body), options); + assert.equal(first.admit, true); + + let called = false; + const wrapped = withChatAdmission(async () => { + called = true; + return new Response("ok"); + }, options); + + const res = await wrapped(chatRequest("http://x/v1/responses", body)); + assert.equal(called, false); + assert.equal(res.status, 503); + assert.equal(res.headers.get("Retry-After"), "2"); + const json = await res.json(); + assert.equal(json.error.code, "chat_admission_busy"); + first.lease?.release(); +}); + +test("withChatAdmission invokes the handler and forwards the admitted request", async () => { + const controller = new ChatAdmissionController(1); + const options = { controller, largeBodyBytes: 32, hardMaxBytes: 1024, queueMs: 0 }; + const body = largeBody(); + let seen: Request | null = null; + const wrapped = withChatAdmission(async (request: Request) => { + seen = request; + return new Response("ok", { status: 200 }); + }, options); + const res = await wrapped(chatRequest("http://x/v1/messages", body)); + assert.ok(seen); + assert.equal(res.status, 200); + assert.equal(await seen.text(), body); +}); + +test("responses admits inline before json; messages wrap withChatAdmission before withInjectionGuard", async () => { + const { readFileSync } = await import("node:fs"); + const responses = readFileSync(new URL("../../src/app/api/v1/responses/route.ts", import.meta.url), "utf8"); + const messages = readFileSync(new URL("../../src/app/api/v1/messages/route.ts", import.meta.url), "utf8"); + const catchAll = readFileSync( + new URL("../../src/app/api/v1/responses/[...path]/route.ts", import.meta.url), + "utf8" + ); + // Keepalive #10806 inlined admitChatRequest into /v1/responses. Wrapping + // withChatAdmission on top would double-admit. Body is reserved before json(). + const admitAt = responses.indexOf("await admitChatRequest(request"); + const jsonAt = responses.indexOf("parsedBody = await request.json()"); + assert.ok(admitAt >= 0, "responses route must call admitChatRequest"); + assert.ok(jsonAt > admitAt, "admitChatRequest must run before request.json()"); + assert.doesNotMatch(responses, /withChatAdmission/); + assert.match(messages, /withChatAdmission\(\s*withInjectionGuard\(postHandler\)\s*\)/); + assert.match(catchAll, /export const POST = withChatAdmission\(postHandler\)/); + assert.doesNotMatch(catchAll, /export async function POST/); +}); + +test("remaining handleChat aliases wrap POST with withChatAdmission (#10790)", async () => { + const { readFileSync } = await import("node:fs"); + const files = [ + "src/app/api/v1/antigravity/route.ts", + "src/app/api/v1/api/chat/route.ts", + "src/app/api/v1/completions/route.ts", + "src/app/api/v1/providers/[provider]/chat/completions/route.ts", + "src/app/api/v1/relay/chat/completions/route.ts", + ]; + for (const rel of files) { + const src = readFileSync(new URL("../../" + rel, import.meta.url), "utf8"); + assert.match(src, /withChatAdmission/, rel); + assert.match(src, /export const POST = withChatAdmission\(postHandler\)/, rel); + } +}); From 9d2240eab7c28149181b9c058064bcfe57507fe4 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:48:18 +0200 Subject: [PATCH 16/17] fix(search): name /v1/search 502 provider and cause (#10756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — validated together with a batch of related RaviTharuma PRs in one combined worktree (typecheck:core clean, complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution! --- .../10735-search-provider-named-errors.md | 1 + open-sse/handlers/search.ts | 7 +- open-sse/handlers/search/providerFailure.ts | 26 +++++++ open-sse/handlers/search/searchProxy.ts | 12 ++- .../unit/search-provider-named-errors.test.ts | 73 +++++++++++++++++++ 5 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 changelog.d/fixes/10735-search-provider-named-errors.md create mode 100644 open-sse/handlers/search/providerFailure.ts create mode 100644 tests/unit/search-provider-named-errors.test.ts diff --git a/changelog.d/fixes/10735-search-provider-named-errors.md b/changelog.d/fixes/10735-search-provider-named-errors.md new file mode 100644 index 000000000000..0e82f36aa87b --- /dev/null +++ b/changelog.d/fixes/10735-search-provider-named-errors.md @@ -0,0 +1 @@ +- **fix(search):** name `/v1/search` 502s with provider id and sanitized Node cause code, without hostnames ([#10735](https://github.com/diegosouzapw/OmniRoute/issues/10735)) diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index 42974dc8fa9a..ca6221538a20 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -31,6 +31,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { z } from "zod"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { resolveSearchProxy, executeProviderFetch } from "./search/searchProxy.ts"; +import { formatSearchProviderFailure } from "./search/providerFailure.ts"; export interface SearchResult { title: string; @@ -1177,11 +1178,7 @@ async function tryZaiMCPProvider( /* non-critical — logging must not block search response */ }); - return { - success: false, - status: isTimeout ? 504 : 502, - error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(err.message)}`, - }; + return formatSearchProviderFailure(config.id, err, isTimeout); } } diff --git a/open-sse/handlers/search/providerFailure.ts b/open-sse/handlers/search/providerFailure.ts new file mode 100644 index 000000000000..e021c2fa80b7 --- /dev/null +++ b/open-sse/handlers/search/providerFailure.ts @@ -0,0 +1,26 @@ +import { sanitizeErrorMessage } from "../../utils/error.ts"; + +export interface SearchProviderFailure { + success: false; + status: number; + error: string; +} + +/** Named 502/504 for /v1/search — provider id + sanitized cause, no hostnames/URLs. */ +export function formatSearchProviderFailure( + providerId: string, + err: unknown, + isTimeout: boolean +): SearchProviderFailure { + const rec = err && typeof err === "object" ? (err as Record) : {}; + const cause = rec.cause && typeof rec.cause === "object" ? (rec.cause as Record) : {}; + const code = + typeof cause.code === "string" && /^[A-Z][A-Z0-9_]{1,39}$/.test(cause.code) ? cause.code : ""; + const msg = + sanitizeErrorMessage(typeof rec.message === "string" ? rec.message : "fetch failed") || "fetch failed"; + return { + success: false, + status: isTimeout ? 504 : 502, + error: `Search provider ${providerId} ${isTimeout ? "timeout" : "error"}: ${code ? `${msg} (cause: ${code})` : msg}`, + }; +} diff --git a/open-sse/handlers/search/searchProxy.ts b/open-sse/handlers/search/searchProxy.ts index f134b4a3bdc2..75faea4db7ec 100644 --- a/open-sse/handlers/search/searchProxy.ts +++ b/open-sse/handlers/search/searchProxy.ts @@ -10,6 +10,7 @@ import { saveCallLog } from "@/lib/usageDb"; import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { formatSearchProviderFailure } from "./providerFailure.ts"; import type { SearchProviderConfig } from "../../config/searchRegistry.ts"; import type { SearchResult } from "../search.ts"; @@ -231,15 +232,12 @@ export async function executeProviderFetch(p: ExecuteProviderFetchParams): Promi clearTimeout(timer); const error = err instanceof Error ? err : new Error(String(err)); const isTimeout = error.name === "AbortError"; + const safeMsg = sanitizeErrorMessage(error.message) || "fetch failed"; if (log) { - log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${error.message}`); + log.error("SEARCH", `${config.id} ${isTimeout ? "timeout" : "fetch error"}: ${safeMsg}`); } - logCall({ status: isTimeout ? 504 : 502, duration: Date.now() - startTime, error: error.message }); + logCall({ status: isTimeout ? 504 : 502, duration: Date.now() - startTime, error: safeMsg }); await emitEvent(isTimeout ? "timeout" : "error"); - return { - success: false, - status: isTimeout ? 504 : 502, - error: `Search provider ${isTimeout ? "timeout" : "error"}: ${sanitizeErrorMessage(error.message)}`, - }; + return formatSearchProviderFailure(config.id, error, isTimeout); } } diff --git a/tests/unit/search-provider-named-errors.test.ts b/tests/unit/search-provider-named-errors.test.ts new file mode 100644 index 000000000000..53fb5af62478 --- /dev/null +++ b/tests/unit/search-provider-named-errors.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-search-named-errors-")); + +const { handleSearch } = await import("../../open-sse/handlers/search.ts"); +const { formatSearchProviderFailure } = await import( + "../../open-sse/handlers/search/providerFailure.ts" +); + +test("formatSearchProviderFailure names the provider and sanitized Node cause", () => { + const err = new TypeError("fetch failed"); + (err as Error & { cause?: { code: string; address: string } }).cause = { + code: "ENETUNREACH", + address: "203.0.113.10", + }; + + const result = formatSearchProviderFailure("serper-search", err, false); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.equal( + result.error, + "Search provider serper-search error: fetch failed (cause: ENETUNREACH)" + ); + assert.equal(result.error.includes("203.0.113"), false); +}); + +test("formatSearchProviderFailure omits non-Node cause codes", () => { + const err = new TypeError("fetch failed"); + (err as Error & { cause?: { code: string } }).cause = { code: "not-a-node-code" }; + + const result = formatSearchProviderFailure("brave-search", err, false); + + assert.equal(result.status, 502); + assert.equal(result.error, "Search provider brave-search error: fetch failed"); +}); + +test("handleSearch names the provider and sanitized cause on fetch failed 502", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + const err = new TypeError("fetch failed"); + (err as Error & { cause?: { code: string; address: string } }).cause = { + code: "ENETUNREACH", + address: "203.0.113.10", + }; + throw err; + }; + + try { + const result = await handleSearch({ + query: "named provider 502", + provider: "serper-search", + maxResults: 5, + searchType: "web", + credentials: { apiKey: "test-key" }, + log: null, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 502); + assert.equal( + result.error, + "Search provider serper-search error: fetch failed (cause: ENETUNREACH)" + ); + assert.equal(String(result.error).includes("203.0.113"), false); + } finally { + globalThis.fetch = originalFetch; + } +}); From 2acafd9c9edee36ffb7507f30d4726659a2d5902 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:49:51 +0200 Subject: [PATCH 17/17] feat(api): add GET /livez as a process-alive probe (#10819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged — locally validated (3/3 focused tests, changelog gate green) after resolving base-drift against #10827's event-loop-lag doc note (both landed today, same MONITORING_GUIDE.md table cell — combined the /livez recommendation with the #10303 lag caveat). Thanks! --- changelog.d/features/10316-livez-endpoint.md | 1 + docs/guides/DOCKER_GUIDE.md | 10 ++-- docs/ops/MONITORING_GUIDE.md | 13 +++-- src/app/livez/route.ts | 27 ++++++++++ tests/unit/livez-route.test.ts | 55 ++++++++++++++++++++ 5 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 changelog.d/features/10316-livez-endpoint.md create mode 100644 src/app/livez/route.ts create mode 100644 tests/unit/livez-route.test.ts diff --git a/changelog.d/features/10316-livez-endpoint.md b/changelog.d/features/10316-livez-endpoint.md new file mode 100644 index 000000000000..01409d7b48b0 --- /dev/null +++ b/changelog.d/features/10316-livez-endpoint.md @@ -0,0 +1 @@ +- **feat(docker):** add `GET`/`HEAD` `/livez` as a process-alive probe, distinct from `/healthz` readiness ([#10316](https://github.com/diegosouzapw/OmniRoute/issues/10316)) diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index fde782dbf9c1..e1b639bcf8ba 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -342,13 +342,15 @@ For orchestrators (Kubernetes, Nomad, etc.): | Probe | Prefer | Avoid | | --- | --- | --- | -| Liveness | TCP on the main port (`PORT`, default `20128`), or soft HTTP `/healthz` | `/api/monitoring/health` as liveness | +| Liveness | HTTP `GET /livez`, or TCP on the main port (`PORT`, default `20128`) | `/api/monitoring/health` as liveness | | Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead | | Deep / blackbox | `/api/monitoring/health` | — | -`/healthz` only reports process lifecycle (`ok` / `starting` / `stopping`). It still -runs on the same Node event loop as request handling, so CPU-bound catalog or -compression work can delay it — busy ≠ dead. Full probe guidance: +`/healthz` reports process lifecycle (`ok` / `starting` / `stopping`). `/livez` is +process-alive only (200 whenever the handler can run; it does not wait for +readiness). Both still run on the same Node event loop as request handling, so +CPU-bound catalog or compression work can delay them — busy ≠ dead. Prefer TCP +liveness if HTTP probes time out. Full probe guidance: [Monitoring guide — Kubernetes probe recommendations](../ops/MONITORING_GUIDE.md#kubernetes-probe-recommendations). ## Docker Compose with Caddy (HTTPS Auto-TLS) diff --git a/docs/ops/MONITORING_GUIDE.md b/docs/ops/MONITORING_GUIDE.md index da1dc7d39bbd..6543b95f064c 100644 --- a/docs/ops/MONITORING_GUIDE.md +++ b/docs/ops/MONITORING_GUIDE.md @@ -157,13 +157,13 @@ Response: ### Kubernetes probe recommendations -OmniRoute is a **single Node process** (one event loop). Stock Docker `HEALTHCHECK` targets `/api/monitoring/health` — that is **too heavy** for kubelet liveness intervals. +OmniRoute is a **single Node process** (one event loop). Stock Docker `HEALTHCHECK` targets lightweight `/healthz`. `/api/monitoring/health` is **too heavy** for kubelet liveness intervals. | Probe | Recommended target | Notes | | --- | --- | --- | | **Startup** | HTTP `GET /healthz` with a long `failureThreshold` (or large `startPeriod`) | Cold start + SQLite migration can exceed a few seconds | -| **Readiness** | HTTP `GET /healthz` | Remove endpoints while starting/stopping; still flaps if the loop is CPU-blocked. A **200 in multiple seconds is not healthy** (#10303) — it means the event loop was starved before the 3-byte handler ran | -| **Liveness** | **TCP** on the main service port (`PORT`, default `20128`), **or** HTTP `/healthz` with soft thresholds | Do **not** kill the pod on short event-loop stalls; busy ≠ dead | +| **Readiness** | HTTP `GET /healthz` | Lifecycle `ok` / `starting` / `stopping` (200 vs 503). Still flaps if the loop is CPU-blocked. A **200 in multiple seconds is not healthy** (#10303) — it means the event loop was starved before the 3-byte handler ran | +| **Liveness** | HTTP `GET /livez`, **or TCP** on the main service port (`PORT`, default `20128`) | `/livez` is process-alive only (always 200 if the handler runs). It still shares the event loop — busy ≠ dead, and it does not detect event-loop starvation (#10303) any better than TCP does. Prefer **TCP** if HTTP probes time out under catalog/compression load; do **not** kill the pod on short event-loop stalls either way | | **Deep health** | `GET /api/monitoring/health` from an external checker | Not for kubelet `livenessProbe` / tight `readinessProbe` | Example shape (adjust thresholds to your cold-start and compression load): @@ -186,11 +186,16 @@ readinessProbe: timeoutSeconds: 2 failureThreshold: 6 livenessProbe: - tcpSocket: + httpGet: + path: /livez port: http periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 6 + # Under event-loop stall HTTP /livez can still time out. TCP is the + # conservative alternative: + # tcpSocket: + # port: http ``` **Do not** point kubelet **liveness** at `/api/monitoring/health`. That path does real DB/monitoring work and will false-positive under load. diff --git a/src/app/livez/route.ts b/src/app/livez/route.ts new file mode 100644 index 000000000000..d82f2dbee9a0 --- /dev/null +++ b/src/app/livez/route.ts @@ -0,0 +1,27 @@ +/** + * Process-alive probe. Distinct from /healthz (lifecycle readiness). + * Does not inspect the database, catalog, or providers. Still runs on the + * main Node event loop — busy ≠ dead; prefer TCP liveness under stall. + */ +export const dynamic = "force-dynamic"; + +const LIVE_BODY = "ok\n"; + +function createLiveResponse(method: "GET" | "HEAD"): Response { + return new Response(method === "HEAD" ? null : LIVE_BODY, { + status: 200, + headers: { + "Cache-Control": "no-store", + "Content-Length": String(LIVE_BODY.length), + "Content-Type": "text/plain; charset=utf-8", + }, + }); +} + +export function GET(): Response { + return createLiveResponse("GET"); +} + +export function HEAD(): Response { + return createLiveResponse("HEAD"); +} diff --git a/tests/unit/livez-route.test.ts b/tests/unit/livez-route.test.ts new file mode 100644 index 000000000000..55aa7320f201 --- /dev/null +++ b/tests/unit/livez-route.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import fs from "node:fs"; + +import { + markServerStarting, + markServerReady, + markServerStopping, +} from "../../src/lib/serverLifecycle.ts"; + +const livez = await import("../../src/app/livez/route.ts"); +const healthz = await import("../../src/app/healthz/route.ts"); + +test("/livez is 200 while starting and stopping; /healthz stays lifecycle", async () => { + assert.equal(livez.dynamic, "force-dynamic"); + markServerStarting(); + + const startingLive = await livez.GET(); + assert.equal(startingLive.status, 200); + assert.equal(await startingLive.text(), "ok\n"); + const startingReady = await healthz.GET(); + assert.equal(startingReady.status, 503); + + markServerReady(); + const readyLive = await livez.GET(); + assert.equal(readyLive.status, 200); + assert.equal(readyLive.headers.get("Cache-Control"), "no-store"); + assert.equal(readyLive.headers.get("Content-Type"), "text/plain; charset=utf-8"); + assert.equal(await readyLive.text(), "ok\n"); + + const readyHead = await livez.HEAD(); + assert.equal(readyHead.status, 200); + assert.equal(await readyHead.text(), ""); + + markServerStopping(); + const stoppingLive = await livez.GET(); + assert.equal(stoppingLive.status, 200); + const stoppingReady = await healthz.GET(); + assert.equal(stoppingReady.status, 503); +}); + +test("/livez source does not import monitoring or sqlite helpers", () => { + const source = fs.readFileSync("src/app/livez/route.ts", "utf8"); + assert.equal(/monitoring/i.test(source), false); + assert.equal(/sqlite/i.test(source), false); + assert.equal(source.includes("getServerLifecyclePhase"), false); +}); + +test("/livez is omitted from the centralized auth proxy matcher", () => { + const proxySource = fs.readFileSync("src/proxy.ts", "utf8"); + const matcherBlock = proxySource.match(/matcher:\s*\[([\s\S]*?)\]/)?.[1]; + assert.ok(matcherBlock, "proxy matcher configuration must remain discoverable"); + assert.equal(/["']\/livez/.test(matcherBlock), false); + assert.equal(/["']\/healthz/.test(matcherBlock), false); +});