The packaged artifact stamped dist/BUILD_SHA but nothing verified the SHA belonged to the release line, so a tarball built from a feature branch installed and served traffic indistinguishably from a release build. That is how the internal gateway ended up running a build that predated #10373 and answered every request with 502 'Executor result must contain a Response' — identifying it required SSH plus grepping the compiled chunks.
scripts/build/buildProvenance.ts classifies a build SHA against the release ref (pure functions, injected git probe). A missing SHA fails even with the canary override: an unidentifiable artifact cannot be vouched for. validate-pack-artifact enforces it on real packs (skipped under --policy-only, which runs without a build); OMNIROUTE_ALLOW_CANARY_BUILD=1 records a deliberate off-release-line build instead of failing it. /api/monitoring/health now exposes system.buildSha — absent when unknown, never fabricated.
Closes#10427
* fix(sse): surface Qwen/Alibaba personal Token Plan quota in dashboard and preflight
The personal Token Plan (5-hour / 7-day sliding windows) has no official
OpenAPI and the inference API key cannot read it. Add a cookie-authenticated
fetcher for the console gateway shared by home.qwencloud.com and the Model
Studio console (contract captured live from a logged-in session):
- open-sse/services/qwenTokenPlanQuotaFetcher.ts: POST /data/api.json
(IntlBroadScopeAspnGateway / sfm_bailian) for usage + quota-config +
subscription; sec_token resolved best-effort from the dashboard HTML;
per-window parse (fields are omitted while a window is Temporarily
Removed); 60s usage cache, 1h tier cache.
- usage/qwen-token-plan.ts leaf + registration in the usage dispatcher,
USAGE_FETCHER_PROVIDERS, USAGE_SUPPORTED_PROVIDERS,
PROVIDER_LIMITS_APIKEY_PROVIDERS and bespoke preflight/monitor windows.
- Also adds bailian-coding-plan to USAGE_SUPPORTED_PROVIDERS /
PROVIDER_LIMITS_APIKEY_PROVIDERS: the coding-plan fetcher existed but the
dashboard filtered those connections out (UI gap).
Refs #9603 (Problema 1 — quota missing; the 429 recovery half is a
follow-up).
* docs(env): document Qwen Token Plan quota env vars + regen omni-settings skill
QWEN_CLOUD_COOKIE, QWEN_CLOUD_SEC_TOKEN, QWEN_TOKEN_PLAN_HOST and
QWEN_TOKEN_PLAN_DASHBOARD_URL added to .env.example and
docs/reference/ENVIRONMENT.md (check:env-doc-sync), with the generated
omni-settings skill refreshed (check:agent-skills-sync).
Refs #9603
* revert: keep hand-tuned omni-settings thinking-budget section
The agent-skills-sync drift predates this PR (hand improvement from #10169
not yet synced into the generator source) — it fails on every open PR and
belongs to a base-reds fix, not this branch. Regenerating here would erase
the intentional content.
* feat(dashboard): add the Qwen/Model Studio console cookie field to the connection modal
The Token Plan quota fetcher is cookie-authenticated (the inference API key
cannot read the console gateway), but no modal field existed to paste that
cookie — so the quota was unconfigurable from the dashboard and the fetcher
could only ever return its 'needs a cookie' message.
Adds the field for qwen-cloud-token-plan and bailian-coding-plan alongside the
existing ollama-cloud / alibaba console-cookie inputs (same password-input,
blank-keeps-stored semantics), pre-fills it when editing a connection, and
extends the providerSpecificData string/length validation to the two new keys.
Tests: tests/unit/qwen-token-plan-cookie-field.test.ts (RED before, GREEN
after) covers persistence + trimming, the blank-input no-overwrite rule and
schema acceptance/rejection.
Refs #9603
* docs(dashboard): correct the Qwen console cookie instructions
The placeholder claimed the cookie looks like 'token=...'; the qwencloud
portal actually issues 'login_qwencloud_ticket=...' alongside cna/cnaui/aui
(mirroring login_aliyunid_ticket on the Alibaba console), so the hint pointed
at the wrong value.
Replaces the guesswork with the verified retrieval steps in all three places
an operator can hit — the modal field hint, the fetcher's 'needs a cookie'
message and .env.example/ENVIRONMENT.md: log in to home.qwencloud.com >
Billing > Subscription, F12 > Network, reload, filter by api.json, click a
request to cs-data.qwencloud.com and copy the WHOLE Cookie request header.
Also documents that the value must go on one line (it contains '=' and ';')
and that it dies with the browser session.
Refs #9603
* fix(dashboard): tolerate partial form objects in the qwen cookie branch
Adding bailian-coding-plan to QWEN_TOKEN_PLAN_PROVIDERS routed callers that
previously matched NO branch in assignQuotaScrapingProviderData into the new
one, which assumed the two new fields are always present. Older callers build
a partial form object, so buildAddProviderSpecificData threw:
TypeError: Cannot read properties of undefined (reading 'trim')
(tests/unit/dashboard/agentrouter-connection-modal-fields.test.ts)
Reads the new fields with optional chaining and adds a regression test that
calls the helper with those keys deleted for both providers.
Refs #9603
* refactor(dashboard): move quota-scraping form logic into a UI-free module
tests/unit/qwen-token-plan-cookie-field.test.ts imported QuotaScrapingFields
directly, which pulls `@/shared/components` and, through that barrel,
untranspiled ESM (@lobehub/icons). The node:test runner cannot parse it and
the whole test file died in CI with:
SyntaxError: Unexpected token 'export'
at @lobehub/icons/es/Ai21/components/Mono.js
(It passed locally, so only the CI shard surfaced it.)
Extracts the pure pieces — QWEN_TOKEN_PLAN_PROVIDERS, QuotaScrapingFieldValues,
EMPTY_QUOTA_SCRAPING_FIELDS and assignQuotaScrapingProviderData — into
quotaScrapingFieldValues.ts. The component imports them and re-exports the
public names, so every existing importer keeps its current path. The unit test
now targets the UI-free module.
Refs #9603
* fix(providers): point bailian-coding-plan at the Token Plan endpoint and its console
Two independent defects kept this provider unusable with a valid Alibaba
Token Plan key (verified live 2026-08-14 with the owner's key and cookie):
1. Wrong inference host. The catalog entry is named "Alibaba Token Plan",
links to token-plan-overview and its hint asks for a Token Plan key, but
the registry pointed at coding-intl.dashscope.aliyuncs.com — the Coding
Plan host, which rejects Token Plan keys with 401 invalid_api_key. The
documented Anthropic base URL for Token Plan is
token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic
(https://www.alibabacloud.com/help/en/model-studio/more-tools). Against
the new host the same key returns 200 for all six registry models and a
real completion; auth stays on x-api-key.
2. Wrong console identity for quota. The personal Token Plan is sold through
two consoles sharing one backend, and the gateway validates the session
against the console declared in the request: an Alibaba console cookie
(login_aliyunid_ticket) sent with the QwenCloud identity is refused with
BailianGateway.Login.NotLogined. resolveConsoleSite() now picks host,
cornerstoneParam.consoleSite/domain and Origin/Referer from the cookie's
login ticket, falling back to the provider. With that switch the same
cookie returns usage/subscription/quota-config.
Also routes bailian-coding-plan quota through the Token Plan fetcher (the
Coding Plan call returns "Bad Request" for these accounts), keeping the old
fetcher as the fallback for real Coding Plan keys, and labels the plan by
console ("Alibaba Token Plan (Pro)" vs "Qwen …").
Live validation: inference 200 (qwen3.7-plus answered "FUNCIONA"); quota
12,934/40,000 credits, 67.7% remaining, resets 2026-08-20.
Refs #9603
---------
Co-authored-by: Xiangzhe <bakryun0718@proton.me>
* feat(sse): add Vertex AI DeepSeek OCR transformation to the registry
Adds VERTEX_DEEPSEEK_TRANSFORMATION (request/response mapping for the
Vertex AI DeepSeek OCR MaaS endpoint) and registers the
"vertex-deepseek-ocr" provider in OCR_PROVIDERS, modeled on litellm's
VertexAIDeepSeekOCRConfig. buildRequest treats the resolved baseUrl as
the complete Vertex endpoint URL (project/location resolved upstream),
matching the existing Mistral passthrough pattern.
* feat(sse): resolve Vertex AI DeepSeek OCR auth and endpoint URL
Adds resolveVertexOcrAccessToken (mints a Vertex OAuth access token from
a Service Account JSON apiKey, reusing open-sse/executors/vertex.ts's
existing JWT-bearer exchange — no new OAuth flow) and
resolveVertexOcrBaseUrl (derives the project/location "openapi/chat/
completions" endpoint from providerSpecificData or the Service Account
JSON's project_id). Both live in open-sse/handlers/ocr.ts, not the
src/app/api/v1/ocr route, since routes may not import executor
implementations directly (EXECUTOR_IMPORT_RESTRICTION in
eslint.config.mjs) — the route re-exports/consumes them across that
boundary. handleOcr now prefers credentials.accessToken over apiKey so
the minted token (not the raw Service Account JSON) is sent upstream.
* docs(api): document the vertex-deepseek-ocr /v1/ocr provider
Adds the vertex-deepseek-ocr row to the /v1/ocr provider table and a
short section on its Vertex AI auth/endpoint resolution, and lists the
new provider/model id in openapi.yaml alongside mistral and
azure-document-intelligence.
* docs(skills): regenerate omni-inference skill for the Vertex OCR provider
---------
Co-authored-by: Xiangzhe <bakryun0718@proton.me>
* feat(ocr): transformation layer on ocrRegistry (Mistral shape canonical)
* feat(ocr): Azure Document Intelligence provider (prebuilt-read, analyze+poll)
* feat(ocr): generic dispatch with per-provider transformation and DI poll loop
* test(ocr): align sanitized-500 assert with HR#12 error sanitization
The test's own title ("returns a sanitized 500") describes the new
behavior mandated by HR#12 (never leak err.message in a response body).
The old regex asserted the pre-sanitization leak (`OCR request failed:
socket closed`) as expected output, which contradicted its own title
and the sanitization this task intentionally introduced in
open-sse/handlers/ocr.ts. Scoped to this single assertion only.
* fix(ocr): fail fast on non-ok poll responses instead of misleading 504
pollOcrOperation now checks pollRes.ok and returns a sanitized 502
immediately (logging the upstream status via console.error) instead of
looping until the 30-attempt cap and surfacing a misleading timeout for
what was actually an auth/upstream error during polling.
* feat(ocr): route/docs for multi-provider /v1/ocr
- Route: map the connection's providerSpecificData.baseUrl onto
credentials.baseUrl (resolveOcrCredentials) so azure-document-intelligence
connections resolve their endpoint the same way every other custom-endpoint
provider does (src/lib/providers/validation/*); previously handleOcr only
saw a baseUrl when a caller set it directly, so the DB-backed Azure
connection endpoint was never forwarded.
- v1OcrSchema.model is already a free-form string, no schema change needed.
- Docs: add the /v1/ocr provider table + example + Azure poll-flow note to
API_REFERENCE.md, and describe the provider/model prefix + async poll
behavior in openapi.yaml.
- Test: tests/unit/ocr-route-contract.test.ts covers getAllOcrModels/
parseOcrModel for both providers and resolveOcrCredentials's mapping.
* chore(quality): rebaseline deadExports for the OCR/image-to-text series
* docs(skills): regenerate omni-inference skill for the multi-provider /v1/ocr
The generated agent skill mirrors docs/reference/API_REFERENCE.md; updating the
/v1/ocr section left it stale and tripped the merge-integrity gate.
---------
Co-authored-by: Xiangzhe <bakryun0718@proton.me>
* test(bridge): explicit native-vision skip guard + skip log
* feat(bridge): configurable describe output cap (modalityBridgeVisionMaxChars)
* feat(dashboard): maxChars field on Modality Bridge vision tab
Add the "Max description characters" field to the Vision tab's Advanced
panel (modalityBridgeVisionMaxChars, clamped to the 100-50000 schema
range with 0 treated as the explicit "unlimited" sentinel), wire the
en.json copy and sync it across all 42 locales, and document the new
setting in GUARDRAILS.md.
* fix(bridge): allow explicit 0 to disable the describe cap
updateSettingsSchema previously rejected modalityBridgeVisionMaxChars: 0
because the field's range was min(100).max(50000), so a dashboard PATCH
sending the explicit "unlimited" sentinel would 400. Widen the schema to
z.union([z.literal(0), z.number().int().min(100).max(50000)]) so 0
validates as its own valid value, not just an implicit default.
* chore(i18n): resync locale keys after release merge
* chore(quality): rebaseline deadExports for the OCR/image-to-text series
---------
Co-authored-by: Xiangzhe <bakryun0718@proton.me>
`CustomModelEntry` never declared `outputTokenLimit`, but the DB persists it
(src/lib/db/models.ts) and the catalog reads it (src/app/api/v1/models/catalog.ts),
producing TS2551 under the open-sse typecheck gate.
Verified locally against release/v3.8.50 @ 90458a613c: TS2551 count in
models/catalog.ts goes 2 -> 0, and model-token-limit-catalog.test.ts passes 5/5
with the added max_output_tokens projection assertion.
* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)
Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.
Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.
npm audit → 0 vulnerabilities.
* fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)
_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.
* feat(codex): converge OAuth fingerprints
* test(codex): preserve identity assertions
* fix(codex): preserve explicit off identity
* fix(codex): close fingerprint transport gaps
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@outlook.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* test: reproduce model param filter close persistence
* fix(dashboard): persist model param filters on popover close (#8910)
ModelCompatPopover declared providerId/modelId in its props type but never
destructured them, so both param-filter fetches referenced undefined
identifiers (TS2304, frozen in the dashboard-typecheck baseline) and threw
into a silent catch. CustomModelsSection also never passed the two props.
- Destructure providerId/modelId; pass them from CustomModelsSection.
- Save pending block/allow drafts when the popover closes or unmounts, so an
outside mousedown no longer discards them.
- Read drafts from refs at save time and guard concurrent saves, avoiding
stale-closure payloads and duplicate PUTs.
- Keep dirty state and drafts on non-OK/failed GET or PUT instead of silently
clearing them; skip state updates after unmount.
- Provider-level block/allow, autoLearn, and other model entries are preserved;
an empty block+allow still removes only the selected model entry.
- Ratchet the three now-clean dashboard-typecheck baseline entries.
Compat-toggle and upstream-header paths are unchanged.
* fix(dashboard): avoid lost update and surface failed param-filter saves (#8910)
The close-time save could clear the dirty flag for a payload snapshotted
before the PUT resolved, silently discarding any keystroke that landed in
that window. Track a monotonic draft revision and only acknowledge the
revision that was actually written, re-running the save (bounded) otherwise.
A failed save previously stayed dirty to 'retry on a later close', but
reopening the popover reloaded server state and silently reverted the
draft. Keep a dirty draft for the same provider/model on reopen and show a
failure marker next to the saving indicator instead.
* fix(dashboard): protect dirty param-filter drafts from load-effect clobber (#8910)
The retained-draft guard in the param-filters load effect required
paramLoadedKeyRef to match the current target, but that ref was only
assigned after a successful GET. Any draft typed before a successful load
for that target was therefore unguarded, and the clean-slate write
overwrote both the text and the dirty flag:
- a draft typed while the INITIAL load GET was still in flight was
overwritten and its dirty flag cleared, so the close-path save became a
no-op and the keystrokes vanished with no feedback;
- after a FAILED initial load, the retained draft was destroyed by the next
successful reopen load — the exact moment the user reopens to retry —
and the failure indicator was cleared as if the save had succeeded.
Track the target on the dirty flag itself (paramDirtyKeyRef, set when the
draft is marked dirty) instead of deriving it from a completed load, and
re-check the guard after the GET await so a load result never overwrites
text, clears dirty, or clears the failure indicator for a draft that is
not on the server.
* fix(dashboard): bind the param-filter save to the draft's own target (#8910)
saveModelParamFilters guarded on paramDirtyRef alone and read the
providerId/modelId it closed over, never the target the draft was typed
for. ModelCompatPopover is not always keyed by a stable identity
(CompatibleModelsSection keys by `${alias}:${modelId}`,
PassthroughModelsSection by the full model string, and providerId is
threaded from route/page state), so a re-render can re-point a live,
mounted popover at a different provider/model. If the old target's save
had failed or never ran, the still-dirty draft was then PUT into the NEW
target — writing a filter list under a model/provider the user never
edited and destroying that target's real config.
Replace the dirty flag / revision counter / dirty-key trio with a single
ParamFilterDraft ref that carries the provider, model and both field
values captured at edit time. The save drives its GET, PUT and payload
from that draft instead of the current props, re-reads the ref after
each await (restarting the attempt if the draft was replaced by one for
another target), and only clears it when the exact draft object it wrote
is still pending. Object identity replaces the revision counter, keeping
the existing lost-update protection.
A load no longer clears the draft or the failure indicator: a draft
pending here belongs to another target and is still owed a write to it.
An orphaned draft is therefore neither dropped nor redirected — it keeps
its own provider/model, keeps the failure marker visible, and is retried
by the next blur/close/unmount save. The cleanup effect also depends on
the target key so re-pointing the popover flushes the old draft.
* fix(dashboard): keep param-filter fields and drafts bound to their own target (#8910)
Two remaining defects of the #8910 silent-data-loss family, both reached through
the re-point path of a live ModelCompatPopover.
1. The inputs render blockText/allowText, whose only writer was the load effect —
and that effect early-returned whenever a draft was dirty for the target. So
re-pointing A -> B -> A left B's server values on screen under A, and the next
keystroke snapshotted them into A's draft, persisting B's content into A's
entry. The fields are now a function of the target: on return to a target with
a pending draft the draft is restored into the inputs, and on a target with no
draft the previous target's values are cleared instead of being left behind.
An edit also no longer trusts the counterpart field unless the values on
screen belong to the target being edited.
2. The pending draft lived in a single slot that every edit overwrote, so typing
into a newly pointed target destroyed the previous target's unsaved work while
the new target's successful save cleared the failure indicator — a green UI
over data that was never written. Drafts are now keyed by provider/model; the
save drains every pending draft against its own target, and the indicator
reflects unsaved work across all targets rather than the last write.
Regression tests: modelCompatPopover-param-filter-target-repoint.test.tsx
(3 cases, RED at ecd111489, GREEN here). Scope limited to this component.
* fix(dashboard): drain midflight param-filter drafts (#8910)
* fix(dashboard): serialize cross-row param-filter saves (#8910)
* docs(changelog): add fragment for #9013
* chore: remove debug console.log and O5 test prefix
---------
Co-authored-by: 千乘妍 (Xiaoyaner) <xiaoyaner0201@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Moonshot approved a 15% extra-credits offer for new users' first top-up,
attached to a dedicated tracked link issued for OmniRoute (valid through
2026-09-30). The dashboard banner and the three README API platform
placements now use that link, and the banner description leads with the
discount in all 43 locales, keeping the commitment made when the offer
was requested. The README CTA gains the 15% mention. A code comment
marks the strings to revisit after 2026-09-30 if the offer is not
renewed.
Co-authored-by: backryun <bakryun0718@proton.me>
* fix(dashboard): expose OpenAI Responses store toggle for non-Codex connections
`EditConnectionModal` only rendered and saved the "OpenAI Responses store"
toggle (providerSpecificData.openaiStoreEnabled) inside the Codex-only
settings block, even though the backend policy that reads this flag
(open-sse/utils/responsesStatePolicy.ts::isOpenAIResponsesStoreEnabled,
applyResponsesPreviousResponseIdPolicy) is already fully provider-agnostic,
and the component already computes a generic `isResponsesConnection` flag
(provider === "openai" or any openai-compatible-responses-* connection, in
addition to codex) that the sibling `preserveEncryptedReasoning` toggle
already correctly uses.
Net effect: an operator with a plain OpenAI API-key connection, or any
generic OpenAI-Responses-compatible proxy connection, had no way anywhere in
the dashboard to opt that connection into `store`/`previous_response_id`
continuation — the policy layer was ready, the control just never rendered
for anything but Codex.
Move the toggle (and its save-time write) out of the isCodex-only block and
gate it on isResponsesConnection instead, matching preserveEncryptedReasoning.
Renamed the local formData field from codexOpenaiStoreEnabled to
openaiResponsesStoreEnabled since it is no longer Codex-specific.
Regression test added (TDD): renders the modal for a plain provider:"openai"
connection and asserts the toggle is present and reflects a persisted flag —
fails on the pre-fix code, passes after.
* fix(responses): stop store-marker leak into Chat Completions requests
The OpenAI Responses store toggle exposed in the previous commit was only
half the fix: the actual store functionality was broken for any model
routed to /v1/chat/completions instead of /v1/responses (e.g. gpt-5-nano,
which lacks the responses-only targetFormat capability). translateRequest
stashes the client's Responses-shaped store intent under an internal
_omnirouteResponsesStore marker so a later re-conversion back to Responses
shape can restore it as store -- but when the destination stays in Chat
Completions shape, that re-conversion never runs, nothing else consumed
the marker, and it leaked verbatim into the real upstream request body.
OpenAI's own API rejects it with 'Unknown parameter: _omnirouteResponsesStore'.
Confirmed live against the real OpenAI API.
Fix: drop the marker unconditionally at the end of translateRequest once
translation is complete, regardless of destination format. Chat Completions'
own store field means something different (dashboard eval storage, not
Responses-style previous_response_id continuation), so the client's intent
must not be silently remapped onto it either -- it's simply dropped.
Also fixes a real crash discovered while live-testing store-enabled
requests: src/sse/handlers/chat.ts referenced isProviderBreakerFailureStatus
without importing it (only the unused PROVIDER_BREAKER_FAILURE_STATUSES
constant was imported), turning a clean 429/'no credits' response into an
uncaught ReferenceError whenever all provider accounts were rate-limited.
Confirmed live (container logs showed the exact ReferenceError before the
fix, and clean error responses after).
Plus two small unrelated base-red fixes needed to get the test suite
running at all on this branch: a broken relative import in
conol-web/index.ts (one path segment short, pointed at a nonexistent
directory), and a real syntax error in gateways.ts (missing closing brace)
that broke esbuild's TypeScript transform for every test file that
transitively imports it, including the pre-existing combo-breaker-429
suite used to verify the isProviderBreakerFailureStatus fix doesn't
regress breaker classification.
Regression test: tests/unit/responses-store-marker-leak.test.ts (confirmed
failing before the translator/index.ts fix, passing after).
⚠️ base-red inherited: migration 143_job_registry.sql duplicated an
already-existing 146_job_registry.sql (byte-identical migration body,
confirmed via diff); the 143 file is deleted since 146 is canonical per
SCHEMA_VERSION_RENAMES. Needed for translateRequest's DB-backed model
capability lookup to run at all in tests.
* fix(api): custom-model delete no longer tombstones a same-id synced model
DELETE /api/provider-models is addressed by `provider` + `model` alone, so
it cannot tell a manually-added custom row from a provider-synced row that
shares the same id. It removed both unconditionally and, because the synced
removal reported success, wrote `isDeleted:true`.
That tombstone is permanent: replaceSyncedAvailableModelsForConnection
filters deleted ids out of every re-import via getModelIsDeleted, so the
provider can never resync. Model sync keeps reporting `added: N` while the
catalog stays empty and /v1/models never lists the model again — even
though routing to it still works, which makes the provider look broken
only in discovery.
Reachable via: eye-hide the synced models (#3782 keeps them in the synced
store), manually add custom models with the same ids, then delete those
custom models. The originals disappear from the catalog while the UI still
lists them.
Remove the custom row first and treat its presence as the operator's
intent, so only a synced-only delete tombstones. #3199 (deleted models stay
dropped) and #3782 (eye-hidden models survive re-sync) are unaffected.
Covered by tests/unit/synced-model-delete-custom-sibling-tombstone.test.ts,
which drives the real route handler: A fails without this change, B guards
the #3199 path.
* chore(changelog): rename fragment to the assigned PR number #10228
* fix(api): reach DeepSeek V4's native max reasoning tier
DeepSeek V4 accepts reasoning_effort low | high | max, defaults to high,
and maps medium and xhigh down to high
(https://api-docs.deepseek.com/api/create-chat-completion; the upstream 400
on an invalid value enumerates none, minimal, low, medium, high, xhigh, max).
OmniRoute's canonical vocabulary is none|low|medium|high|xhigh, where `max`
is an alias collapsing onto `xhigh`. DeepSeek then maps `xhigh` back down to
`high`, so a client sending {"effort":"max"} silently got high — the model's
top reasoning tier was unreachable through the canonical field, and the
catalog never advertised `max` as an available tier.
Mirror the existing extendCodexGpt56EffortValues precedent: expose the
provider-native tier for these models only, without widening the global
request vocabulary. CANONICAL_EFFORT_VALUES and normalizeEffort() are
unchanged, so every other provider keeps collapsing max -> xhigh.
Scoped to the native `deepseek`/`ds` provider. Routed namespaces that merely
carry "deepseek" in the id (openrouter/deepseek/..., tllm/deepseek_v4,
oc/deepseek-v4-flash-free) terminate at a different upstream whose effort
vocabulary we do not control, so they keep the canonical behavior. The
provider is not resolved yet where the canonical params are folded in
(chat.ts), so the check also accepts a `<prefix>/<model>` id.
An explicit client reasoning_effort / reasoning.effort still wins, as before.
Covered by tests/unit/deepseek-native-max-effort.test.ts: 4 of its 6 cases
fail without this change.
* chore(changelog): add fragment for #10230
Remove the Puter provider (id `puter`, alias `pu`) entirely, at the
request of Puter's owner, Nariman Jelveh:
- registry entry (open-sse/config/providers/registry/puter/) and
PuterExecutor (open-sse/executors/puter.ts), with their registrations
- API-key preset card (gateways.ts), provider icon and public SVG asset
- 33 free-model catalog entries (pool `puter`)
- authHint i18n key across all 43 UI locales
- credential-requirement frozen-list entry and related comments
- docs: ARCHITECTURE, CODEBASE_DOCUMENTATION, FREE_TIERS (removal note),
PROVIDER_REFERENCE regenerated (337 providers), translated doc mirrors,
llm.txt + its 42 i18n mirrors, README/AGENTS/package.json counts
(338→337 providers, 144→145 migrations) and the 5 canonical SVGs
- migration 152 cleans up stored puter connections/keys/custom models;
historical usage records are preserved (same principle as migration 151)
- regression guard: tests/unit/puter-provider-removed.test.ts; puter
fixtures in shared tests swapped for neutral providers; translate-path
golden snapshot regenerated
Historical CHANGELOG mentions are intentionally preserved; the removal
carries its own CHANGELOG entry.
Co-authored-by: backryun <bakryun0718@proton.me>
Moonshot cannot sell coding plan subscriptions to most new users, so the
Get Kimi Code traffic could not convert. At their request the dashboard
banner and the README promotional spots now point at
platform.kimi.ai?aff=omniroute with partner-approved copy, in all 43
locales. The dismissal key bumps to -v2 so the retargeted banner shows
once to users who dismissed the old one, and the in-app title finally
adopts the founding Open Source Friend framing agreed in July (title key
renamed to foundingFriendTitle per the value-drift gate rename rule).
Kimi Code provider pages keep kimi.com/code on purpose: they serve
existing coding plan subscribers. Also un-excludes the now-fixed
kimiSponsorBanner.test.tsx from vitest.config.ts (#8618 is closed).
Co-authored-by: backryun <bakryun0718@proton.me>
The Build CI job is advisory, so eight module-level defects from eight
different PRs accumulated on release/v3.8.50 until `npm run build` failed
with 7 Turbopack errors and `npm run lint` with 14.
Build (link-time):
- modelSelectModalHelpers.ts: a lost `}` swallowed PROVIDER_TEST_CHUNK_SIZE
into isProviderModelHidden's body (#9011).
- videoGeneration.ts: handleFalVideoGeneration imported twice; the standalone
falHandler.ts is superseded by the provider-neutral mediaGeneration/fal.ts
and is removed here (#9982 over #9969).
- catalog.ts: re-exported and called the injectable SWR policy that #9199
deliberately replaced with a fixed 30s bound. Fixed on the consumer side —
restoring the accessor would resurrect the unbounded window #9199 removed
after measuring a 41s catalog build in production.
- tinycmsSigner.ts: generated wasm-bindgen glue kept a sidecar
`new URL('wasm_signer_bg.wasm', import.meta.url)` that no file backs;
Turbopack resolves it statically. The module ships inlined as WASM_BASE64
and the only caller always passes it explicitly (#8736/#10087).
- conolDiscovery.ts: imported getProviderOutboundGuard from outboundUrlGuard,
which does not export it. Fixed on the consumer side: outboundUrlGuard.ts is
loaded by the packaged CLI without a tsconfig, so it must stay free of
`@/`-aliased imports (#7682).
Runtime (the build never caught this one):
- catalogCache.ts::scheduleBackgroundRefresh had two dangling statements
referencing undeclared `inFlight`/`promise`, so EVERY stale-while-revalidate
read threw a ReferenceError. Surfaced by realigning the #8728 suite, which
#9199 left asserting a removed contract.
Lint:
- driverFactory.test.ts: a case inserted between the preceding test's `finally`
and its `});` left the file unparseable, so the SQLite driver-cascade suite
(26 tests) had not run since 2026-08-11 (#9173).
- providerModelsConfig.ts: imported an executor directly, crossing the G14
boundary; routed through a new open-sse/services/zaiWebCredentials.ts (#8451).
- image-combo.test.ts: 11 `any` violations, now typed (#9499).
Validation: npm run build exit 0, npm run lint clean, typecheck:core clean,
41/41 tests green across the affected suites.
Refs #9011#9982#9199#8728#8736#10087#8974#9173#8451#9499
Co-authored-by: backryun <bakryun0718@proton.me>
* fix: complete Z.ai web browser transport
* refactor: address Z.ai review feedback
* test(zai-web): reconcile the #8014 endpoint guard with the chats/new + signed flow
Rebasing onto release/v3.8.49 pulled in #8503, which repointed CHAT_URL to
/api/v2/chat/completions and added an endpoint probe. This branch already
targets v2, so the executor conflict resolved to this branch's superset
(NEW_CHAT_URL + signature constants alongside the same v2 CHAT_URL). The two
tests needed adapting, because #8503's assertions assume the pre-rework flow:
- executor-zai-web.test.ts: the completion URL now carries the request
signature as a query string, so an exact-equality check on the endpoint can
never match. Assert the v2 prefix instead.
- zai-web-chat-endpoint-8014-probe.test.ts: the probe drove the executor with a
bare cookie credential and no captcha proof, which now routes through the
browser transport — fetch was never called and the probe captured nothing.
Supplied a direct-path credential, and matched on pathname across all
requests (the executor also probes the homepage for the frontend version and
calls /api/v1/chats/new first).
The guard's intent is unchanged and slightly strengthened: it now asserts no
request reaches the stale unversioned path and that exactly one completions
request is issued, against v2.
54/54 across the zai suites; typecheck:core and eslint clean.
* fix(zai-web): surface upstream error frames instead of finishing empty
Reported on this PR: HTTP 200, `out=0`, stream "complete", no content and no
diagnosis.
Cause. HTTP-level failures are already handled — fetchUpstream turns any !ok
response into a makeErrorResult with the sanitized body. The gap is a 200 whose
SSE body carries an error payload: parseZaiFrame returns null for it,
drainSseDeltas drops it, and buildZaiStreamingBody then closes with an empty
assistant message + stop + [DONE]. The caller reads that as a successful empty
completion, so a rejected signature, an expired captcha and a stale token all
look identical — which is why this had to be diagnosed by reading code rather
than logs. Hard Rule #6.
Fix. parseZaiFrame now classifies an affirmatively error-shaped frame
(`error` at the top level or under `data`, string or {detail|message|msg}) as a
terminal delta, checked before the delta paths so it cannot fall through to the
"no usable delta" null. The stream emits it as `[Z.ai error] <message>`,
matching the mid-stream convention the other web executors already use
(zed-hosted's createErrorChunk) — the 200 is on the wire, so the status cannot
change, but the caller must not be left reading a blank success. Content
streamed before the failure is preserved. Message goes through
sanitizeErrorMessage (Rule #12).
Deliberately NOT changed: a contentless frame still parses to null. That is
live-validated behaviour, not an oversight — z.ai emits phase frames with no
delta_content, and executor-zai-web.test.ts pins it ("returns null for frames
with no usable delta"). Treating "nothing parseable arrived" as a failure would
invent policy on top of an observed protocol and risk false errors on the happy
path, so this only adds recognition of explicit error frames.
Tests (TDD, RED then GREEN): zai-web-silent-empty-repro.test.ts — 7 cases.
Error frame classified and terminal; surfaced through the stream with the
upstream's own text; surfaced after partial content without losing it; plus a
REGRESSION GUARD that contentless/phase-only frames are still skipped, and two
controls that the happy path and reasoning-only output are untouched. The guard
and controls passed before the fix; the four error cases did not.
94/94 across the zai + stream suites; typecheck:core, eslint and check:file-size
clean.
* refactor(sse): extract the zai-web transports so the complexity ratchet holds
The v3.8.49 merge-train rebaseline (#8686) set the ceiling to the tip's own
measurement, leaving zero headroom, so this branch's +5 cyclomatic / +3 cognitive
own-growth had nowhere to sit once rebased onto it.
Eight violations, all in code this branch introduces, resolved by extraction —
no behaviour change:
- `execute` (152 lines, complexity 25, cognitive 20) now delegates to
`resolveZaiRequest()` for the four client-error rejections and to a
`fetchViaSignedApi()` method for the CAPTCHA/signature path, so it reads as
"validate, pick a transport, shape the response".
- `fetchThroughBrowser` (126 lines, cognitive 16) hands its image decoding to
`resolveZaiBrowserAttachments()`, its Playwright options to
`buildZaiBrowserChatOptions()`, and its call-log payload to
`buildZaiBrowserAuditBody()`.
- `configureZaiBrowserEffort` (cognitive 35 — the worst of the set) repeated a
wrap-and-relabel try/catch four times inside an if/else. `runStage`, which
already existed one function below, is now module-scoped and reused, and the
toggle collapses to `checked !== config.enabled` (same four cases).
- `validateWebCookieProvider` (complexity 19) moves its can-we-probe-this
cascade into `resolveWebCookieProbe()`, which returns either a rejection or
the URL + headers to use.
- `acquireBrowserContext`'s creation closure (complexity 17) hands cookie and
localStorage seeding to `seedContextSession()`.
That last extraction also clears a violation that predates this branch —
`acquireBrowserContext` was already over the 80-line ceiling — so cyclomatic
lands at 2187 against a baseline of 2188.
Verified: check:complexity-ratchets green both metrics; typecheck:core clean;
ESLint clean on all four files; 85 tests across the zai-web, web-cookie
validation, browser-pool and model-test-runner suites pass.
* fix(zai-web): surface upstream errors on the non-streaming path
collectZaiNonStreaming ignored delta.error — a 200 whose SSE body carries
an error frame (rejected signature, expired captcha, stale token) came
back as a successful empty completion. Now it throws on an error frame,
matching the streaming path's [Z.ai error] convention; the caller's
existing try/catch returns makeErrorResult(502) instead of an empty 200.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: backryun <busan011@ormbiz.co.kr>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* feat(devin-desktop): replace public Windsurf provider
* fix(migrations): renumber Devin Desktop migration to 151 (avoid 147 collision)
147_windsurf_to_devin_desktop.sql collided with the released
147_api_keys_model_access_mode.sql — getMigrationFiles throws
"Migration version collision detected" on every DB start. Base occupies
slots up to 150, so renumber the new migration to 151 and point the
windsurf→devin RENAMED_MIGRATION_COMPATIBILITY entries (and tests) at it.
147 is freed in KNOWN_GAPS since 147_api_keys now owns the slot.
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* feat(api-manager): add provider-level model permissions
Persist canonical provider wildcards alongside exact model grants and
preserve explicit restricted-empty deny-all semantics across API, SQLite,
JSON import, sync, runtime policy, and the dashboard.
Invalidate filtered model catalogs on permission changes and guard against
stale in-flight catalog builders repopulating invalidated cache entries.
* fix(api-manager): show provider and model counts separately in summary
Provider wildcard selections (provider/*) are no longer counted as
individual models in the Selected Models Summary. The header now shows
"N providers · M models" when both are present, or just the non-empty
category when only one type is selected.
* fix(api-manager): separate provider and model permission displays
* fix(api-manager): separate provider wildcard permissions in UI
* fix(executors): route Claude-via-Vertex through native rawPredict with real streaming
Claude models on Vertex AI were being sent through the generic OpenAI-
compatible partner endpoint, which 404s/errors for Claude on at least
some projects. Route them through Vertex's native Anthropic Messages
API (publishers/anthropic/.../rawPredict) instead, stripping the
body-level model field rawPredict rejects and injecting the required
anthropic_version field.
rawPredict only ever returns a complete JSON body, never real SSE
framing, so streaming requests now get a genuine Anthropic-format SSE
stream synthesized from that JSON (message_start/content_block_*/
message_delta/message_stop), which the existing claude-to-openai
response translator already knows how to parse.
Also fixes two response-format resolution bugs that silently dropped
a custom model's DB-stored targetFormat override whenever the model
id also existed in the static provider registry (as claude-sonnet-4-6
and claude-opus-4-7 do under vertex): resolveModelOrError had its own
ad-hoc resolution that never consulted the override, and even once
fixed, executeChatWithBreaker discarded the correctly-resolved format
before handleChatCore's own resolution ran a second time.
* docs: add changelog fragment for #8909
* refactor(sse): extract shared Claude effort-model predicate
* fix(sse): strip Claude effort-suffix ids for any provider serving a real Claude model
* fix(sse): keep no-think and CC-discovery catalog variant roots unprefixed
* fix(dashboard): re-qualify no-think playground model ids correctly
* fix(sse): scope Vertex 404s to a per-model lockout via passthroughModels
* docs: add changelog fragment for the Claude catalog/dispatch fix
* fix(sse): align regex naming and changelog formatting
* fix(sse): clarify effort-variant strip comment and add cross-module drift guard
* fix(sse): disambiguate Vertex connection-wide vs per-model 403s
* docs: document Vertex 403 disambiguation in changelog fragment
* fix(sse): correlate reason and resource within the same ErrorInfo detail
* fix(sse): extract Vertex error classifier and rebaseline frozen file sizes
* test: register vertex-passthrough-model-lockout in stryker tap.testFiles
* fix(sse): reconciles rebase-onto-tip drift for 9006
Two categories of inherited base-branch breakage surfaced when
rebasing onto release/v3.8.50's latest tip, both confirmed unrelated
to this PR's own diff:
- check:file-size: base.ts and chat.ts drifted further past their
frozen caps via already-merged commits (7163081f5 and others) that
didn't rebaseline after growing them. Documented and bumped in
file-size-baseline.json.
- chat-helpers.test.ts: two gpt-5.5 routing assertions predate #9275
(fix(routing): bare model ids route to codex first), which
deliberately made gpt-5.5 route to codex unconditionally, regardless
of which other providers are active. Confirmed via #9275's own
commit message and code comments this is intentional, not a
regression; verified reproducible on the raw base tip alone, with
no changes from this PR involved. Updated both assertions and their
names to match the new, intentional default.
* ci: re-trigger checks after GitHub Actions incident (2026-08-07, resolved)
* ci: re-trigger checks (previous push event was dropped)
* fix(quality): rebaseline combo-routing-engine.test.ts own-comment growth
The ALL_ACCOUNTS_INACTIVE->ALL_TARGETS_SKIPPED fix (a32aed738) added explanatory comments (+7 lines), pushing the file past its frozen 3457 cap. CI's PR-mode check:file-size caught it; local check-file-size.mjs was not re-run after that specific commit.