Repository navigation
Releases: luqman-v1/9router-go
Release list
v1.9.14-exp.3
Experimental build. Not published to
latest, and9router-go update
will not offer it to an install running a stable release. Track it
explicitly:docker pull luqmenul/9router-go:1.9.14-exp.3Issues found here are still worth reporting — but check the stable
release first, because experimental breakage is usually already
fixed by the next tagged build.
fix(proxy-pools): never dial an edge relay as an HTTP CONNECT proxy
A proxy pool whose type was left at the default "http" while its URL pointed
at a self-hosted edge relay was dialed as a CONNECT proxy. The CONNECT attempt
sent a raw TLS handshake at the relay, and the HTTP client read the relay's
plain-text reply as a status line:
malformed HTTP status code "\x01@\x00\x01\x00\x00\x10\x00\x00\x04\x00…"
ForwardOpencode: forward to https://opencode.ai/inference/v1/chat/completions
The same install worked as soon as the pool was detached, which is the tell: the
relay is a forwarding HTTPS endpoint, not a tunnel, and ProxyPool.IsEdgeRelay()
decided which one it was from the type field alone. With "http" there it
returned false, so getProviderConfig also skipped the rewrite that points
BaseURL at the relay and adds x-relay-target / x-relay-path — the two
things that make a relay work. Every request through that pool failed at the
transport, before any provider saw it.
internal/db/proxyPools.go:
IsEdgeRelayURL(rawURL)recognises the platform hosts that serve relays
(*.vercel.app,*.workers.dev,*.deno.dev,*.netlify.app) and the
/.netlify/functions/relaypath.IsEdgeRelay()now also returnstruewhen the declared type is""or
"http"and any pool URL matchesIsEdgeRelayURL. A relay URL created
with the default type can no longer be routed into the proxy path. The
explicitvercel/cloudflare/deno/netlifytypes are unchanged, and
a real proxy — loopback, a:8080CONNECT proxy,socks5— is still a proxy.
internal/handlers/chat/connections_proxy.go:
getClientForConnectionreturns the plainh.Clientfor an edge relay
instead of wrapping it inhttp.ProxyURL. The request still leaves through
the relay; it simply travels as a forwarded request carrying the
x-relay-target/x-relay-pathheadersgetProviderConfiginjects, not as
a CONNECT tunnel.
Verified. go build ./... and go test ./internal/db/... ./internal/handlers/chat/... pass. New tests:
TestProxyPool_IsEdgeRelay covers the inference per host and the negatives (a
real proxy on :8080, a loopback proxy, socks5, and a lookalike
vercel.app.evil.com host that must not match);
TestGetClientForConnection_RelayPoolDeclaredAsHTTP pins that a relay pool
yields the plain client; and
TestRelayPoolAssignedToNoAuthProviderUsesRelayHeaders exercises the whole path
— a relay pool assigned to the no-auth opencode provider resolves to a
connection whose plain client is used, with BaseURL rewritten to the relay and
x-relay-target: https://opencode.ai plus
x-relay-path: /inference/v1/chat/completions on the forwarded request.
For operators hitting this today: setting the pool's type to the matching
platform (vercel, cloudflare, deno, netlify) also avoids the failure.
The code fix makes the default "http" safe as well, because the old failure
mode was a cryptic transport error rather than a configuration message.
fix(executor): keep OpenAI-only keys off the Claude messages
A replayed assistant turn used to carry the client's remaining keys into the Claude
body. convertOpenAIMessagesToClaude copied every key of a message — the
tool_calls branch everything except tool_calls/content, the fall-through the
map whole — and the top-level cleanup in ensureMessagesMaxTokens never looked at
messages. Anthropic answers a message key it does not define with
400 messages.<n>.reasoning_content: Extra inputs are not permitted, and because
that turn stays in the client's history, every later request of the conversation
failed too.
Normal use reaches it, not a hand-crafted payload: reasoning clients (pi, the
OpenAI SDKs, Hermes) echo back the reasoning_content this proxy streamed to them.
#290 is the request-side half that made Claude start producing the field, so this is
its second turn. The text cannot be turned into a thinking block instead — the
signature Anthropic needs to accept one back is not in the OpenAI shape.
Messages are now rebuilt from an allowlist of the keys a Messages API message
object defines (role, content); SanitizeClaudePassthrough applies the same
rule to Claude passthrough bodies from mixed combos. This matches upstream
open-sse/translator/request/openai-to-claude.js, which rebuilds every message as
{role, content}.
Verified. The Claude upstream returned 400 messages.1.reasoning_content: Extra inputs are not permitted for a replayed reasoning turn before the change and 200
after, on a build of this branch over v1.9.13 with a real Claude OAuth connection.
A turn with reasoning_effort: high still returns reasoning_content (185 chars)
plus its tool_use, and replaying both with the tool_result answers normally; a
real pi session with thinking high that used to die on turn 2 now completes. New
tests assert the body the upstream sees, in both conversion paths, including that
the tool_use/tool_result turns survive. gofmt, go vet ./... and
go test ./... clean.
Fixes
- opencode-go: drop
response_formatfor DeepSeek models inForwardOpencodeGousing anchored prefix matching (strings.HasPrefix(strings.ToLower(cleanModel), "deepseek")), preventing upstream relay HTTP 400 rejection.
feat(access): allowlist globs and deny rules for per-key model access
Per-key model allowlists gain the pattern vocabulary issue #365 asks for, ported
from decolua/9router keyAccess.js (v0.5.104): a wildcard that spans the /
(*claude*), deny entries (!opus, -haiku), and a deny-only list that opens
everything it does not name.
Why: the allowlist matched through the resolver's own glob
(providers.MatchModelPattern, backed by path.Match), where * is
segment-local. An operator writing *claude* — the spelling the issue and
upstream both use — got a key that denied every model, and there was no way to
express "everything except X" short of enumerating the catalogue. Measured
before the change: 7 of 17 cases in TestModelAllowedPatterns failed, among
them *claude* against anthropic/claude-sonnet-4 and every deny-only list.
What changed:
providers.MatchAllowPattern— the allowlist glob.*spans/, matching is
case-insensitive and anchored at both ends, the pattern is literal except for
*, and a pattern without*still answers exactly as before.
MatchModelPatternis untouched so the resolver's capability patterns keep
their segment-local semantics; a test pins that the two cannot be collapsed
into one by accident.providers.IsDenyEntry/providers.MatchDenyEntry—!term/-term
parsing (a lone sigil is a model id, not a rule) and deny matching, which
accepts a glob or a substring so-haikuneeds no asterisk.chat.modelAllowed— parses the list into positives and denies, checks denies
first so a deny always wins over a positive rule, and reads a list with no
positive entry as deny-only: open except denied. An empty or blank-only list
still means "no allowlist configured" and allows everything, which is what
keeps every key minted before this feature behaving as it did.- Dispatch and listing still share the one decision function, so a denied model
is hidden from/v1/modelsas well as refused at/v1/chat/completions.
Verified: go test ./internal/providers/ ./internal/handlers/chat/ — both
packages ok; the 7 previously failing cases are green alongside 40 new matcher
cases.
feat(keys): meter per-key token quotas from the caller, and let a budget be reset
Per-key token budgets are made real (#365): the spend a tokenQuota is measured
against is now attributed to the client that spent it, an exhausted key can be
given a fresh budget without deleting its history, and the dashboard can read the
spend it is rendering.
Why — the quota was inert. usageHistory.apiKey holds the upstream
credential (masked), not the caller: the usage writer stores
maskAPIKey(info.APIKey), and the suite pinned that behaviour elsewhere. The sum
a quota was measured from therefore never matched a client key. On the live
database this was 0 rows out of 634, so checkApiKeyQuota could never fire and a
runaway client was never bounded — which is the first thing the issue asks for.
The unit test that appeared to cover this inserted its own rows keyed by the
client key, so it passed without touching the real write path.
What changed:
usageHistory.clientKey(additive column) records the caller, masked, written
from the request context inlogUsagethrough the new
middleware.AuthenticatedApiKeyFromContext— the auth middleware already put
the key on the context, so no request-path change was needed.
InsertUsageHistorytakes it as a trailing variadic so the fixtures and
migrations that have no caller to name keep compiling unchanged.tokenUsageSincesumsclientKey = ? OR apiKey = ? OR apiKey = ?: the last
two keep rows written by the upstream Next.js dashboard counting, since that
ledger stored the client key where this one stores the upstream credential.
No index is added — the predicate is an OR across two columns, which no
single-column index serves.apiKeys.quotaResetIdplusResetApiKeyQuotaand
POST /api/keys/{id}/reset-quota: the mark is theusageHistoryrow id the
budget is measured from, taken in the same transaction as the write. A row id
and not a timestamp becauseusageHistory.timestampis RFC3339 at second
precision, so a reset issued in the...
v1.9.14-exp.2
Experimental build. Not published to
latest, and9router-go update
will not offer it to an install running a stable release. Track it
explicitly:docker pull luqmenul/9router-go:1.9.14-exp.2Issues found here are still worth reporting — but check the stable
release first, because experimental breakage is usually already
fixed by the next tagged build.
fix(codex): a failed turn is no longer served to the client as an empty 200
Claude Code (and any other client) received an empty response when the Codex
upstream rejected a request — a tool-schema pattern it refuses was the case
reported in #334. The upstream's reason never reached the client, and because
the turn was closed with a fabricated success terminal, combo fallback stopped
on the model that had just failed.
Cause. The failure arrives as an event on an HTTP 200 stream, not as an
HTTP status, so by the time it is known the status line is long gone.
ProcessCodexEvent already recorded such an event on state.UpstreamErr —
its own comment says "handleCodexStream converts to an UpstreamError after the
scan" — but only the non-streaming path did. On the streaming path, which is
the one Claude Code uses, nothing read state.UpstreamErr:
- a
[DONE]in the stream wrote the success terminal (finish_reason: "stop",
orstop_reason: "end_turn"for a Claude client) and the[DONE]right
there, before any error check could run; - when no
[DONE]arrived, the same fabricated terminal was written afterwards.
Both read to a client as a completed answer carrying no content.
Fix. The streaming path now ends a failed, content-free turn the way the
rest of the gateway already ends a failure after HTTP 200 — with its own
in-band error frame (proxy.BuildStreamErrorBytes, the shape openai.go and
bedrock.go use) and a returned *proxy.UpstreamError, so the failure row is
written and fallback rotates instead of settling.
codexFailedEmptyTurnis the one predicate for "the upstream failed and
produced nothing". An error event after content is deliberately not that
case: those turns keep the partial reply and stream normally.response.failedis now recorded as well, andnormalizeCodexFailureEvent
rewrites it into the{"error":{...}}shape the reader understands, so the
upstream's real message survives.codexEventErrorInfois shared by the returned error and the in-band frame,
so the client's error line and the failure row cannot disagree.- The
patternstripping Codex does need (\p{...}Unicode property escapes)
was checked against upstream and is already at parity: upstream's
open-sse/utils/codexToolSchema.jsstrips only those, and lookarounds are
not stripped there either. Nothing was loosened; this issue is the
error path, not the sanitizer. Tool-schema validation semantics are
unchanged.
Verified. internal/proxy/executor/codex_failed_turn_test.go covers both
clients × both event shapes (error and response.failed), the precedence case
(an error after content is not replaced), and a healthy turn still terminating.
internal/integration/codex_failed_turn_test.go boots the real router against a
fake upstream and pins the client-visible contract for /v1/messages and
/v1/chat/completions. Both fail against the pre-fix code, and both pass with
it. go test ./... and make test-integration are green.
Fixes
- dashboard: refuse an
expiresAtthat is not an RFC3339 timestamp. The auth middleware enforces an expiry withtime.Parse(time.RFC3339, …)and skips a value it cannot parse, so a mistyped contract end was stored as a key that never expires; the refusal now names the expected shape (2026-12-31T23:59:59Z, or empty for no expiry). - dashboard: validate the resale
metadatablob instead of storing it verbatim. It must be a JSON object within 2 KB and may only carrycustomerId,contact,notes,priceCents(whole, non-negative cents) andbillingCycle(one-off,monthly,quarterly,yearly); the refusal lists the accepted keys, because a typo such ascustomerIDwould otherwise drop that key out of the resale report silently.
Changed
- dashboard:
PUT /api/keys/{id}validates every field before the first write, so a refused policy no longer half-applies the fields that happened to be checked first — rate limits used to be committed before a bad expiry or an over-long name was rejected. The policy dialog's metadata help text now names the accepted keys and the size cap.
fix(codebuddy-intl): send the X-Conversation-* header family the intl gateway now requires
Since 2026-10-15 www.codebuddy.ai runs a "channel verification" gate: a chat
request that does not carry the X-Conversation-* header family the official IDE
sends on every turn is refused before the model is reached, with
HTTP 400 {"code":11128,"msg":"conversation id missing",
"displayTips":{"en":"This call failed channel verification, possibly because it
was not sent from an official client. Please resend from the official client."}}
100% of cbai/* requests failed that way — the report that opened this is
codebuddy-intl/deepseek-v4.1-flash. codebuddy-cn (copilot.tencent.com) is
not gated, which is why only intl connections broke. Same upstream change as
decolua/9router#4892.
ForwardCodebuddyCN now detects the intl BaseURL (codebuddy.ai) and merges
three headers into a copy of the provider config's StaticHeaders, so the
shared map is never mutated and the CN path is untouched:
X-Conversation-ID— RFC-4122-v5-shaped UUID derived from the session key
(req.SessionID, falling back to the opening user message, then a random
UUID), so retries of the same turn and account failovers stay on one
conversation. The derivation is byte-identical to the JS executor's in
decolua/9router#4928, checked against it directly.X-Conversation-Request-ID— fresh UUID per requestX-Conversation-Message-ID— fresh UUID per message
Verified. codebuddy_conversation_headers_test.go covers header presence and
UUID shape, stability of the conversation id across retries, session-id
precedence, typed-block content, and the random fallback.
Against a live upstream the author A/B'd the same credentials and body with only
the family differing: absent → 400 / 11128, present → 200 SSE. Independently, a
wire-level check against an httptest upstream confirms the three headers reach
the request for the codebuddy.ai BaseURL (with Accept: text/event-stream and
the static headers intact), are absent for the CN BaseURL, and that
StaticHeaders is not mutated.
Residual risk. The gate was confirmed to require the family; that the three
headers it names are sufficient (rather than more of the ~10 the official client
sends) rests on that A/B and could not be re-derived here without an intl
credential. Detection keys on strings.Contains(cfg.BaseURL, "codebuddy.ai"),
which is safe today because the executor is provider-scoped but would need
review if a custom baseUrl ever pointed at that host.
Fixes
- translator: concatenate every
systemanddevelopermessage with\n\ninto Responsesinstructionsduring Chat Completions to Responses translation, instead of silently dropping all but the first (fixes #332, porting decolua/9router#4813).
fix(freebuff): route x-relay-* control-plane calls to their own endpoint
- Thread
StaticHeadersthrough the freebuff session/run helpers (session request, run start/finish, session release, model switch) and stamp relay routing onto those requests viaapplyRelayHeaders, so relay-routed connections no longer bypass the relay on control-plane calls. Freebuff still owns its CLI identity headers; tests passnilto keep single-process behavior. applyRelayHeaderssetsx-relay-pathto the endpoint the call needs, takingx-relay-targetfromStaticHeaders. Copying the connection's header verbatim was wrong:BuildEdgeRelayHeadersderives it from the provider base URL, which for freebuff is the chat route, so a relay forwarded every session/run/release call to/api/v1/chat/completions. Covered byfreebuff_relay_test.go, whose fake relay forwards tox-relay-target+x-relay-pathexactly as the deployed worker does.- Dashboard compatible-model testing: per-model test status/error/latency replaces the single in-flight
compatibleTestId, new Test all button probes with a concurrency ceiling of 2 (each probe consumes account quota), and the import dialog can filter to free-tier models only, mirroringproviders.IsFreeTierModel. The provider-switch reset now also clears the compatible-model verdicts, and aTest allrun that outlives its panel stops writing. - Scope unchanged: relay routing covers the chat pipeline and the freebuff session/run helpers.
HandleFreebuffSessionSwitchandfetchFreebuffUpstreamSessionstill read noStaticHeaders, so a relayed connection switches models straight from the host IP — threading the config there is a follow-up.
Quoted WorkingDirectory= made systemd refuse the autostart unit
service install wrote WorkingDirectory="/usr/local/bin" into the systemd
user unit. systemd parses that setting with config_parse_working_directory,
which runs the whole remainder of the line through path_simplify_and_warn
without unquoting (unlike ExecStart=), so the quotes were taken as part of
the value and the unit was rejected as not absolute:
WorkingDirectory= path is not absolute: "/usr/local/bin"
Unit configuration has fatal error, unit will not be started.
The path is now emitted unquoted, with only a literal % doubled for specifier
expansion; whitespace is not a separator for this setting, so a spaced path
still survives. Closes #329.
The autostart tests now hand each rendered config to a real parser instead of
matching substrings: systemd-analyze verify for the unit, `pluti...
v1.9.14-exp.1
Experimental build. Not published to
latest, and9router-go update
will not offer it to an install running a stable release. Track it
explicitly:docker pull luqmenul/9router-go:1.9.14-exp.1Issues found here are still worth reporting — but check the stable
release first, because experimental breakage is usually already
fixed by the next tagged build.
Fixes
- translator: concatenate every
systemanddevelopermessage with\n\ninto Responsesinstructionsduring Chat Completions to Responses translation, instead of silently dropping all but the first (fixes #332, porting decolua/9router#4813).
Quoted WorkingDirectory= made systemd refuse the autostart unit
service install wrote WorkingDirectory="/usr/local/bin" into the systemd
user unit. systemd parses that setting with config_parse_working_directory,
which runs the whole remainder of the line through path_simplify_and_warn
without unquoting (unlike ExecStart=), so the quotes were taken as part of
the value and the unit was rejected as not absolute:
WorkingDirectory= path is not absolute: "/usr/local/bin"
Unit configuration has fatal error, unit will not be started.
The path is now emitted unquoted, with only a literal % doubled for specifier
expansion; whitespace is not a separator for this setting, so a spaced path
still survives. Closes #329.
The autostart tests now hand each rendered config to a real parser instead of
matching substrings: systemd-analyze verify for the unit, plutil/xmllint
for the launchd plist, sh -n for the OpenRC and runit scripts, and
desktop-file-validate for the XDG autostart entry. Each check skips where its
tool is absent rather than passing silently.
Fixes
- providers: allow models carrying the
-freesuffix inIsOpencodeFreeTierModelon OpenCode providers (opencode,opencode-zen,opencode-go), ensuring preview and uncataloged free models route to/inference/v1onopencode-zenconnections without manual catalog additions (fixes #327, PR #328).
feat(deepseek-web): route chat.deepseek.com with a browser-login userToken
Adds the first provider in this build authenticated by a credential a browser
session produces rather than an API key or an OAuth grant, ported from OmniRoute
open-sse/executors/deepseek-web.ts. The credential is the userToken from
chat.deepseek.com → DevTools → Application → Local Storage.
Why: the deepseek account a user already has is a free consumer web account,
not an API plan, and it has been unusable here. grok-web and perplexity-web
were already catalogued but had no executor at all, so a chat request fell
through to the shared passthrough and sent Authorization: Bearer <cookie> to a
site that answers 401.
What it does, per turn:
GET /api/v0/users/currentexchanges theuserTokenfor a short-lived Bearer
access token, memoized an hour and dropped on any auth failure. A pasted
{"value":"…"}localStorage entry is unwrapped, which is the shape a copy out
of DevTools actually has.POST /api/v0/chat_session/createopens a session, closed again once the turn
is done.POST /api/v0/chat/create_pow_challenge→ a DeepSeekHashV1 proof of work
is solved and returned inX-Ds-Pow-Response. That hash is SHA3-256 with the
last 23 KECCAK-p[1600] rounds; Go'scrypto/sha3only exposes 24, so the
sponge is implemented indeepseek_web_pow.gowith the prefix absorbed once and
reused per candidate, bounded by a 30s deadline and cancellable from the
request context.- The upstream event stream is translated to OpenAI chunks, keeping the
reasoning and answer channels separate and folding search citations into the
answer.
Details worth knowing:
- This API reports auth and rate limiting as HTTP 200 with a code in the
body (40003 / 40002). Both are mapped to 401 / 429, so the router cools down
and rotates the account instead of retrying a dead session. - A stream that closes without the upstream's
FINISHEDsignal is reported as
an error, not as a completed turn. Serving the partial text with
finish_reason: stopis how a client comes to treat a truncated reply as the
model's conclusion. OriginandRefererfollow the connection's configured base URL, so a
baseUrloverride is honoured end to end instead of only for the completion
call.deepseek-webandgemini-webare separate entries fromdeepseekand
gemini, with their own aliases (ds-web,gweb) and their own model
catalogues — pinned by a test, since a cross-alias would send a browser
credential to an API endpoint.AddConnectionModalnow persistsauthType: 'cookie'for a browser-login
provider instead of'apikey'. The probe dispatcher already branched on
cookie, so every cookie provider was taking the wrong probe path.
gemini-web is catalog-only. gemini.google.com has no API the web client
calls directly — upstream drives it with an embedded Chromium — and this build
ships no browser. It is listable and manageable; a chat request is refused with
501 and the reason, and the probe says it cannot check rather than reporting a
valid cookie as invalid.
Verification: go build ./... and go vet ./... clean.
internal/proxy/executor/deepseek_web_pow_test.go pins the 23-round permutation
against OmniRoute's own vectors (deepseek-pow-js-only.test.ts) plus the block
boundary cases those vectors never reach.
deepseek_web_test.go drives a whole turn against one httptest upstream and
verifies the call order, that the proof-of-work nonce really satisfies the
challenge, the browser fingerprint headers, both response shapes, and that a
truncated stream is not served as complete.
validate_web_providers_test.go covers the probe's two misleading answers: a
rejected credential arriving as HTTP 200, and a 200 carrying no token.
deepseek_web_catalog_test.go pins the catalog and the provider isolation.
Live: the credential exchange, session creation and proof-of-work were confirmed
against the real endpoint — the nonce for a difficulty-144000 challenge is found
in ~400ms. The completion itself could not be exercised because DeepSeek's status
page showed an active outage (incident 7116348214287, "Identified") throughout,
so the stream translator's handling of a healthy response remains unproven
against the live site. bun run build, 344 web tests and make vet-svelte
(0 unresolved identifiers) pass.
fix(deepseek-web): read the answer the site actually sends, and recover tool calls
Two defects found by running a real turn against chat.deepseek.com, both of
which the httptest fixtures had been shaped to agree with.
The answer arrived without a topic. The stream carries three shapes, and only
one names one:
{"v":{"response":{...fragments...}}} the answer, topicless
{"p":"response/fragments/-1/content","o":"APPEND","v":"x"} one character at a time
{"p":"response/status","o":"SET","v":"FINISHED"} the sign-off
The decoder dispatched on p, so the answer fell through to a default branch
that handled a bare string and dropped an object — losing the whole reply while
still finding FINISHED. The turn therefore reported success with no text, which
is why a live run answered 502 upstream answered 200 without a completion. A
topicless object carrying response is now read before the topic switch, and the
per-character APPEND form is folded into the channel already open. A plain
question went from 2 chunks to 55 and now returns {"content":"ok"} live.
Tool calling is a convention here, not a protocol. The endpoint has no
tools[] parameter and the stream has no tool-call topic: a model told a tool
exists answers by typing a JSON object one character at a time, in 47 separate
APPEND operations. A client treating that as a normal streaming answer would
hold {"name":"get_weather","arguments":{" with no way to tell a truncated call
from prose.
So the two paths are kept apart. A request carrying tools[] buffers the turn to
completion and parses only whole JSON — the same guarantee the native API gives,
since it does not stream tool arguments either. A request without tools[]
reaches none of that code and streams exactly as before. The declarations are
folded into the prompt, because there is no field to send them in.
Recovery is deliberately conservative: the answer must be a single JSON object
naming a tool the client actually declared. An undeclared name, prose around the
object, or malformed JSON is passed through as ordinary content rather than
guessed at — a wrong guess becomes an invocation the client acts on.
Verified live: a deepseek-v4-pro turn asked about the weather and returned
"finish_reason":"tool_calls",
"function":{"name":"get_weather","arguments":"{\"city\": \"Jakarta\"}"}Also fixed: parameters is accepted as either a JSON Schema object or a
string, since the OpenAI shape sends an object while several SDKs stringify it
first; typing it as a string rejected the common form outright.
fix(deepseek-web): stop the agent loop repeating a tool call forever
An agent driven through this provider called get_weather, received the result,
and called it again on every subsequent turn until its retry cap — reported as
Assistant returned empty stop after retry cap.
The cause was in the prompt, not the model. A tool-calling turn is replayed to
the web API as text, because the endpoint accepts a single prompt string. An
assistant turn that ends in a tool call carries content: null, so it was
dropped from the replay entirely. The transcript reached the model as:
User: What is the weather in Jakarta? Use the tool.
Tool result (get_weather) Sunny, 31C, humidity 70%
A result with no visible cause. The model reads that as "I have not called
anything yet" and calls again, and the loop cannot terminate because ...
v1.9.13
OpenCode: append the exact fingerprint even when caller tools collide
Follow-up to #320, root-caused live: a TUI-shaped body whose read/shell
tools carry non-CLI definitions got its tools aliased (read_client, ...)
but no exact read/shell declaration traveled, and the gate answered 403
"free tier can only be used from within OpenCode" — reproduced against the
real gate (403 before, 200 after, same body).
- The append check now runs on post-alias names like upstream's
appendMissingFingerprintTools, instead of the pre-alias seen set. - Pinned with
TestConcealOpenCodeV2CollidingNamesStillAppendExact
(orderread_client2,read_client,Edit,read,shell, both lanes).
Verified with go test -count=1 ./internal/translator/ ./internal/proxy/ /internal/proxy/executor/ ./internal/handlers/chat/ and two live gate
probes (minimal + colliding-tools bodies, cli and desktop identities).
📊 fix(usage): fix column label precedence in UsageBreakdownTable and aggregate byEndpoint
- Background: In Usage Breakdown, switching to
Usage by AccountorUsage by API Key
consistently displayed the model ID rather than the account display name or API key name,
becauserow.rawModelwas evaluated first in the fallback expression. Additionally,
Usage by Endpointwas always empty becauseHandleUsageStatsnever aggregated the
byEndpointdata. - Frontend: Explicitly checked
tableViewinUsageBreakdownTable.svelteto render
row.accountNamefor accounts,row.keyName/row.apiKeyMaskedfor API keys, and
row.endpointfor endpoints. - Backend: Added
byEndpointparsing inHandleUsageStatsdaily rollup andusage_window_cache.go.
v1.9.12
Fixed
- Deterministic request serialization for ALL providers: chat-completions bodies leaving via
newJSONRequestare now re-serialized withencoding/json/v2Deterministic(true)(map members sorted). Previously only the opencode/zen lanes carried per-lanemarshalStablecalls; a new provider serving a DeepSeek model did not inherit the fix, and any rewrite-free turn kept its original randomized-bytes body — turning the shared prefix of every turn after the first into a full prompt-cache miss on DeepSeek (and other prefix-caching upstreams). Non-chat payloads (OAuth token posts, Geminicontents, malformed JSON) pass through byte-identical, so signed flows (e.g. COSY) are unaffected. Parity note: the JavaScriptdecolua/9routeroriginal is deterministic by construction (JS object insertion order); this restores that property in the Go rewrite.
OpenCode: free tier moved to the /inference/v1 gate with the v2 client fingerprint
Upstream decolua/9router#4842 parity (Closes #317, supersedes #318 and #319):
OpenCode v2 moved free inference off /zen/v1; every free request there now
answers 403 FreeTierError.
- The keyless free executor dials /inference/v1/{chat/completions,responses}.
A base URL carrying the /zen/v1 prefix is rewritten host-agnostically,
custom endpoints stay untouched, and relays carry the lane in x-relay-path. - Fingerprint headers follow the v2 CLI: User-Agent opencode//
/ with v1 shapes rejected, x-opencode-session-id,
x-session-affinity and x-session-id mirror one session (a downstream hint
moves all four together), the rejected x-opencode-request is never sent,
and the project defaults to "global" like upstream. - Session ids keep a small counter: generated and translated prefixes pin the
counter to 1 exactly like the CLI, so non-OpenCode clients stop tripping
the gate under concurrent load. - The request body declares OpenCode's exact read and shell tools — verbatim
descriptions and parameter schemas from the v2 CLI, strict:false on the
Responses lane. A caller tool reusing one of those names with a different
definition travels under a *_client alias (history and tool_choice
retargeted); appended placeholders get the upstream singular/plural note,
never on Claude-shaped bodies. tool_choice passes through untouched except
on the Responses lane, where auto-only models collapse to "auto" and a
forced choice naming no declared tool is dropped. - Paid lanes are deliberately narrow: opencode-zen keeps /zen/v1 with the v1
identity and quartet cloaking for paid ids, and opencode-go keeps its
predicate, v1 UA and request ids. Only curated free-tier ids move:
keyed requests for them 403 ("free tier can only be used from within
OpenCode", probed live on muse-spark-1.3-contributor-free), so zen and go
forward those to the keyless gate with public credentials, v2 cli identity
and the exact fingerprint. big-pickle stays on zen (no live 403 evidence;
the lane table carries it as paid chat). With no go connection configured,
the paid go lane borrows the key from an active opencode-zen connection
(key only — provider, BaseURL and lanes stay go), operator-requested.
Verified with go test -count=1 ./internal/proxy/ ./internal/proxy/executor/ ./internal/translator/ ./internal/providers/ ./internal/handlers/chat/:
verb gate-contract pins (verbatim schemas, strict:false, note wording),
lane predicates for free vs go, v1 identity on paid lanes, the zen keyed-lane
guard, session mirrors, and the mock E2E shapes.
✨ feat(ratelimit): per-key lifetime token quota and FIFO request queue
- Lifetime token quota: new
tokenQuotacolumn per API key. It caps the
cumulative prompt+completion tokens a key may spend over its whole life;
once reached, requests are refused with403andX-Token-Quota-Limit/
X-Token-Quota-Usedheaders. Usage is summed fromusageHistoryunder both
the masked key this gateway writes and the raw key the upstream dashboard
wrote. A failed usage read fails open, so a storage blip cannot lock a
key out of a service it has always been able to call. - Request queue: new
queueTimeoutMscolumn. A request that exceeds the
key's RPM or concurrency limit now waits in a FIFO queue (head-first, no
overtaking) for the RPM window to roll or a concurrent slot to free, up to
the timeout, before returning429. Defaults to 60s when a concurrency cap
is set; a key with no queueable limit keeps the previous immediate-refusal
behaviour. TPM is deliberately not queued — a token bucket refills
continuously and would live-lock a waiter. - Atomic admission: a request refused on concurrency does not consume an
RPM stamp, so concurrency pressure cannot silently drain the RPM budget. - Observability: new
router_rate_limit_queue_secondshistogram records
how long requests wait for a slot. - Dashboard / API / backup:
tokenQuotaandqueueTimeoutMsare exposed
onGET /api/keys, accepted onPUT /api/keys/{id}(non-negative; omitted
leaves the stored value unchanged), shown in the key policy dialog with a
quota badge, and carried through database backup/restore so a restore cannot
silently lift a throttle. Schema columns are additive; existing installs
upgrade in place, andusageHistorygains anapiKeyindex so the quota
sum is not a full table scan on every quota-bearing request. - No behaviour change for an existing install. A key with no
queueTimeoutMsof its own only queues when it also has its own concurrency
cap (the upstream 60s default). A concurrency cap that arrives purely from
the operator's global default keeps the pre-change immediate refusal, so a
global setting cannot silently introduce queueing on every key. - Verified:
go build ./...,go vet ./internal/..., andgo test -count=1 ./internal/...all pass; new queue/quota/schema/backup/full-stack tests pass
under-race.
🔄 fix(dashboard): group accounts by provider first and set default priority on connection creation
- Background: Previously,
providerConnectionsrows created via OAuth lacked apriorityvalue
and were stored asNULL. InsortProviderConnections,NULLpriorities defaulted tomath.MaxInt
and were compared across providers before grouping by provider. Consequently, a newly added account
was pushed to the very end of the database—past accounts of other providers—and landed isolated on
subsequent pagination pages. - Provider-First Grouping:
sortProviderConnectionsnow groups by provider first so accounts of
the same provider always remain contiguous in Quota Tracker, followed by priority and name. - Default Priority: Added
ResolveDefaultPriority(provider)ininternal/db/connections.go
and updated OAuth connection inserts so new accounts receive an explicit sequential priority.
Dashboard: copying an API key no longer copies the mask
Issue #310 reported that the Copy button on an API-key row put the masked
display value on the clipboard (sk-c07…c93e) rather than the credential, so
the pasted key could not authenticate. Revealing the key first worked around it,
but copying a secret that depends on whether it happened to be on screen is not
a usable workflow.
The copy button took whatever the cell was rendering, and a cell that has not
been revealed renders keyDisplay — the mask. It now copies the stored secret
directly and ignores the reveal state, so Copy works on a freshly loaded
dashboard. A row the server holds no plaintext for (a caller authenticated with
an engine client key, or a row created before #199 whose secret was hashed away)
has nothing usable to copy; it now reports that instead of silently putting an
unusable mask on the clipboard.
Verified by a new end-to-end case that copies from an un-revealed row, reads the
clipboard, and authenticates the copied value against the running gateway with
GET /v1/models — it returns sk-1…fe7b before the fix and a working sk-…
key after.
Codex + GPT-6 Sol: 400 Unknown parameter: 'input[0].content' when a token saver is on
- What was broken. A
/v1/responsesclient routed to a Responses-speaking upstream (codex,perplexity-agent,opencode,opencode-zen,grok-cli) failed with400 {"error":{"message":"Unknown parameter: 'input[0].content'","code":"unknown_parameter"}}, which the client only saw as a[fallback] upstream failed provider=codex model=gpt-6-sol status=400log line. Every request failed while the same model worked with token savers turned off — and forgpt-6-sol/gpt-6-lunathe whole Responses-Lite prefix the executor adds is affected, so no other model on the connection worked either. Closes #307. - Why.
tokensaver.InjectSystemPrompthad one injector for bothmessages[]andinput[]. On a Responses body it wrote a Chat system turn,{"role":"system","content":"<text>"}, atinput[0]. The Responses item schema wants{"type":"message","role":"system","content":[{"type":"input_text","text":"…"}]}— a turn with notypeand a stringcontentwhere the validator reads a block array. Upstream dispatches by wire shape and keeps its two injectors apart (open-sse/rtk/systemInject.js:injectChatSystemvsinjectResponsesInputSystem); Go had collapsed them. The trigger is any active token saver — Caveman, Ponytail or ADHD — since those are the settings that inject a system prompt at all; RTK alone never reaches this code. - How it is fixed.
InjectSystemPromptnow dispatches on the same three shapes upstream does, andinput[]gets its own injector: it appends the prompt as aninput_textblock to an existing system/developer message item, prepends one when the conversation has none, and widens a client-supplied stringcontentinto blocks rather than leaving a string behind. An item withouttype:"message"is never adopted. Chat Completions bodies are u...
v1.9.12-exp.5
Experimental build. Not published to
latest, and9router-go update
will not offer it to an install running a stable release. Track it
explicitly:docker pull luqmenul/9router-go:1.9.12-exp.5Issues found here are still worth reporting — but check the stable
release first, because experimental breakage is usually already
fixed by the next tagged build.
Codex + GPT-6 Sol: 400 Unknown parameter: 'input[0].content' when a token saver is on
-
What was broken. A
/v1/responsesclient routed to a Responses-speaking upstream (codex,perplexity-agent,opencode,opencode-zen,grok-cli) failed with400 {"error":{"message":"Unknown parameter: 'input[0].content'","code":"unknown_parameter"}}, which the client only saw as a[fallback] upstream failed provider=codex model=gpt-6-sol status=400log line. Every request failed while the same model worked with token savers turned off — and forgpt-6-sol/gpt-6-lunathe whole Responses-Lite prefix the executor adds is affected, so no other model on the connection worked either. Closes #307. -
Why.
tokensaver.InjectSystemPrompthad one injector for bothmessages[]andinput[]. On a Responses body it wrote a Chat system turn,{"role":"system","content":"<text>"}, atinput[0]. The Responses item schema wants{"type":"message","role":"system","content":[{"type":"input_text","text":"…"}]}— a turn with notypeand a stringcontentwhere the validator reads a block array. Upstream dispatches by wire shape and keeps its two injectors apart (open-sse/rtk/systemInject.js:injectChatSystemvsinjectResponsesInputSystem); Go had collapsed them. The trigger is any active token saver — Caveman, Ponytail or ADHD — since those are the settings that inject a system prompt at all; RTK alone never reaches this code. -
How it is fixed.
InjectSystemPromptnow dispatches on the same three shapes upstream does, andinput[]gets its own injector: it appends the prompt as aninput_textblock to an existing system/developer message item, prepends one when the conversation has none, and widens a client-supplied stringcontentinto blocks rather than leaving a string behind. An item withouttype:"message"is never adopted. Chat Completions bodies are untouched. -
Verified.
internal/tokensavercovers the three Responses paths; the newinternal/integration/codex_responses_tokensaver_test.goboots the real router with acodexconnection aimed at a fake/responsesupstream and asserts on the outbound body — a control case with the saver off and the regression case with Caveman on. The regression case fails onmainwithinput[1].content is string, want an array of content blocksand passes here.go test ./...is green.### 🎨 fix(dashboard): align Antigravity quota with standard QuotaTable layout and restore row hiding -
Background: The 2x2 nested grid card introduced in #282 created an oversized,
bloated card height on accounts with multiple Antigravity connections, diverged from
the sleek layout of Codex and other providers, and dropped row-hiding controls (onHideQuota). -
Refinement: Reverted Antigravity rendering to the standard, compact
QuotaTable.svelte
component, preserving the 100% scale and 4 canonical window buckets while restoring
row-hiding capabilities and uniform card sizing.
feat(cli): service autostart management
9router-go service install registers the gateway to start on boot using
whatever init the machine actually runs:
- systemd — always the per-user unit (
systemd --user), so install stays
unprivileged and can never touch a root-owned unit. Every
install/uninstall/status/control call goes through onesystemdScopehelper,
which keeps--userfrom being dropped and the command from landing on the
system bus (root prompt, user unit left enabled). There is no system-unit
scope or--systemflag. - launchd (macOS), runit (root, or
~/serviceunder a userrunsvdir),
and cron@rebootplus an XDG autostart desktop entry, all tagged with a
# 9router-go-autostartmarker so install is idempotent and uninstall never
touches the user's own entries. - OpenRC is a system service and needs root: it has no per-user scope, so
service installrefuses without it rather than silently installing a
different backend (run withsudo, or use--backend cron). - Windows — a scheduled task created with
schtasksat logon, run as the
installing user rather thanSYSTEM(a SYSTEM task reads a different
%APPDATA% data dir). Creating the task requires an Administrator console.
Companions: service start|stop|restart drive the supervised service (falling
back to the pid-file daemon for cron, which has no supervisor), status reports
the entry in effect, and uninstall stops/disables before deleting and reports
failure when the file survives. install warns when the start daemon is still
holding the port, and an unmatched positional argument now fails fast with
unknown command instead of booting a second gateway.
The home directory is read through a package seam (userHomeDir) rather than
HOME, because os.UserHomeDir reads %USERPROFILE% on Windows; tests point
every user-scoped path (systemd unit, launchd plist, autostart entry, runit
~/service) at a temp home instead of the runner's real one. Windows task
commands are quoted for cmd.exe rather than the POSIX shell, so a path under
C:\Program Files\... still fires.
Verified with go test ./... (all per-OS backends are exercised from one runner
through the seams) plus make build.
🐛 The Quota Tracker provider filter pushed the page down instead of opening over it
Opening "All providers" on the Quota Tracker shoved the quota cards out of the
way instead of overlaying them, which is what made the page look broken.
The provider panel was the only dropdown in the dashboard that measured itself
against its trigger and wrote left / top inline while not being
position: fixed and not being portalled. The computed position came back
static, so the placement maths was dead output: left: 896px; top: 140px was
written onto a plain in-flow <div>, and placePanel / placementStyle were
doing work no browser honoured.
Everything around it was already correct and gave the bug away on re-reading —
the click-outside handler checks providerDropdownPanel.contains(target)
precisely because the panel is expected to be detached, the resize and scroll
listeners already reposition it, and a comment states the panel is fixed. Only
the element itself was missing the two attributes every sibling panel has:
| Panel | fixed |
use:portal |
|---|---|---|
SectionMenu, CountedSelect, Menu, ViewSelect |
✅ | ✅ |
QuotaTrackerView provider filter |
❌ | ❌ |
The panel now carries the same use:portal + fixed z-50 pair the other five
use, so it is anchored to the window rather than to the toolbar row, and the
long provider list overlays the cards instead of pushing them.
Verified in a browser against a running gateway: computed position is fixed
and the parent is BODY; selecting an option still filters (label and row
update); a click on the panel's own background leaves it open while a click
outside closes it, so the click-outside handler still resolves through the
detached node; and at a 390px phone width the panel measures 256px wide with
both edges on screen.### Added
- SumoPod provider:
sumopodregistered as an API-key provider (bearer transport, OpenAI-compatible) pointing atai.sumopod.com/v1/chat/completionswith thespshort alias and a 61-model catalogue synced from the live/v1/modelsresponse (embeddings excluded). Live/v1/modelsimport is wired viamodelsListURL(bearer, like tokenharbor/dahl).
🐛 Quota Tracker now shows 5h / weekly windows for Claude OAuth accounts (#293)
- Claude connections were listed in the tracker but never read:
fetchProviderUsagehad no
claudecase, so every account answered{plan:"claude", quotas:{}}and the dashboard
printed "Account active. No quota limits tracked." without any upstream call. - The dashboard now reads
GET api.anthropic.com/api/oauth/usage(port of upstream
getClaudeUsage) and renderssession (5h),weekly (7d)and model-scoped weekly rows
(weekly opus (7d),weekly fable (7d), …) with used / remaining / resetAt. - Reads are cached per token for 60s, a 429 puts the token on a cooldown (honouring
Retry-After) and keeps serving the last good reading; a 401/403 goes through the existing
refresh-and-retry path.
🐛 /v1/models top-level context_length / max_completion_tokens now match capabilities (#291)
- Top-level limits came from the generic substring table (
claude-*→ 200000/8192) while
capabilities.contextWindow/maxOutputcarried the per-model values, so one entry
contradicted itself (e.g.cc/claude-opus-5-5: 200000/8192 vs 1000000/128000). Clients that
read the OpenAI-style fields — such aspi-9router-ext— capped Claude output at 8192. - The top-level fields (and
/v1/models/info) are now filled from the same chain as
capabilities(declared custom limits → per-model caps → catalog → substring table); the
substring table only fills gaps. claude-fable-5-1gets its documented 1M context / 128K output in the per-model table.
🐛 reasoning_effort to a Claude upstream no longer fails with thinking.type: Field required (#289)
- An OpenAI-shaped client sending
reasoning_effort(or Responsesreasoning.effort) to a Claude
upstream gotthinking: {"display": "summarized"}with notype, and Anthropic answered
400 thinking.type: Field required. - The request translation now sets the missing
type, mirroring theclaude-adaptive/
claude-budgetbranches of upstreamthinkingUnified.js:adaptive+output_config.effort
for ada...
v1.9.12-exp.4
Experimental build. Not published to
latest, and9router-go update
will not offer it to an install running a stable release. Track it
explicitly:docker pull luqmenul/9router-go:1.9.12-exp.4Issues found here are still worth reporting — but check the stable
release first, because experimental breakage is usually already
fixed by the next tagged build.
🎨 fix(dashboard): align Antigravity quota with standard QuotaTable layout and restore row hiding
- Background: The 2x2 nested grid card introduced in #282 created an oversized,
bloated card height on accounts with multiple Antigravity connections, diverged from
the sleek layout of Codex and other providers, and dropped row-hiding controls (onHideQuota). - Refinement: Reverted Antigravity rendering to the standard, compact
QuotaTable.svelte
component, preserving the 100% scale and 4 canonical window buckets while restoring
row-hiding capabilities and uniform card sizing.
feat(cli): service autostart management
9router-go service install registers the gateway to start on boot using
whatever init the machine actually runs:
- systemd — always the per-user unit (
systemd --user), so install stays
unprivileged and can never touch a root-owned unit. Every
install/uninstall/status/control call goes through onesystemdScopehelper,
which keeps--userfrom being dropped and the command from landing on the
system bus (root prompt, user unit left enabled). There is no system-unit
scope or--systemflag. - launchd (macOS), runit (root, or
~/serviceunder a userrunsvdir),
and cron@rebootplus an XDG autostart desktop entry, all tagged with a
# 9router-go-autostartmarker so install is idempotent and uninstall never
touches the user's own entries. - OpenRC is a system service and needs root: it has no per-user scope, so
service installrefuses without it rather than silently installing a
different backend (run withsudo, or use--backend cron). - Windows — a scheduled task created with
schtasksat logon, run as the
installing user rather thanSYSTEM(a SYSTEM task reads a different
%APPDATA% data dir). Creating the task requires an Administrator console.
Companions: service start|stop|restart drive the supervised service (falling
back to the pid-file daemon for cron, which has no supervisor), status reports
the entry in effect, and uninstall stops/disables before deleting and reports
failure when the file survives. install warns when the start daemon is still
holding the port, and an unmatched positional argument now fails fast with
unknown command instead of booting a second gateway.
The home directory is read through a package seam (userHomeDir) rather than
HOME, because os.UserHomeDir reads %USERPROFILE% on Windows; tests point
every user-scoped path (systemd unit, launchd plist, autostart entry, runit
~/service) at a temp home instead of the runner's real one. Windows task
commands are quoted for cmd.exe rather than the POSIX shell, so a path under
C:\Program Files\... still fires.
Verified with go test ./... (all per-OS backends are exercised from one runner
through the seams) plus make build.
🐛 The Quota Tracker provider filter pushed the page down instead of opening over it
Opening "All providers" on the Quota Tracker shoved the quota cards out of the
way instead of overlaying them, which is what made the page look broken.
The provider panel was the only dropdown in the dashboard that measured itself
against its trigger and wrote left / top inline while not being
position: fixed and not being portalled. The computed position came back
static, so the placement maths was dead output: left: 896px; top: 140px was
written onto a plain in-flow <div>, and placePanel / placementStyle were
doing work no browser honoured.
Everything around it was already correct and gave the bug away on re-reading —
the click-outside handler checks providerDropdownPanel.contains(target)
precisely because the panel is expected to be detached, the resize and scroll
listeners already reposition it, and a comment states the panel is fixed. Only
the element itself was missing the two attributes every sibling panel has:
| Panel | fixed |
use:portal |
|---|---|---|
SectionMenu, CountedSelect, Menu, ViewSelect |
✅ | ✅ |
QuotaTrackerView provider filter |
❌ | ❌ |
The panel now carries the same use:portal + fixed z-50 pair the other five
use, so it is anchored to the window rather than to the toolbar row, and the
long provider list overlays the cards instead of pushing them.
Verified in a browser against a running gateway: computed position is fixed
and the parent is BODY; selecting an option still filters (label and row
update); a click on the panel's own background leaves it open while a click
outside closes it, so the click-outside handler still resolves through the
detached node; and at a 390px phone width the panel measures 256px wide with
both edges on screen.### Added
- SumoPod provider:
sumopodregistered as an API-key provider (bearer transport, OpenAI-compatible) pointing atai.sumopod.com/v1/chat/completionswith thespshort alias and a 61-model catalogue synced from the live/v1/modelsresponse (embeddings excluded). Live/v1/modelsimport is wired viamodelsListURL(bearer, like tokenharbor/dahl).
🐛 Quota Tracker now shows 5h / weekly windows for Claude OAuth accounts (#293)
- Claude connections were listed in the tracker but never read:
fetchProviderUsagehad no
claudecase, so every account answered{plan:"claude", quotas:{}}and the dashboard
printed "Account active. No quota limits tracked." without any upstream call. - The dashboard now reads
GET api.anthropic.com/api/oauth/usage(port of upstream
getClaudeUsage) and renderssession (5h),weekly (7d)and model-scoped weekly rows
(weekly opus (7d),weekly fable (7d), …) with used / remaining / resetAt. - Reads are cached per token for 60s, a 429 puts the token on a cooldown (honouring
Retry-After) and keeps serving the last good reading; a 401/403 goes through the existing
refresh-and-retry path.
🐛 /v1/models top-level context_length / max_completion_tokens now match capabilities (#291)
- Top-level limits came from the generic substring table (
claude-*→ 200000/8192) while
capabilities.contextWindow/maxOutputcarried the per-model values, so one entry
contradicted itself (e.g.cc/claude-opus-5-5: 200000/8192 vs 1000000/128000). Clients that
read the OpenAI-style fields — such aspi-9router-ext— capped Claude output at 8192. - The top-level fields (and
/v1/models/info) are now filled from the same chain as
capabilities(declared custom limits → per-model caps → catalog → substring table); the
substring table only fills gaps. claude-fable-5-1gets its documented 1M context / 128K output in the per-model table.
🐛 reasoning_effort to a Claude upstream no longer fails with thinking.type: Field required (#289)
- An OpenAI-shaped client sending
reasoning_effort(or Responsesreasoning.effort) to a Claude
upstream gotthinking: {"display": "summarized"}with notype, and Anthropic answered
400 thinking.type: Field required. - The request translation now sets the missing
type, mirroring theclaude-adaptive/
claude-budgetbranches of upstreamthinkingUnified.js:adaptive+output_config.effort
for adaptive models,enabled+budget_tokensotherwise.max_tokensis raised to
budget_tokens + 1024(capped at the model's output limit) so the answer is not starved; the
budget is only shrunk when it alone reaches the ceiling. An explicitthinking.typefrom the
client is never overwritten. reasoning_effort: none/off(or Responseseffort: none) with a captureddisplaynow sends
thinking: {"type": "disabled"}instead of a typeless object.- The thinking format is looked up under the real lane provider (
minimax-code,opencode-zen,
opencode-go) instead of a hardcodedclaude. claude-fable-5is declaredclaude-adaptivein the per-model table; it used to fall through the
*claude*fable*glob toclaude-budget, which would have sent the manualenabled/budget_tokens
mode that current Claude models do not accept.
fix(codex): strip temperature/top_p before forwarding to the Codex backend
Editors such as Zed send temperature on every chat-completions turn, and
buildResponsesBody forwarded it as-is. The ChatGPT Codex OAuth backend
answered 400 {"detail":"Unsupported parameter: temperature"} on the reasoning
models behind Codex, so each turn burned one failed upstream call before the
combo moved on — the 400 is request-scoped, so the connection was never locked
and the failure repeated every time.
temperature and top_p now join max_tokens / max_completion_tokens /
max_output_tokens in stripCodexUnsupportedTokenParams. The strip stays on
the Codex forward path only: buildResponsesBody is shared with the other
Responses-API providers (grok-cli, kimi, muse, opencode-zen), whose backends do
accept the field, so moving it there would silently change their requests.
Parity with upstream open-sse/executors/codex.js, whose transformRequest
deletes body.temperature and body.top_p alongside the generation limits.
Verification: go test ./internal/proxy/executor/ passes. The fake upstream
in TestForwardCodex_UpstreamNeverSeesTokenParams now rejects all five
parameters the way ChatGPT does and the client body carries temperature and
top_p, so the wiring — not just the helper — is pinned. Manually verified on
Windows with Zed: gpt-6-astra went from a 400 on every turn to 200.
Known gap (not introduced here): upstream deletes a longer list
(frequency_penalty, presence_penalty, logprobs, `t...
v1.9.12-exp.3
Experimental build. Not published to
latest, and9router-go update
will not offer it to an install running a stable release. Track it
explicitly:docker pull luqmenul/9router-go:1.9.12-exp.3Issues found here are still worth reporting — but check the stable
release first, because experimental breakage is usually already
fixed by the next tagged build.
🐛 The Quota Tracker provider filter pushed the page down instead of opening over it
Opening "All providers" on the Quota Tracker shoved the quota cards out of the
way instead of overlaying them, which is what made the page look broken.
The provider panel was the only dropdown in the dashboard that measured itself
against its trigger and wrote left / top inline while not being
position: fixed and not being portalled. The computed position came back
static, so the placement maths was dead output: left: 896px; top: 140px was
written onto a plain in-flow <div>, and placePanel / placementStyle were
doing work no browser honoured.
Everything around it was already correct and gave the bug away on re-reading —
the click-outside handler checks providerDropdownPanel.contains(target)
precisely because the panel is expected to be detached, the resize and scroll
listeners already reposition it, and a comment states the panel is fixed. Only
the element itself was missing the two attributes every sibling panel has:
| Panel | fixed |
use:portal |
|---|---|---|
SectionMenu, CountedSelect, Menu, ViewSelect |
✅ | ✅ |
QuotaTrackerView provider filter |
❌ | ❌ |
The panel now carries the same use:portal + fixed z-50 pair the other five
use, so it is anchored to the window rather than to the toolbar row, and the
long provider list overlays the cards instead of pushing them.
Verified in a browser against a running gateway: computed position is fixed
and the parent is BODY; selecting an option still filters (label and row
update); a click on the panel's own background leaves it open while a click
outside closes it, so the click-outside handler still resolves through the
detached node; and at a 390px phone width the panel measures 256px wide with
both edges on screen.### Added
- SumoPod provider:
sumopodregistered as an API-key provider (bearer transport, OpenAI-compatible) pointing atai.sumopod.com/v1/chat/completionswith thespshort alias and a 61-model catalogue synced from the live/v1/modelsresponse (embeddings excluded). Live/v1/modelsimport is wired viamodelsListURL(bearer, like tokenharbor/dahl).
🐛 Quota Tracker now shows 5h / weekly windows for Claude OAuth accounts (#293)
- Claude connections were listed in the tracker but never read:
fetchProviderUsagehad no
claudecase, so every account answered{plan:"claude", quotas:{}}and the dashboard
printed "Account active. No quota limits tracked." without any upstream call. - The dashboard now reads
GET api.anthropic.com/api/oauth/usage(port of upstream
getClaudeUsage) and renderssession (5h),weekly (7d)and model-scoped weekly rows
(weekly opus (7d),weekly fable (7d), …) with used / remaining / resetAt. - Reads are cached per token for 60s, a 429 puts the token on a cooldown (honouring
Retry-After) and keeps serving the last good reading; a 401/403 goes through the existing
refresh-and-retry path.
🐛 /v1/models top-level context_length / max_completion_tokens now match capabilities (#291)
- Top-level limits came from the generic substring table (
claude-*→ 200000/8192) while
capabilities.contextWindow/maxOutputcarried the per-model values, so one entry
contradicted itself (e.g.cc/claude-opus-5-5: 200000/8192 vs 1000000/128000). Clients that
read the OpenAI-style fields — such aspi-9router-ext— capped Claude output at 8192. - The top-level fields (and
/v1/models/info) are now filled from the same chain as
capabilities(declared custom limits → per-model caps → catalog → substring table); the
substring table only fills gaps. claude-fable-5-1gets its documented 1M context / 128K output in the per-model table.
fix(codex): strip temperature/top_p before forwarding to the Codex backend
Editors such as Zed send temperature on every chat-completions turn, and
buildResponsesBody forwarded it as-is. The ChatGPT Codex OAuth backend
answered 400 {"detail":"Unsupported parameter: temperature"} on the reasoning
models behind Codex, so each turn burned one failed upstream call before the
combo moved on — the 400 is request-scoped, so the connection was never locked
and the failure repeated every time.
temperature and top_p now join max_tokens / max_completion_tokens /
max_output_tokens in stripCodexUnsupportedTokenParams. The strip stays on
the Codex forward path only: buildResponsesBody is shared with the other
Responses-API providers (grok-cli, kimi, muse, opencode-zen), whose backends do
accept the field, so moving it there would silently change their requests.
Parity with upstream open-sse/executors/codex.js, whose transformRequest
deletes body.temperature and body.top_p alongside the generation limits.
Verification: go test ./internal/proxy/executor/ passes. The fake upstream
in TestForwardCodex_UpstreamNeverSeesTokenParams now rejects all five
parameters the way ChatGPT does and the client body carries temperature and
top_p, so the wiring — not just the helper — is pinned. Manually verified on
Windows with Zed: gpt-6-astra went from a 400 on every turn to 200.
Known gap (not introduced here): upstream deletes a longer list
(frequency_penalty, presence_penalty, logprobs, top_logprobs, n,
seed, user, metadata, stream_options, safety_identifier,
previous_response_id) and then filters the body through a Responses-API
allowlist. This port still deletes only the five fields above, so a client
sending e.g. frequency_penalty to Codex can still hit the same 400.### 📦 feat(settings): database storage & retention profile (observability limits)
- Background:
requestDetailspreviously retained diagnostic entries with hardcoded limits
(500,000 rows ceiling, 30 days) and unconditionally logged massive payloads on every call,
causing SQLite databases to swell up to hundreds of megabytes on user machines. - Observability Mode: Added
observabilityMode(full,errors_only,off). When set to
off, request detail logging is bypassed completely (<10 MB permanent DB). When set toerrors_only,
only failed/fallback transactions are recorded. - Configurable Retention: Added
retentionDaysandmaxRequestRecords(default 5,000) settings. - Maintenance Endpoints: Added
GET /api/settings/database/stats,POST /api/settings/database/prune,
andPOST /api/settings/database/vacuumallowing operators to prune and reclaim free pages back to disk.
🛡️ feat(security): SSRF protection via safe network dialer
- Background: Custom provider nodes and custom proxies previously permitted dialing
any IP address without restriction, posing a risk of internal network scanning or
cloud metadata exposure (169.254.169.254). - Hardening: Implemented
NewSafeDialer()andNewSafeTransport()ininternal/proxy/ssrf.go,
enforcing destination IP inspection at the socket control level. Dials to loopback, RFC 1918
private LANs, link-local, and cloud metadata addresses are blocked by default withErrSSRFBlocked. - Operational Override: Operators running local inference engines (such as Ollama on localhost)
can explicitly permit private networking by settingALLOW_PRIVATE_NETWORKING=true.
✨ feat(quota): 4-card clean layout & 100% scale for Antigravity quota
- Background: Antigravity quotas were computed against an artificial scale of
1000.0,
and displayed as flat model lists rather than the 4 canonical time-window quota buckets
provided by Google Antigravity (retrieveUserQuotaSummary). - Backend: Standardized Antigravity quotas to a pure 100.0 percentage scale with window
metadata (5handweekly). - Dashboard UI: Introduced
AntigravityQuotaCard.svelterendering a 2x2 grid (Gemini 5h,
Gemini Weekly, Claude/GPT 5h, Claude/GPT Weekly) with color-coded progress bars and countdown timers.
🐛 Issue #280 — MiMo and every other provider vanished from the topology map, and the Test button faked usage
Two defects made a provider the operator never used look permanently busy on
Usage → Overview, and they compounded each other.
-
The "Test" button metered probes as traffic.
POST /api/models/testis
dispatched straight into the chat handler with a probe context
(media/model_test_ping.go:84), and the probe context is honoured at every
other side effect in the forwarding path — connection unlocks, cooldown
clears, model-lock releases, fallback skips. The one gap was the usage write:
tryForwardWithConnectioncalledlogUsageoutside the
IsProbeContextguard, so a single click wrote a realusageHistoryrow and
ausageDaily.byProviderbucket. A provider with a passing test appeared to
have served a request nobody sent, and its tokens landed in the daily totals
and the est. cost. "Check all models" multiplies this by the catalog size.
tryForwardWithConnectionnow returns early on a probe, before any usage is
written — the request still reaches the provider, so the button still works. -
The topology map drew every configured connection, forever. The map is
built client-side inAnalyticsViewfrom five sources, and four of them are
usage-windowed throughstats.byProvider. The fifth was not: the connections
loop drew any non-inactiveproviderConnectionsrow. Since that list is the
whole table, a provider the operator had merely configured sat on the map
regardless of the selected window, and outlived both the connection and the
period. It ...
v1.9.12-exp.2
Experimental build. Not published to
latest, and9router-go update
will not offer it to an install running a stable release. Track it
explicitly:docker pull luqmenul/9router-go:1.9.12-exp.2Issues found here are still worth reporting — but check the stable
release first, because experimental breakage is usually already
fixed by the next tagged build.
🐛 The Quota Tracker provider filter pushed the page down instead of opening over it
Opening "All providers" on the Quota Tracker shoved the quota cards out of the
way instead of overlaying them, which is what made the page look broken.
The provider panel was the only dropdown in the dashboard that measured itself
against its trigger and wrote left / top inline while not being
position: fixed and not being portalled. The computed position came back
static, so the placement maths was dead output: left: 896px; top: 140px was
written onto a plain in-flow <div>, and placePanel / placementStyle were
doing work no browser honoured.
Everything around it was already correct and gave the bug away on re-reading —
the click-outside handler checks providerDropdownPanel.contains(target)
precisely because the panel is expected to be detached, the resize and scroll
listeners already reposition it, and a comment states the panel is fixed. Only
the element itself was missing the two attributes every sibling panel has:
| Panel | fixed |
use:portal |
|---|---|---|
SectionMenu, CountedSelect, Menu, ViewSelect |
✅ | ✅ |
QuotaTrackerView provider filter |
❌ | ❌ |
The panel now carries the same use:portal + fixed z-50 pair the other five
use, so it is anchored to the window rather than to the toolbar row, and the
long provider list overlays the cards instead of pushing them.
Verified in a browser against a running gateway: computed position is fixed
and the parent is BODY; selecting an option still filters (label and row
update); a click on the panel's own background leaves it open while a click
outside closes it, so the click-outside handler still resolves through the
detached node; and at a 390px phone width the panel measures 256px wide with
both edges on screen.### 🐛 Quota Tracker now shows 5h / weekly windows for Claude OAuth accounts (#293)
- Claude connections were listed in the tracker but never read:
fetchProviderUsagehad no
claudecase, so every account answered{plan:"claude", quotas:{}}and the dashboard
printed "Account active. No quota limits tracked." without any upstream call. - The dashboard now reads
GET api.anthropic.com/api/oauth/usage(port of upstream
getClaudeUsage) and renderssession (5h),weekly (7d)and model-scoped weekly rows
(weekly opus (7d),weekly fable (7d), …) with used / remaining / resetAt. - Reads are cached per token for 60s, a 429 puts the token on a cooldown (honouring
Retry-After) and keeps serving the last good reading; a 401/403 goes through the existing
refresh-and-retry path.
🐛 /v1/models top-level context_length / max_completion_tokens now match capabilities (#291)
- Top-level limits came from the generic substring table (
claude-*→ 200000/8192) while
capabilities.contextWindow/maxOutputcarried the per-model values, so one entry
contradicted itself (e.g.cc/claude-opus-5-5: 200000/8192 vs 1000000/128000). Clients that
read the OpenAI-style fields — such aspi-9router-ext— capped Claude output at 8192. - The top-level fields (and
/v1/models/info) are now filled from the same chain as
capabilities(declared custom limits → per-model caps → catalog → substring table); the
substring table only fills gaps. claude-fable-5-1gets its documented 1M context / 128K output in the per-model table.
fix(codex): strip temperature/top_p before forwarding to the Codex backend
Editors such as Zed send temperature on every chat-completions turn, and
buildResponsesBody forwarded it as-is. The ChatGPT Codex OAuth backend
answered 400 {"detail":"Unsupported parameter: temperature"} on the reasoning
models behind Codex, so each turn burned one failed upstream call before the
combo moved on — the 400 is request-scoped, so the connection was never locked
and the failure repeated every time.
temperature and top_p now join max_tokens / max_completion_tokens /
max_output_tokens in stripCodexUnsupportedTokenParams. The strip stays on
the Codex forward path only: buildResponsesBody is shared with the other
Responses-API providers (grok-cli, kimi, muse, opencode-zen), whose backends do
accept the field, so moving it there would silently change their requests.
Parity with upstream open-sse/executors/codex.js, whose transformRequest
deletes body.temperature and body.top_p alongside the generation limits.
Verification: go test ./internal/proxy/executor/ passes. The fake upstream
in TestForwardCodex_UpstreamNeverSeesTokenParams now rejects all five
parameters the way ChatGPT does and the client body carries temperature and
top_p, so the wiring — not just the helper — is pinned. Manually verified on
Windows with Zed: gpt-6-astra went from a 400 on every turn to 200.
Known gap (not introduced here): upstream deletes a longer list
(frequency_penalty, presence_penalty, logprobs, top_logprobs, n,
seed, user, metadata, stream_options, safety_identifier,
previous_response_id) and then filters the body through a Responses-API
allowlist. This port still deletes only the five fields above, so a client
sending e.g. frequency_penalty to Codex can still hit the same 400.### 📦 feat(settings): database storage & retention profile (observability limits)
- Background:
requestDetailspreviously retained diagnostic entries with hardcoded limits
(500,000 rows ceiling, 30 days) and unconditionally logged massive payloads on every call,
causing SQLite databases to swell up to hundreds of megabytes on user machines. - Observability Mode: Added
observabilityMode(full,errors_only,off). When set to
off, request detail logging is bypassed completely (<10 MB permanent DB). When set toerrors_only,
only failed/fallback transactions are recorded. - Configurable Retention: Added
retentionDaysandmaxRequestRecords(default 5,000) settings. - Maintenance Endpoints: Added
GET /api/settings/database/stats,POST /api/settings/database/prune,
andPOST /api/settings/database/vacuumallowing operators to prune and reclaim free pages back to disk.
🛡️ feat(security): SSRF protection via safe network dialer
- Background: Custom provider nodes and custom proxies previously permitted dialing
any IP address without restriction, posing a risk of internal network scanning or
cloud metadata exposure (169.254.169.254). - Hardening: Implemented
NewSafeDialer()andNewSafeTransport()ininternal/proxy/ssrf.go,
enforcing destination IP inspection at the socket control level. Dials to loopback, RFC 1918
private LANs, link-local, and cloud metadata addresses are blocked by default withErrSSRFBlocked. - Operational Override: Operators running local inference engines (such as Ollama on localhost)
can explicitly permit private networking by settingALLOW_PRIVATE_NETWORKING=true.
✨ feat(quota): 4-card clean layout & 100% scale for Antigravity quota
- Background: Antigravity quotas were computed against an artificial scale of
1000.0,
and displayed as flat model lists rather than the 4 canonical time-window quota buckets
provided by Google Antigravity (retrieveUserQuotaSummary). - Backend: Standardized Antigravity quotas to a pure 100.0 percentage scale with window
metadata (5handweekly). - Dashboard UI: Introduced
AntigravityQuotaCard.svelterendering a 2x2 grid (Gemini 5h,
Gemini Weekly, Claude/GPT 5h, Claude/GPT Weekly) with color-coded progress bars and countdown timers.
🐛 Issue #280 — MiMo and every other provider vanished from the topology map, and the Test button faked usage
Two defects made a provider the operator never used look permanently busy on
Usage → Overview, and they compounded each other.
-
The "Test" button metered probes as traffic.
POST /api/models/testis
dispatched straight into the chat handler with a probe context
(media/model_test_ping.go:84), and the probe context is honoured at every
other side effect in the forwarding path — connection unlocks, cooldown
clears, model-lock releases, fallback skips. The one gap was the usage write:
tryForwardWithConnectioncalledlogUsageoutside the
IsProbeContextguard, so a single click wrote a realusageHistoryrow and
ausageDaily.byProviderbucket. A provider with a passing test appeared to
have served a request nobody sent, and its tokens landed in the daily totals
and the est. cost. "Check all models" multiplies this by the catalog size.
tryForwardWithConnectionnow returns early on a probe, before any usage is
written — the request still reaches the provider, so the button still works. -
The topology map drew every configured connection, forever. The map is
built client-side inAnalyticsViewfrom five sources, and four of them are
usage-windowed throughstats.byProvider. The fifth was not: the connections
loop drew any non-inactiveproviderConnectionsrow. Since that list is the
whole table, a provider the operator had merely configured sat on the map
regardless of the selected window, and outlived both the connection and the
period. It is now gated on the same window as every other source. The lookup
resolves a connection's stored id against the catalog's canonical id and its
published alias, because the backend keys usage canonically while a
connection row may hold either.
Verified in a real browser against a running gateway seeded with an active
xiaomi-mimo connection and zero traffic: t...
v1.9.12-exp.1
Experimental build. Not published to
latest, and9router-go update
will not offer it to an install running a stable release. Track it
explicitly:docker pull luqmenul/9router-go:1.9.12-exp.1Issues found here are still worth reporting — but check the stable
release first, because experimental breakage is usually already
fixed by the next tagged build.
🐛 Quota Tracker now shows 5h / weekly windows for Claude OAuth accounts (#293)
- Claude connections were listed in the tracker but never read:
fetchProviderUsagehad no
claudecase, so every account answered{plan:"claude", quotas:{}}and the dashboard
printed "Account active. No quota limits tracked." without any upstream call. - The dashboard now reads
GET api.anthropic.com/api/oauth/usage(port of upstream
getClaudeUsage) and renderssession (5h),weekly (7d)and model-scoped weekly rows
(weekly opus (7d),weekly fable (7d), …) with used / remaining / resetAt. - Reads are cached per token for 60s, a 429 puts the token on a cooldown (honouring
Retry-After) and keeps serving the last good reading; a 401/403 goes through the existing
refresh-and-retry path.
🐛 /v1/models top-level context_length / max_completion_tokens now match capabilities (#291)
- Top-level limits came from the generic substring table (
claude-*→ 200000/8192) while
capabilities.contextWindow/maxOutputcarried the per-model values, so one entry
contradicted itself (e.g.cc/claude-opus-5-5: 200000/8192 vs 1000000/128000). Clients that
read the OpenAI-style fields — such aspi-9router-ext— capped Claude output at 8192. - The top-level fields (and
/v1/models/info) are now filled from the same chain as
capabilities(declared custom limits → per-model caps → catalog → substring table); the
substring table only fills gaps. claude-fable-5-1gets its documented 1M context / 128K output in the per-model table.
fix(codex): strip temperature/top_p before forwarding to the Codex backend
Editors such as Zed send temperature on every chat-completions turn, and
buildResponsesBody forwarded it as-is. The ChatGPT Codex OAuth backend
answered 400 {"detail":"Unsupported parameter: temperature"} on the reasoning
models behind Codex, so each turn burned one failed upstream call before the
combo moved on — the 400 is request-scoped, so the connection was never locked
and the failure repeated every time.
temperature and top_p now join max_tokens / max_completion_tokens /
max_output_tokens in stripCodexUnsupportedTokenParams. The strip stays on
the Codex forward path only: buildResponsesBody is shared with the other
Responses-API providers (grok-cli, kimi, muse, opencode-zen), whose backends do
accept the field, so moving it there would silently change their requests.
Parity with upstream open-sse/executors/codex.js, whose transformRequest
deletes body.temperature and body.top_p alongside the generation limits.
Verification: go test ./internal/proxy/executor/ passes. The fake upstream
in TestForwardCodex_UpstreamNeverSeesTokenParams now rejects all five
parameters the way ChatGPT does and the client body carries temperature and
top_p, so the wiring — not just the helper — is pinned. Manually verified on
Windows with Zed: gpt-6-astra went from a 400 on every turn to 200.
Known gap (not introduced here): upstream deletes a longer list
(frequency_penalty, presence_penalty, logprobs, top_logprobs, n,
seed, user, metadata, stream_options, safety_identifier,
previous_response_id) and then filters the body through a Responses-API
allowlist. This port still deletes only the five fields above, so a client
sending e.g. frequency_penalty to Codex can still hit the same 400.### 📦 feat(settings): database storage & retention profile (observability limits)
- Background:
requestDetailspreviously retained diagnostic entries with hardcoded limits
(500,000 rows ceiling, 30 days) and unconditionally logged massive payloads on every call,
causing SQLite databases to swell up to hundreds of megabytes on user machines. - Observability Mode: Added
observabilityMode(full,errors_only,off). When set to
off, request detail logging is bypassed completely (<10 MB permanent DB). When set toerrors_only,
only failed/fallback transactions are recorded. - Configurable Retention: Added
retentionDaysandmaxRequestRecords(default 5,000) settings. - Maintenance Endpoints: Added
GET /api/settings/database/stats,POST /api/settings/database/prune,
andPOST /api/settings/database/vacuumallowing operators to prune and reclaim free pages back to disk.
🛡️ feat(security): SSRF protection via safe network dialer
- Background: Custom provider nodes and custom proxies previously permitted dialing
any IP address without restriction, posing a risk of internal network scanning or
cloud metadata exposure (169.254.169.254). - Hardening: Implemented
NewSafeDialer()andNewSafeTransport()ininternal/proxy/ssrf.go,
enforcing destination IP inspection at the socket control level. Dials to loopback, RFC 1918
private LANs, link-local, and cloud metadata addresses are blocked by default withErrSSRFBlocked. - Operational Override: Operators running local inference engines (such as Ollama on localhost)
can explicitly permit private networking by settingALLOW_PRIVATE_NETWORKING=true.
✨ feat(quota): 4-card clean layout & 100% scale for Antigravity quota
- Background: Antigravity quotas were computed against an artificial scale of
1000.0,
and displayed as flat model lists rather than the 4 canonical time-window quota buckets
provided by Google Antigravity (retrieveUserQuotaSummary). - Backend: Standardized Antigravity quotas to a pure 100.0 percentage scale with window
metadata (5handweekly). - Dashboard UI: Introduced
AntigravityQuotaCard.svelterendering a 2x2 grid (Gemini 5h,
Gemini Weekly, Claude/GPT 5h, Claude/GPT Weekly) with color-coded progress bars and countdown timers.
Fixed
ollama-cloudprovider connections were stuck on a legacy provider id that
never existed in the catalog (onlyollamadoes — the id came from a
sibling project's naming, synced in by hand). Chat requests on those
connections failed withhas no baseUrl in connection data and is not in KnownProviders, and the Analytics provider topology card rendered the
connection's account email as the node label instead of a provider name.
MigrateLegacyProviderIDsnow rewritesollama-cloud→ollamaon every
boot (idempotent, best-effort — a failure logs and does not block
startup), andtopologyNameinAnalyticsView.svelterejects any
fallback label containing@so an OAuth account email can never stand in
for a provider name again, independent of the email-privacy toggle.- Verified:
go test ./internal/db/...(new
TestMigrateLegacyProviderIDs/TestMigrateLegacyProviderIDsNilDB),
bun test src(353 pass / 0 fail),make vet-svelte(0 unresolved
identifiers, baseline tightened 83 → 82).