* feat(provider): add Chenzk API OpenAI-compatible gateway
Registers Chenzk (chenzk.top) as a new API-key gateway provider — an
OpenAI-compatible aggregator exposing GPT/Claude/DeepSeek/GLM model groups
behind one endpoint. Adapted to OmniRoute's directory-per-provider registry
(open-sse/config/providers/registry/) and metadata catalog
(src/shared/constants/providers/apikey/gateways.ts), following the same
passthrough-models pattern already used for kenari/x5lab/sumopod (live
/v1/models catalog resolves the model list instead of a hardcoded array).
Co-authored-by: Ahmad Putra Cahyo <CahyokPutraDev99@users.noreply.github.com>
Inspired-by: https://github.com/decolua/9router/pull/2437
* chore(changelog): fragment for #7246
* test(provider): regen golden snapshot + bump family-count for Chenzk gateway
The Chenzk provider added in a616b88c9 registered a new APIKEY_PROVIDERS
entry (gateways.ts) but did not update the two characterization tests
that assert exact provider counts: the translate-path golden snapshot
(missing the chenzk entry) and the 167-entry family-merge count in
providers-constants-split.test.ts (now 168, verified as a strict
partition sum across the 6 family files, no loss/dup).
Co-authored-by: Ahmad Putra Cahyo <CahyokPutraDev99@users.noreply.github.com>
---------
Co-authored-by: Ahmad Putra Cahyo <CahyokPutraDev99@users.noreply.github.com>
* feat(cli): add Grok Build CLI tool setup (~/.grok/config.toml)
Registers xAI's Grok Build TUI coding agent as a configurable CLI tool in
/dashboard/cli-code, so OmniRoute can write itself in as a custom model
provider in ~/.grok/config.toml.
Mechanism: Grok Build reads a TOML config that can hold several user-defined
[model.*] sections plus a [models].default pointer. Unlike the sibling Forge
handler (which owns its whole config file and can full-replace it), this one
surgically upserts ONLY the [model.omniroute] section and rewrites
[models].default, leaving every other section byte-intact. Apply records the
previous default in an `# omniroute-prev-default` marker comment so Reset can
restore the user's original default instead of guessing.
Built on OmniRoute's existing CLI-tools infrastructure rather than replaying
the upstream shape: getCliRuntimeStatus() for detection (no ad-hoc
`which grok` exec), Zod validation via cliModelConfigSchema, the write guard,
createBackup(), the cliToolState DB module, and sanitizeErrorMessage() for
every error path (Hard Rule #12).
Security: GET reaches getCliRuntimeStatus(), which spawns a child process to
locate and healthcheck the `grok` binary. That is the same transitive-spawn
surface that classified /api/skills/collect/, so the route is registered in
LOCAL_ONLY_API_PREFIXES and loopback-enforced before any auth check
(Hard Rules #15 + #17). Writing a local CLI's config file is inherently a
local-machine operation, so this costs no real capability.
Co-authored-by: rixzkiye <rizkiyemubarok05@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2571
* chore(changelog): fragment for #7241
* fix(cli): shrink cliTools.ts/cliRuntime.ts under the file-size ratchet + fix stale catalog counts
The grok-build registry/runtime entries pushed cliTools.ts (916->932) and
cliRuntime.ts (1128->1137) past their frozen file-size caps. Extract the
grok-build entries into cliToolsGrokBuild.ts (registry, typed) and
cliRuntimeGrokBuild.ts (runtime metadata, deliberately untyped/no
cliCatalog import so it doesn't drag that schema file into the
typecheck:core curated allowlist's transitive graph). The amp runtime
entry rides along in the same runtime file for the extra headroom needed
to clear cliRuntime.ts's cap with zero slack.
Also update the two catalog-cardinality canaries (cli-tools-schema.test.ts,
cli-catalog-counts.test.ts) and EXPECTED_CODE_COUNT to include grok-build:
20->21 visible code entries, 24->25 total code entries, 32->33 grand total.
Fixes CI reds on #7241 surviving a release/v3.8.49 merge: Fast Quality
Gates (check:file-size) and Unit Tests fast-path (1/4, 2/4).
* test(stryker): register grok-build route-guard test in tap.testFiles
check:mutation-test-coverage --strict flagged
tests/unit/route-guard-grok-build-settings-local-only.test.ts as a covering
unit test for src/server/authz/routeGuard.ts missing from
stryker.conf.json's tap.testFiles allowlist (only became reachable once the
Fast Quality Gates job got past the file-size fix earlier in this branch).
---------
Co-authored-by: rixzkiye <rizkiyemubarok05@gmail.com>
* feat(dashboard): add Type filter and easiest-first sort to Free Provider Rankings (#6915)
Adds sort/filter controls keyed on provider auth type (NOAUTH/OAUTH/APIKEY)
to /dashboard/free-provider-rankings so zero-setup providers can be
surfaced without eyeballing the Type column:
- Type filter chips (All / No Signup / OAuth Login / API Key), client-side
over the already-fetched rankings (no API change).
- "Easiest first" sort toggle groups NOAUTH < OAUTH < APIKEY while
preserving the existing score-descending order within each group
(stable sort).
- Type column legend/tooltip explaining what each auth type means.
- Pure filter/sort logic extracted to a new freeProviderRankingsAuthType.ts
module (no DB imports) rather than freeProviderRankings.ts, so importing
it from the "use client" page does not pull server-only DB wiring
(fs/path/better-sqlite3) into the client bundle; freeProviderRankings.ts
re-exports both for API/test parity.
- i18n keys added to en.json + __MISSING__ placeholders synced to all 42
locale mirrors (consistent with the existing untranslated-key convention).
* test(6915): move page test to tests/unit/ui so a runner actually collects it
The .test.tsx sat at tests/unit/ top-level, which no runner collects
(vitest ui filters on tests/unit/ui) — check:test-discovery flagged it as
a new orphan: the test never ran. Moved under tests/unit/ui/ and switched
the dynamic import to the @/ alias (the convention of its neighbours).
6/6 now pass under test:vitest:ui.
* fix(types): explicit unknown hop on the getProviderConnections cast (#6915)
The dashboard-typecheck gate (added by #7203, after this branch was cut)
scopes tsc to the src/app/(dashboard) import graph. This PR's page.tsx now
imports freeProviderRankingsAuthType, which type-imports freeProviderRankings
— dragging that module into the graph for the first time and surfacing its
pre-existing TS2352 (JsonRecord[] -> ConnectionState[] is a structural subset).
Fixed the cast rather than widening the frozen baseline.
* fix(api): bulk-add API keys no longer overwrite existing connections
createProviderConnection upserts apikey connections BY NAME (same provider +
auth_type "apikey" + same name updates the row in place, replacing its
apiKey/priority/testStatus instead of inserting). The bulk-add route
auto-names unnamed lines "Key 1", "Key 2", ... restarting from 1 on every
request, blind to names already saved for the provider — so re-running a
bulk paste against a provider that already had "Key 1" silently replaced it
instead of adding a new connection alongside it. The same collision could
also happen within one batch for two identical custom name|apiKey lines.
Add resolveBulkNameCollisions (src/shared/utils/bulkApiKeyParser.ts): gap-fills
the smallest free "<name> <n>" suffix against both existing connection names
and names already assigned earlier in the same batch, so a name is never
reused. Wire it into POST /api/providers/bulk before the create loop, fetching
existing apikey connection names via the existing getProviderConnections db
module (no raw SQL added to the route).
Co-authored-by: asynx6 <sahrulbeni656@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2587
* chore(changelog): fragment for #7234
---------
Co-authored-by: asynx6 <sahrulbeni656@gmail.com>
* feat(providers): editable ComfyUI base-URL field + per-connection override for image/video/music generation (#6928)
Adds a shared resolveComfyUiBaseUrl() helper (open-sse/utils/comfyuiClient.ts) that
prefers a per-connection providerSpecificData.baseUrl override over the registry
default, wired through the comfyui dispatch branch in the image, video, and music
generation handlers plus a best-effort authType:"none" credential lookup in all
three /v1/{images,videos,music}/generations routes (never hard-fails when no
connection exists, so zero-config localhost users are unaffected).
Surfaces an editable base-URL field on the ComfyUI connection form by adding it to
CONFIGURABLE_BASE_URL_PROVIDERS / DEFAULT_PROVIDER_BASE_URLS /
getProviderBaseUrlPlaceholder in providerPageHelpers.ts, so Docker-network setups
(e.g. http://comfyui:8188) can be configured the same way self-hosted chat
providers are.
Closes#6928
* refactor(6928): extract local-override credential lookup in media routes
The inline per-connection override block nested if>if inside the music and
videos POST handlers, taking each to cognitive complexity 16 (>15) — two NEW
violations that broke check:complexity-ratchets (892 > baseline 890).
Extracted resolveLocalOverrideCredentials() in both routes; behaviour is
unchanged. cognitive-complexity back to 890 = baseline.
* feat(dashboard): replace free-text model inputs with hidePaid-aware Selects (#6540)
Swap RoutingTab.webSearchRouteModel, ComboDefaultsTab.handoffModel, and
BackgroundDegradationTab's from/to fields from free-text inputs to a new
shared ModelSelectField fed by the already hidePaidModels-aware
GET /api/models, with an off-catalog "(custom)" fallback so an existing
saved value is never silently dropped. ModelRoutingSection's glob pattern
field gets a fail-open "matches only paid models" warning instead, since
it's a wildcard matcher rather than a single model id.
Adds save-time paid-target rejection (PAID_MODEL_TARGET_BLOCKED, 400) on
PATCH /api/settings, PATCH /api/settings/combo-defaults, and PUT
/api/settings/background-degradation when hidePaidModels is on, failing
open for aliases/combo names/unrecognized providers.
globToRegex is extracted from lib/db/modelComboMappings.ts into a new
dependency-free shared/utils/globPattern.ts so both the DB module and the
new client-side pattern heuristic reuse the same regex-building logic.
* refactor(6540): extract paid-target guard in background-degradation PUT
The inline hidePaid check nested if>if>for>if inside the PUT handler,
pushing its cognitive complexity to 16 (>15) — a NEW violation that broke
the check:complexity-ratchets gate (891 > baseline 890). Extracted the
check into a module-local hasBlockedPaidTarget() helper; behaviour and
response body are unchanged. cognitive-complexity back to 890 = baseline.
* fix(i18n): mirror paidModelPatternWarning into pt-BR.json (#6540)
The new key landed only in en.json; the i18n pt-BR integrity test
(no drift, #6695) requires pt-BR.json to carry every en.json key.
* feat(mitm): add Antigravity reasoning-effort overrides
The Antigravity MITM alias mapping only ever swapped the destination model;
there was no way to override the reasoning effort Antigravity's own
thinkingConfig requested. Alias entries are now `{ model?, reasoningEffort? }`
(a legacy plain-string mapping still normalizes to `{ model }`, so no DB
migration is required). The standalone proxy (server.cjs) forwards the chosen
tier as a top-level `reasoningEffortOverride` on the intercepted request; the
antigravity->openai translator honors it ahead of its thinkingConfig-derived
guess, and an explicit "none" suppresses reasoning_effort entirely even when
Antigravity's own request asked for thinking. Reuses the existing canonical
5-tier reasoning vocabulary (`@/shared/reasoning/effortStandardization.ts`,
with max/extra aliasing to xhigh) instead of introducing a new one. The API
route validates the reasoning-effort value at the boundary and the Antigravity
tool card UI now exposes a per-model reasoning-effort selector alongside the
existing model-mapping input.
Co-authored-by: Truong Fiu <gnourtf@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2584
* chore(changelog): fragment for #7228
---------
Co-authored-by: Truong Fiu <gnourtf@gmail.com>
Extracts the routing-combo compression-mode dropdown (Default/Off/Lite/
Standard/Aggressive/Ultra) from the combo card into a shared
ComboCompressionModeSelect component, reused on both the combo card
(compact) and the Compression Combos page's "Assign to routing" list
under Context & Cache. Both surfaces persist through the existing
PUT /api/combos/{id} route -- no backend or schema change.
* feat(dashboard): add reorder-by-availability button to provider connections
Adds a "Reorder" action to the provider detail Connections toolbar that
sorts a provider's connections so available ones float to the top and
unavailable ones sink to the bottom, then persists the new order via the
existing per-connection priority PUT endpoint (same pattern already used
by handleSwapPriority).
Availability is computed with OmniRoute's own resilience model rather than
upstream's `modelLock_*` convention: a connection counts as available when
its effective status (testStatus, adjusted for the lazy connection-cooldown
window via rateLimitedUntil) is active/success — mirroring the exact logic
ConnectionRow already uses for its status badge, so the button and the row
badges never disagree. The sort is a stable Array.prototype.sort, so
connections keep their relative order within each availability group.
New pure helpers (sortConnectionsByAvailability, isConnectionAvailable,
getConnectionEffectiveStatus) live in connectionRowHelpers.ts and are
covered by a dedicated unit test, including the cooldown-lazy-recovery
edge case. i18n keys added to all 43 locales.
Co-authored-by: Fazril Syaveral Hillaby <fazriloke18@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2558
* chore(changelog): fragment for #7211
* fix(dashboard): extract reorder-by-availability into its own hook (file-size ratchet)
The reorder-by-availability feature pushed useProviderConnections.ts to 974
lines, past its frozen file-size cap (954). Extract the handler + its state
into a dedicated useReorderByAvailability hook, following the same pattern
already used for useModelVisibilityHandlers/useModelImportHandlers — no
behavior change, same tests still cover the sort logic in
connectionRowHelpers.ts.
* fix(dashboard): type the reorder hook's notifier explicitly (dashboard-typecheck TS2339)
ReturnType<typeof useNotificationStore> resolves to unknown under the
dashboard-scoped tsconfig gate (#7203), so notify.error tripped TS2339.
The hook only needs error(), so declare that minimal surface directly.
---------
Co-authored-by: Fazril Syaveral Hillaby <fazriloke18@gmail.com>
* feat(dashboard): show Codex plan label in provider and quota views
ConnectionRow on the provider-detail page never surfaced the Codex
subscription plan captured at OAuth import time
(providerSpecificData.chatgptPlanType, src/lib/oauth/services/codexImport.ts)
anywhere in the row UI. Added a small pure helper, getCodexPlanLabel, and a
Badge in ConnectionRow gated on isCodex.
Separately, the quota view's plan-badge machinery (resolvePlanValue /
tierByConnection / QuotaCardHeader) already existed for all providers, but
its persisted-metadata fallback list omitted chatgptPlanType. When the live
Codex usage endpoint has no plan_type/planType field, the usage service
reports the literal string "unknown" (open-sse/services/usage/codex.ts),
which resolvePlanValue's normalizePlanCandidate() filters out — so the quota
badge fell through to "Unknown" instead of the plan captured at login.
Added chatgptPlanType to the persisted candidate list.
Co-authored-by: Carmelo Campos <carmelogunsroses@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2570
* chore(changelog): fragment for #7210
* fix(dashboard): extract getCodexPlanLabel to unfreeze providerPageHelpers.ts
The Fast Quality Gates file-size ratchet froze
providerPageHelpers.ts at 1053 lines; adding getCodexPlanLabel
inline pushed it to 1067. Move the self-contained helper into its
own codexPlanLabel.ts module instead of growing the frozen file,
and repoint ConnectionRow.tsx + the regression test at the new
location. No behavior change.
---------
Co-authored-by: Carmelo Campos <carmelogunsroses@gmail.com>
* feat(kiro): register GPT-5.6 Sol/Terra/Luna model family
Kiro announced its first OpenAI-family models on 2026-07-14
(kiro.dev/changelog/models): GPT-5.6 Sol (flagship), Terra (balanced
mid-tier) and Luna (fastest/cheapest), all sharing a 272k context
window. Registers the three base model ids in the kiro provider
registry with contextLength/maxOutputTokens so getResolvedModelCapabilities()
resolves the real 272k window instead of falling back to the generic
default. OmniRoute derives the thinking/agentic synthetic variants and
per-account rate multipliers dynamically at discovery time
(open-sse/services/kiroModels.ts), so only the three base entries need
static registration here.
Co-authored-by: Edison42 <gn00742754@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2596
* chore(changelog): fragment for #7209
* fix(kiro): add GPT-5.6 Sol/Terra/Luna pricing rows
The registry additions in this PR exposed three new Kiro model ids
without matching pricing rows, tripping the catalog invariant that
every Kiro registry model must resolve a non-zero pricing row
(tests/unit/catalog-updates-v3x.test.ts) — the models would have
billed at $0.00.
Reuses the shared GPT_5_6_{SOL,TERRA,LUNA}_PRICING tiers already used
by the codex and openai aliases.
---------
Co-authored-by: Edison42 <gn00742754@gmail.com>
tryAwsSsoCache() only resolved clientId/clientSecret via data.clientIdHash -> <hash>.json. Newer kiro-auth-token.json files instead carry a top-level clientId directly, so that lookup silently failed and left clientId/clientSecret null, sending the dashboard's Import Token POST down the non-IDC path. That path (KiroService.validateImportToken -> readCachedClientCredentials) picked a client registration by region + latest-expiry across ALL cached SSO client registrations, ignoring the token's actual clientId — on a machine with multiple stale registrations this returned a mismatched clientId/clientSecret pair, producing 'Bad credentials' on refresh.
Fix: resolve clientId/clientSecret by scanning the cache for a registration file whose own clientId matches the token's clientId (falling back to clientIdHash first, then a direct-match scan), and thread an optional clientId hint into readCachedClientCredentials()/validateImportToken() so an exact match always wins over the region/latest-expiry heuristic.
Reported-by: Asher (@XCrag) (https://github.com/decolua/9router/issues/1253)
* feat(providers): curated OpenRouter embeddings catalog + specialty merge in live discovery (#6976)
OpenRouter serves embeddings via a dedicated OpenAI-compatible
/api/v1/embeddings endpoint that is omitted from /v1/models, and the
embeddingRegistry entry for it was stale (3 legacy ids). Meanwhile
providerModelsConfig gives openrouter a live discovery config, so
buildApiDiscoveryResponse's success path returned only the live chat
catalog verbatim — the specialty (embeddings/rerank) static catalog was
only ever merged in on the no-config local_catalog fallback, so OpenRouter
embeddings never surfaced through model discovery.
Refreshed the curated openrouter embeddingRegistry lineup (ids verified
against https://openrouter.ai/docs/api/reference/embeddings and the
collections page) and added a scoped, additive merge
(mergeSpecialtyCatalogIntoLiveModels, allowlisted to openrouter) that folds
embeddings/rerank entries from getStaticModelsForProvider() into the live
discovery response, deduped by id. Scoped as an allowlist rather than a
blanket merge because some providers (e.g. Gemini) already return
embedding models directly from their live /v1/models endpoint, where a
blind merge would risk stale/duplicate entries.
* test(providers): type the models discovery payload instead of any (#6976)
no-explicit-any is an error under tests/ (#6218), so the 4 `any`
usages in the new discovery assertions failed the max-warnings-0
lint gate. Replace them with an explicit ModelsResponseBody shape —
type-only change, all 13 assertions unchanged and still passing.
* test(providers): type the openrouter merge assertion callback (#6976)
The new #6976 assertion added a 56th explicit `any` to this file,
one over the 55 frozen in config/quality/eslint-suppressions.json,
tripping the max-warnings-0 lint gate. Type the callback param
instead of raising the frozen count — the debt ratchet only decreases.
All 59 tests still pass.
* fix(dashboard): hide disabled provider connections from combo builder
The combos page's fetchData() only filtered available connections by
testStatus ("active"/"success"), so a connection the user had
explicitly disabled (isActive: false) could still show up in the
combo builder if it carried a stale testStatus from before it was
disabled.
Add filterActiveConnections() in src/shared/utils/connectionStatus.ts
and apply it ahead of the existing testStatus filter.
Co-authored-by: itolstov <attid0@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2526
* chore(changelog): fragment for #6984
* fix(combos): keep combos page within frozen size cap
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(combos): extract filterUsableConnections to shrink the combos god-file
The combos page only filtered provider connections on testStatus, so a
connection the user had explicitly disabled survived with a stale
"active"/"success" status. The isActive + testStatus gate now lives in
the shared connectionStatus util as filterUsableConnections(), which the
page calls in a single line.
This keeps src/app/(dashboard)/dashboard/combos/page.tsx BELOW its frozen
file-size cap (4653 vs 4655 congelado — the file shrinks by 2 lines vs the
release tip) without touching config/quality/file-size-baseline.json, as
the gate asks ("modularize/extraia (DRY) para encolher").
The regression test now exercises filterUsableConnections directly instead
of hand-mirroring the page's filter chain, so it guards the real code path.
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
* fix(combos): drop nullish entries in filterActiveConnections
`connection?.isActive !== false` evaluated to true for null/undefined
entries, so nullish elements survived the filter. Callers read properties
off the result — filterUsableConnections() reads `connection.testStatus`
— which would throw "TypeError: Cannot read properties of null".
Guard with an explicit truthiness check. Covered by a test that fails
against the previous predicate.
Reported-by: gemini-code-assist
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
---------
Co-authored-by: itolstov <attid0@gmail.com>
getStaticModelsForProvider() only defined literal catalogs for
linkup-search, ollama-search, and searchapi-search out of the 12 ids in
SEARCH_PROVIDERS. The other 9 (serper-search, brave-search,
perplexity-search, exa-search, tavily-search, google-pse-search,
youcom-search, searxng-search, zai-search) returned undefined and hit
the 400 "does not support models listing" tail in the models route
during the "Import Models" step.
Instead of adding 9 more one-off literal entries, generalize the class:
when a provider has no dedicated STATIC_MODEL_PROVIDERS entry, fall
back to a catalog derived from SEARCH_PROVIDERS[id].searchTypes (every
search-registry entry already declares this). Future search providers
added to searchRegistry.ts automatically get a usable catalog with zero
extra code.
Closes#7529
DefaultExecutor.buildUrl()'s "zai"/"glm-coding-apikey" case always returned
the Anthropic Messages URL, ignoring a per-model targetFormat override
(custom-model dropdown, #2905) that resolves to "openai" — e.g. for a vision
model like glm-4.6v. chatCore/executionCredentials.ts now threads the
resolved override onto providerSpecificData.targetFormat so buildUrl (via
the new default/zaiFormatOverride.ts helper, extracted to respect the
file-size ratchet) can route to the OpenAI-compatible endpoint instead.
Separately, custom-model id lookup (lookupCustomModelMeta in
src/sse/services/model.ts, getCustomModelRow in src/lib/db/models.ts) did an
exact, case-sensitive match, so a model saved as "glm-4.6v" was invisible
when looked up as "glm-4.6V". Both now fall back to a case-insensitive match
after the exact match fails.
Regression tests: tests/unit/zai-glm-target-format-override.test.ts (reused
from the triage plan-file's RED probe) and
tests/unit/zai-execution-credentials-target-format-7364.test.ts (production
wiring in executionCredentials.ts).
Gates run: check-file-size, check-complexity, check-cognitive-complexity,
typecheck:core, eslint (suppressions), tests/unit/zai-glm-target-format-override.test.ts,
tests/unit/zai-execution-credentials-target-format-7364.test.ts,
tests/unit/executor-default-base.test.ts, tests/unit/custom-model-target-format.test.ts,
tests/unit/chatcore-execution-credentials.test.ts, tests/unit/chatcore-target-format.test.ts,
tests/unit/model-resolver.test.ts, tests/unit/model-alias-provider-resolution.test.ts,
tests/unit/combo-custom-provider-resolution.test.ts — all green.
Refs #7364
detectBinary() in tool-detector.ts never checked process.platform and never
passed shell:true, so on native Windows an installed CLI (npm installs
claude/codex/opencode as .cmd shims) was reported as NOT installed:
1. execFileImpl(binary, ["--version"]) fails without shell:true for .cmd
shims (Node's CVE-2024-27980 hardening).
2. the `which` fallback doesn't exist on native Windows (no WSL/git-bash).
Both threw, both were swallowed by empty catches, detectBinary returned
{installed: false}. cliRuntime.ts::locateCommand already solved this for the
runtime-spawn path (#968) but never propagated here — re-drift, per the
issue title.
Exports locateCommand from cliRuntime.ts and reuses it (+ shouldUseShellForCommand,
+ getLookupEnv) for the win32 existence/path probe in tool-detector.ts, keeping
the --version probe local but shell-gated. Also routes the which fallback through
the injectable execFileImpl hook (it previously called the raw execFileAsync,
making it unmockable and prone to false-positives from a real system which).
captureCurrentProviderRequest mirrors every Bedrock Converse request into the
pending-request log tracker right after openAIToBedrockConverse() builds it,
including the decoded image.source.bytes Uint8Array. sanitizePayloadPII() and
redactPayload() in src/lib/logPayloads.ts both gate their recursive walk on
Array.isArray(), which is false for typed arrays, so each image fell into the
generic-object branch and got enumerated one JS key per decoded byte (twice,
once per function) before any truncation bound applied. For 3x ~1MB images
this took ~4s of synchronous, event-loop-blocking work, matching the
reporter's "1-2 images OK, 3+ fails" threshold and their --stack-size
observation (data-width pressure, not call-depth).
Add an opaque-binary short-circuit (ArrayBuffer.isView) ahead of the
Array.isArray branch in both functions, returning a fixed-size placeholder
instead of recursing. Apply the same guard to cloneBoundedForLog() in
open-sse/utils/requestLogger.ts for defense-in-depth (same blind spot, only
accidentally safe today via its own key-count slice).
Regression test reproduces the exact reporter shape (3x 1MB images) through
the real openAIToBedrockConverse() converter and protectPayloadForLog(),
asserting completion well under the previous ~4s and that binary bytes are
never expanded into per-byte object keys.
- byProvider now resolves the internal provider id to its configured
display name via getProviderById() (fallback: raw id for providers
not in the static registry). Fixes the Usage page showing "codex"
instead of "OpenAI Codex".
- byModel's in-memory dedup key now uses the normalized model name
instead of the raw one, so the same logical model recorded under a
bare and a provider-prefixed spelling (e.g. "glm-5.2" vs
"z-ai/glm-5.2") merges into a single aggregated row instead of
appearing twice with the same displayed name.
- Introduces a local UsageRows type alias in route.ts to shrink the
repeated "as Array<Record<string, unknown>>" casts back under the
frozen file-size baseline once the file was touched.
* fix(dashboard): resolve costs page 500 from out-of-scope t() in TopListCard (#7272)
TopListCard (CostOverviewTab.tsx) referenced the bare identifier `t`
from an outer component's scope when rendering the zero-cost /
!hasCostData branch, throwing "ReferenceError: t is not defined" and
crashing /dashboard/costs?range=all&apiKeyIds=...&groupBy=model
whenever a filtered slice landed only $0-cost rows.
Extracted TopListCard into its own component file and threaded the
resolved legacyFreeLabel string in as a prop, mirroring the existing
CostBreakdownTable pattern in the same file. Also fixes a case of the
typecheck:core dashboard .tsx coverage gap tracked in #7033.
* test(dashboard): move TopListCard #7272 regression test to vitest UI project
The node:test unit runner cannot load TopListCard's import chain
(@/shared/components -> ProviderIcon -> @lobehub/icons ESM), which made
the "Impacted unit tests (TIA subset; blocking)" CI job red with
"Unexpected token 'export'" on tests/unit/costs-toplistcard-legacy-free-label-7272.test.ts.
Moved the regression test to tests/unit/ui/ and rewrote it against the
vitest UI project (test:vitest:ui, blocking in the test-vitest CI job),
which handles the ESM import chain natively. Verified the test still
fails with "ReferenceError: t is not defined" against the pre-#7272
TopListCard body and passes against the fixed component.
* fix(db): pre-init sql.js WASM ahead of any getDbInstance() consumer (#7288)
* fix(db): close sqljs preinit ordering gap without top-level await (#7288)
The previous fix added a top-level await barrier at the bottom of
src/lib/db/core.ts to guarantee sql.js pre-init before any consumer
reached getDbInstance(). That made core.ts an async ES module, which
broke esbuild's CJS require() bundling for every test file that does
require("../../src/lib/db/core.ts") (tsx's CJS require hook rejects
requiring a transitive dependency with a top-level await), and caused
unrelated tests running in the same node:test process to fail with
"Promise resolution is still pending but the event loop has already
resolved".
Move the fix to the real startup entrypoint instead: registerNodejs()
(src/instrumentation-node.ts) now awaits ensureDbReadyForBoot() before
ensureSecrets()/clearStaleCrashCooldowns()/getSettings()/initAuditLog(),
all of which reach getDbInstance() transitively. ensureDbInitialized()
is idempotent, so later getDbInstance() calls are free cache reads.
The driverFactory.ts error-surfacing improvements from the original
#7288 fix (logging swallowed sync-driver errors, surfacing the real
sql.js pre-init failure instead of the generic "not pre-initialized
yet" message) are unchanged.
Updated tests/unit/db-sqljs-preinit-ordering-gap-7288.test.ts to prove:
no top-level await in core.ts, the corrected call order in
registerNodejs() (source-order assertion), and the original
getDbInstance()-no-longer-throws-the-misleading-message behavior driven
via the same warm-up path ensureDbReadyForBoot() now guarantees ahead
of every other startup step.
* refactor(db): drop the orphaned preInitSqlJsIfSyncDriversUnavailable helper (#7288)
Moving the ordering guarantee to registerNodejs() left this exported
helper with zero production callers — its own docblock still claimed it
was 'Chamada no top level de core.ts', describing an architecture the
hotfix removed. It was also redundant: ensureDbInitialized() already does
tryOpenSync-then-preInitSqlJs on the real boot path (core.ts:1358).
Its two tests exercised the helper as a stand-in for the real warm-up
('Simulates the fixed ordering'), so they proved a simulation rather than
production behaviour. They now drive ensureDbReadyForBoot()/tryOpenSync()
directly. The ordering guard still fails against the unfixed
instrumentation-node.ts (verified) and the whole file is 4/4 green.
The live parts of the driverFactory change (logSwallowedDriverError,
getSqlJsPreInitError) are untouched — both have real callers.
Root cause: validateOpenAILikeProvider's chat-probe status handling
(src/lib/providers/validation/openaiFormat.ts) only special-cased
401/403, 404/405, and >=500 — every other status, including 429, fell
through to the unqualified return { valid: true, error: null }. For
permanently-throttled free tiers (e.g. opencode-zen, classified
'avoid' in freeTierCatalog.ts), the dashboard connection Test reported
green forever while real traffic hit 429 on every request, with no
signal to the user.
Fix mirrors the existing validateBedrockProvider 429 precedent in the
same file: keep valid:true (the key is accepted) but add a warning
field describing the rate limit, instead of an indistinguishable pass.
Regression test: tests/unit/issue-7284-connection-test-masks-429.test.ts
mocks a 404 /models probe followed by a 429 chat probe and asserts the
result now carries { valid: true, error: null, warning: <rate-limit string> }.
Gates run (all green):
- node --import tsx/esm --test tests/unit/issue-7284-connection-test-masks-429.test.ts
- node --import tsx/esm --test tests/unit/provider-validation-firepass-403.test.ts tests/unit/validation-format-validators-split.test.ts (existing tests of the touched area, unaffected)
- node scripts/check/check-file-size.mjs
- node scripts/check/check-complexity.mjs (2054/2056 baseline, unchanged)
- node scripts/check/check-cognitive-complexity.mjs (889/890 baseline, unchanged)
- npm run typecheck:core
- npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files>
Root cause: the Providers page model-name filter
(filterConfiguredProviderEntries) matched only against
getModelsByProviderId(...), the static curated model registry, never
the live/synced catalog for the connection. Aggregator providers
(openrouter, kilocode, theoldllm...) declare a single-entry static
placeholder (e.g. openrouter's {id:'auto',name:'Auto (Best Available)'}),
so searching for any real upstream model name could never match and the
whole provider silently disappeared from the list.
Fix: source the live/synced catalog (already persisted per-connection via
GET /api/synced-available-models, the same store the combo builder's model
picker already relies on) via a new useSyncedModelsByProvider hook, and
union it with the static registry inside the filter. An empty/never-synced
catalog falls back to the static-only match so already-correct static
providers are unaffected.
Regression test: tests/unit/provider-model-filter-live-catalog-7250.test.ts
reproduces the original bug (static-only match returns 0 results for a
real model name) and proves the fix (live catalog match returns 1),
plus non-regression coverage for the static-only fast path and unrelated
providers.
* fix(cli): Windows cert check/uninstall key off the real CA identity, not a hardcoded legacy host (#7275)
checkCertInstalledWindows()/uninstallCertWindows() queried the Windows Root
store by the literal legacy hostname daily-cloudcode-pa.googleapis.com
regardless of the certPath passed in (the check's param was even
underscore-prefixed/unused). It only worked because that hostname happens to
be the generated CA's own commonName today (generate.ts derives it from
ANTIGRAVITY_TARGET.hosts[0]) -- a coincidence, not a derivation, with no
shared symbol coupling the two. installCertWindows() was already correct.
Both functions now derive a SHA-1 thumbprint straight from the certPath file
via the new exported certutilThumbprint() helper (reusing the existing
getCertFingerprint() logic checkCertInstalledMac() already keys off), so the
Windows store lookup/delete always matches the real generated CA regardless
of any future rename/reorder of ANTIGRAVITY_TARGET.hosts in generate.ts. Same
anti-pattern class as #6338 (DNS side).
* test(cli): assert certutil argv precisely instead of scanning for the legacy host (#7275)
The two negative substring checks tripped CodeQL's
js/incomplete-url-substring-sanitization (high) by looking like URL
sanitization while actually asserting a certutil argv. Replaced with
assertions on the exact argv / extracted certId, which are strictly
stronger: pinning the value proves no other identity can be passed.
Both still fail against the unfixed install.ts (5/5 RED) and pass with
the fix (5/5 GREEN).
deepMergeFallback in src/i18n/request.ts only substituted the English
fallback value when a key was entirely undefined. Keys backfilled by
scripts/i18n/sync-ui-keys.mjs with the __MISSING__:<english> sentinel
exist on the target object, so they passed through untouched and were
rendered raw to the user (395 zh-TW keys, 337 pt-BR keys, systemic
across locales).
Now any target leaf that still carries the __MISSING__: prefix is
treated the same as an absent key, so the clean EN value wins.
Does not touch the underlying translation content (395/337 strings) -
that is a separate content workstream.
The PKCE callback server binds the SERVER's loopback (localhost:PORT). When
the operator drives the OAuth flow from a different machine (OmniRoute on a
remote host/VPS), the provider redirects the browser to the operator's OWN
localhost:PORT — the confirmation screen hangs forever with no explanation.
start-callback-server now inspects the request Host: on a non-loopback host it
returns { remoteHost, tunnelCommand, message } so the UI can show the
'ssh -L PORT:127.0.0.1:PORT' instruction (or steer to the paste/import flow)
instead of a silent hang. Loopback access is unaffected. The Host header is
spoofable, so this drives only a UI hint — never an auth decision.
Logic extracted to remoteOAuthHint.ts (keeps the god-route under its size
budget and makes it unit-testable). TDD: 4 tests covering loopback (no hint),
null host (fail-open), and remote host (correct tunnel command for both the
fixed 1455 and OS-assigned ports).
Closes#7523
POST /api/oauth/codex/import accepted a payload with an already-invalidated
refresh_token and persisted it as an 'active' connection that could never
work — the failure surfaced confusingly only on first real use, long after
the import looked successful.
Validate each record's refresh_token against OpenAI's OAuth endpoint before
persisting, reusing refreshCodexToken() (free exchange, no quota). On an
unrecoverable refresh error the record is rejected with a clear re-auth
message; a valid token imports as before, with any rotated tokens applied.
Bulk import still processes each record independently — one dead token no
longer blocks the valid ones.
Reproduced live 2026-07-16: a 2026-07-10 auth.json imported clean but its
refresh_token returned 401 refresh_token_invalidated. TDD: 3 tests RED against
the old route, 5/5 GREEN with the fix.
Closes#7522
The connection Test button always reported success for a Codex connection
backed by a ChatGPT account: the probe sent `gpt-5.3-codex`, a codex-only
model the ChatGPT-account backend rejects outright with a 400 — the same
status the probe treats as 'auth accepted, body invalid'. A bad token and a
good token both came back 400, so Test could never fail on a bad token.
Probe with `gpt-5.5` (confirmed served for ChatGPT-account sessions via live
VPS test 2026-07-16) instead; `input: []` still yields the intended 400 for a
good token, 401/403 for a bad one.
Live verification (VPS): gpt-5.3-codex, gpt-5.6-sol and the gpt-5*-codex ids all
return 'not supported when using Codex with a ChatGPT account'; gpt-5.5 and
gpt-5.6-terra answer normally on the same account.
Closes#7521
* fix(dashboard): align onboarding welcome feature cards vertically
* fix(dashboard): align onboarding tier content
* chore: scope onboarding PR to UI fix
* i18n(pt-BR): add onboarding.tier.flowCaption + afterSetup keys
The two new tier keys added to en.json were missing from pt-BR.json, tripping
the i18n-pt-br no-drift test (#6695). Add their pt-BR translations.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
* fix(dashboard): add vision-capability toggle for custom OpenAI-compatible models (port from 9router#1904)
detectVisionInput()/getCustomVisionCapabilityFields() already honoured an explicit
supportsVision flag on a custom-model record, but there was no way to set it: the
POST/PUT /api/provider-models Zod schema and updateCustomModel()/addCustomModel()
silently dropped the field, and the 'Custom Models' add/edit UI had no checkbox at
all. Self-hosted/local backends that don't self-report an image input modality
(OpenRouter-style architecture.input_modalities) therefore had no way to be flagged
vision-capable, so the vision tag never appeared and image inputs were rejected.
Reported-by: nguyenphi37 (https://github.com/decolua/9router/issues/1904)
* refactor(dashboard): extract providerCredentialText from providerPageHelpers to respect the file-size gate
providerPageHelpers.ts is a frozen god-file (cap 1053, split(\n).length metric)
and this PR's own +3 lines (the #1904 supportsVision field) pushed it to 1054,
failing check:file-size. Extract the cohesive providerText utility + the 4
web-session-credential label/hint/title helpers into a new leaf module
(providerCredentialText.ts), re-exported from providerPageHelpers.ts for
backward compatibility so all existing import sites keep working unchanged.
File now sits at 946 lines, well under the frozen cap.
* refactor(db): extract tri-state override helper to keep the complexity ratchet at baseline
The #1904 supportsVision override added a second copy of the "absent keeps /
null clears / else coerce" block already used by preserveOpenAIDeveloperRole,
pushing updateCustomModel to 84 lines and check:complexity to 2057 > 2056.
The file-size failure was masking this one: the gate exits on its first red,
so complexity never ran until providerPageHelpers was back under its cap.
Fold both blocks into applyTriStateBooleanOverride(). Behavior is unchanged —
updateCustomModel is back under max-lines-per-function and the global count
returns to the 2056 baseline (cognitive-complexity stays at 890).
* Use OpenAI chunks for early chat keepalives
* Update keepalive assertion to match chat completion chunk format
---------
Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
A fusion combo fans every panel model out in parallel and buffers each
model's full response text in memory simultaneously. With the runtime heap
capped by Dockerfile's OMNIROUTE_MEMORY_MB (default 1024MB), a large panel
(reported: ~73 models via an 'auto' combo with strategy: fusion) with
sizable concurrent responses can exceed the heap ceiling and OOM-crash the
whole container instead of failing one request.
handleFusionChat now rejects panels above a configurable hard cap
(FUSION_DEFAULTS.maxPanel = 40, overridable per-combo via
fusionTuning.maxPanel) with a clean 400 before fan-out begins.
Reported-by: Phong Vu (@fontvu) (https://github.com/decolua/9router/issues/1905)
* fix(api): check Vercel SSO-protection PATCH response on relay deploy (port from 9router#1037)
The Vercel relay deploy route disabled Deployment Protection (SSO) by firing a PATCH request with .catch(() => {}) and never checking res.ok. When Vercel rejects or no-ops the PATCH (plan doesn't allow disabling protection, an under-scoped token, etc.), the relay was still saved and activated as a healthy proxy pool, and later requests routed through it failed with an undiagnosed 403 Access denied from Vercel's own deployment protection — indistinguishable from an upstream-provider rejection (e.g. Codex/ChatGPT edge-IP blocking).
Extract disableSsoProtection() to check the PATCH response and surface an ssoProtectionWarning in the deploy response when it fails, so the failure source can be diagnosed instead of silently masked.
Reported-by: Rico Aditya (@ricatix) (https://github.com/decolua/9router/issues/1037)
* refactor(api): extract vercel-deploy POST helpers to keep the cognitive-complexity ratchet at baseline
The SSO-protection check added to POST pushed its cognitive complexity from
15 to 21, regressing the cognitive-complexity ratchet (891 > baseline 890).
Extract two pure helpers with identical behavior:
- buildDeployErrorResponse(): the sanitized non-ok Vercel deploy response
- resolveSsoProtectionWarning(): the SSO PATCH check + warning string
POST now reads as a flat sequence of guards. No behavior change.
* fix(cli): remove MITM DNS spoof entries before killing server process (port from 9router#1809)
stopMitm() killed the spawned MITM server process first and only removed the /etc/hosts DNS-spoof entries afterward. During that window any client whose DNS still resolved a target host to 127.0.0.1 but whose MITM listener was already dead got connect ECONNREFUSED 127.0.0.1:443 — exactly the community-confirmed workaround (stop DNS before stopping the server) proves. Swap the two steps so DNS is always cleared first, mirroring the ordering already used by repairMitm() and handleExitCleanup().
Reported-by: dionisius95 (https://github.com/decolua/9router/issues/1809)
* refactor(mitm): extract repair planning out of manager to respect the file-size cap
The #1809 DNS-before-kill ordering fix pushed src/mitm/manager.ts to 813
lines, over the 800-line cap check:file-size enforces for non-frozen files.
Move the pure repair-planning pieces (collectManagedHosts, the RepairPlan
shape and its filesystem/cert/DNS sweep) into a sibling src/mitm/repair.ts.
The in-memory session bookkeeping repairMitm() owns — cached sudo password,
orphaned flag, PID file — deliberately stays in manager.ts, so the seam is
"plan the repair" vs "own the session".
manager.ts is now 731 lines; behavior is unchanged. The DNS-first ordering
fix and its regression guard (tests/unit/mitm-stop-dns-before-kill-1809.ts)
are untouched and still pass.
* fix(mitm): split stopMitm() DNS/kill steps to fix complexity ratchet regression
stopMitm()'s new DNS-before-kill ordering (#1809) pushed its cyclomatic
complexity to 18 (max 15), regressing the complexity ratchet from 2056 to
2057. Extract the DNS-removal step and the process-kill step (in-memory +
PID-file fallback) into two private helpers, mirroring the existing
performRepairSteps() extraction pattern in repair.ts. Behavior unchanged;
complexity back at 2056 (cognitive-complexity drops to 889, one under
baseline).
Root cause: validateOpenAICompatibleProvider's chat-completions probe fallback treated ANY 4xx other than 401/403/429/400 as a silent 'credentials valid' pass with no warning, so a bogus/non-standard model id (e.g. Featherless/OpenRouter vendor/model typos) went undetected at Check time. The first real request then hit the upstream 404 model_not_found and the per-model lockout, holding the model unavailable for the configured reset window with no prior indication anything was wrong.
User-visible effect: 'Check' now returns valid:true with an explicit warning (including the upstream error message when parseable) whenever the chat probe answers 404, so a bad model id is caught before it reaches production traffic and the lockout.
Reported-by: advane204f (https://github.com/decolua/9router/issues/2032)
Cloudflare Workers AI free tier (10k Neurons/day, account-wide) returns
429 with body 'you have used up your daily free allocation of 10,000
neurons' which matched no QUOTA_PATTERNS keyword — falling through to
rate_limit (~60s cooldown) instead of quota_exhausted.
Two layers:
1. Provider-specific rule for 'cloudflare-ai' in providerRuleRegistry
(scope: connection — budget is account-wide, not per-model)
2. Defense-in-depth: /daily free allocation/i in classify429 QUOTA_PATTERNS
Tests: 11/11 pass (provider rule + classify429 paths covered).
Closes#6980
Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
* fix(skills): register cli-skill-collector in the agent-skills catalog (#6294 shipped the dir only)
* chore(skills): regenerate cli-skill-collector SKILL.md via the generator, preserving the #6294 authored workflow in the custom block
* fix(skills): derive coverage totals from the id lists + align remaining count assertions (45 catalog / 21 cli)
* fix(skills): SkillCoverage totals are number, not stale literals
* fix: raise main server keepAliveTimeout/headersTimeout above Node's 5s default (#7003)
JetBrains AI Assistant's pooled java.net.http.HttpClient reuses a
keep-alive connection past Node's unconfigured 5_000ms keepAliveTimeout,
hitting a socket the server already tore down and getting 0 response
bytes back ("HTTP/1.1 header parser received no bytes"). Wire a new
getMainServerTimeoutConfig() (mirroring apiBridgeServer's pattern) into
run-next.mjs so the main dashboard/API server raises keepAliveTimeout
to 65s and headersTimeout to 66s by default, both env-overridable.
* fix: wire main-server keepAlive timeouts into standalone/production server path (#7003)
getMainServerTimeoutConfig() was only wired into scripts/dev/run-next.mjs,
the dev-only entry point for `npm run dev`/`npm start`. The server real
end users run — `omniroute serve` (npm-installed CLI), Docker, and
Electron — spawns the standalone Next build's server.js via
run-standalone.mjs, which prefers server-ws.mjs (built verbatim from
scripts/dev/standalone-server-ws.mjs by assembleStandalone.mjs) over the
bare server.js precisely because it wraps http.createServer with
production behavior the bare server lacks. That wrapper never configured
keepAliveTimeout/headersTimeout, so the JetBrains AI Assistant reconnect
bug this issue reports still hit the production entry point after the
first pass of this fix. Wire the same helper into the wrapped server
object there too.
* fix: filter hidden custom models out of legacy combo model picker (#7156)
* chore(test): move model-select-modal-hidden-models-7156 test into tests/unit/ui (collector coverage) (#7156)
The dns toggle button called fetch(..., { method: "PUT" }) but
src/app/api/tools/agent-bridge/agents/[id]/dns/route.ts only exports
POST, so Next.js auto-returned 405 on every Start/Stop DNS click.
Fixes the frontend caller to match the documented POST contract
(docs/frameworks/AGENTBRIDGE.md:490) already covered by
tests/unit/agent-bridge-dns-route-validation.test.ts.
Adds a regression test asserting the fetch call uses method: POST.