Skip to content

Releases: luqman-v1/9router-go

v1.9.14-exp.3

v1.9.14-exp.3 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 17 Oct 16:31

Experimental build. Not published to latest, and 9router-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.3

Issues 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/relay path.
  • IsEdgeRelay() now also returns true when the declared type is "" or
    "http" and any pool URL matches IsEdgeRelayURL. A relay URL created
    with the default type can no longer be routed into the proxy path. The
    explicit vercel / cloudflare / deno / netlify types are unchanged, and
    a real proxy — loopback, a :8080 CONNECT proxy, socks5 — is still a proxy.

internal/handlers/chat/connections_proxy.go:

  • getClientForConnection returns the plain h.Client for an edge relay
    instead of wrapping it in http.ProxyURL. The request still leaves through
    the relay; it simply travels as a forwarded request carrying the
    x-relay-target / x-relay-path headers getProviderConfig injects, 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_format for DeepSeek models in ForwardOpencodeGo using 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.
    MatchModelPattern is 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 -haiku needs 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/models as 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 in logUsage through the new
    middleware.AuthenticatedApiKeyFromContext — the auth middleware already put
    the key on the context, so no request-path change was needed.
    InsertUsageHistory takes it as a trailing variadic so the fixtures and
    migrations that have no caller to name keep compiling unchanged.
  • tokenUsageSince sums clientKey = ? 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.quotaResetId plus ResetApiKeyQuota and
    POST /api/keys/{id}/reset-quota: the mark is the usageHistory row id the
    budget is measured from, taken in the same transaction as the write. A row id
    and not a timestamp because usageHistory.timestamp is RFC3339 at second
    precision, so a reset issued in the...
Read more

v1.9.14-exp.2

v1.9.14-exp.2 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 16 Oct 08:46
d33e7b9

Experimental build. Not published to latest, and 9router-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.2

Issues 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",
    or stop_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.

  • codexFailedEmptyTurn is 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.failed is now recorded as well, and normalizeCodexFailureEvent
    rewrites it into the {"error":{...}} shape the reader understands, so the
    upstream's real message survives.
  • codexEventErrorInfo is shared by the returned error and the in-band frame,
    so the client's error line and the failure row cannot disagree.
  • The pattern stripping Codex does need (\p{...} Unicode property escapes)
    was checked against upstream and is already at parity: upstream's
    open-sse/utils/codexToolSchema.js strips 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 expiresAt that is not an RFC3339 timestamp. The auth middleware enforces an expiry with time.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 metadata blob instead of storing it verbatim. It must be a JSON object within 2 KB and may only carry customerId, contact, notes, priceCents (whole, non-negative cents) and billingCycle (one-off, monthly, quarterly, yearly); the refusal lists the accepted keys, because a typo such as customerID would 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 request
  • X-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 system and developer message with \n\n into Responses instructions during 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 StaticHeaders through the freebuff session/run helpers (session request, run start/finish, session release, model switch) and stamp relay routing onto those requests via applyRelayHeaders, so relay-routed connections no longer bypass the relay on control-plane calls. Freebuff still owns its CLI identity headers; tests pass nil to keep single-process behavior.
  • applyRelayHeaders sets x-relay-path to the endpoint the call needs, taking x-relay-target from StaticHeaders. Copying the connection's header verbatim was wrong: BuildEdgeRelayHeaders derives 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 by freebuff_relay_test.go, whose fake relay forwards to x-relay-target + x-relay-path exactly 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, mirroring providers.IsFreeTierModel. The provider-switch reset now also clears the compatible-model verdicts, and a Test all run that outlives its panel stops writing.
  • Scope unchanged: relay routing covers the chat pipeline and the freebuff session/run helpers. HandleFreebuffSessionSwitch and fetchFreebuffUpstreamSession still read no StaticHeaders, 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...

Read more

v1.9.14-exp.1

v1.9.14-exp.1 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 15 Oct 14:35

Experimental build. Not published to latest, and 9router-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.1

Issues 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 system and developer message with \n\n into Responses instructions during 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 -free suffix in IsOpencodeFreeTierModel on OpenCode providers (opencode, opencode-zen, opencode-go), ensuring preview and uncataloged free models route to /inference/v1 on opencode-zen connections 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/current exchanges the userToken for 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/create opens 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 in X-Ds-Pow-Response. That hash is SHA3-256 with the
    last 23 KECCAK-p[1600] rounds; Go's crypto/sha3 only exposes 24, so the
    sponge is implemented in deepseek_web_pow.go with 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 FINISHED signal is reported as
    an error, not as a completed turn. Serving the partial text with
    finish_reason: stop is how a client comes to treat a truncated reply as the
    model's conclusion.
  • Origin and Referer follow the connection's configured base URL, so a
    baseUrl override is honoured end to end instead of only for the completion
    call.
  • deepseek-web and gemini-web are separate entries from deepseek and
    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.
  • AddConnectionModal now persists authType: '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 ...

Read more

v1.9.13

Choose a tag to compare

@github-actions github-actions released this 15 Oct 01:30
3005746

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
    (order read_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 Account or Usage by API Key
    consistently displayed the model ID rather than the account display name or API key name,
    because row.rawModel was evaluated first in the fallback expression. Additionally,
    Usage by Endpoint was always empty because HandleUsageStats never aggregated the
    byEndpoint data.
  • Frontend: Explicitly checked tableView in UsageBreakdownTable.svelte to render
    row.accountName for accounts, row.keyName / row.apiKeyMasked for API keys, and
    row.endpoint for endpoints.
  • Backend: Added byEndpoint parsing in HandleUsageStats daily rollup and usage_window_cache.go.

v1.9.12

Choose a tag to compare

@github-actions github-actions released this 14 Oct 23:53
983fe81

Fixed

  • Deterministic request serialization for ALL providers: chat-completions bodies leaving via newJSONRequest are now re-serialized with encoding/json/v2 Deterministic(true) (map members sorted). Previously only the opencode/zen lanes carried per-lane marshalStable calls; 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, Gemini contents, malformed JSON) pass through byte-identical, so signed flows (e.g. COSY) are unaffected. Parity note: the JavaScript decolua/9router original 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 tokenQuota column per API key. It caps the
    cumulative prompt+completion tokens a key may spend over its whole life;
    once reached, requests are refused with 403 and X-Token-Quota-Limit /
    X-Token-Quota-Used headers. Usage is summed from usageHistory under 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 queueTimeoutMs column. 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 returning 429. 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_seconds histogram records
    how long requests wait for a slot.
  • Dashboard / API / backup: tokenQuota and queueTimeoutMs are exposed
    on GET /api/keys, accepted on PUT /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, and usageHistory gains an apiKey index 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
    queueTimeoutMs of 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/..., and go 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, providerConnections rows created via OAuth lacked a priority value
    and were stored as NULL. In sortProviderConnections, NULL priorities defaulted to math.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: sortProviderConnections now 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) in internal/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/responses client routed to a Responses-speaking upstream (codex, perplexity-agent, opencode, opencode-zen, grok-cli) failed with 400 {"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=400 log line. Every request failed while the same model worked with token savers turned off — and for gpt-6-sol / gpt-6-luna the whole Responses-Lite prefix the executor adds is affected, so no other model on the connection worked either. Closes #307.
  • Why. tokensaver.InjectSystemPrompt had one injector for both messages[] and input[]. On a Responses body it wrote a Chat system turn, {"role":"system","content":"<text>"}, at input[0]. The Responses item schema wants {"type":"message","role":"system","content":[{"type":"input_text","text":"…"}]} — a turn with no type and a string content where the validator reads a block array. Upstream dispatches by wire shape and keeps its two injectors apart (open-sse/rtk/systemInject.js: injectChatSystem vs injectResponsesInputSystem); 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. InjectSystemPrompt now dispatches on the same three shapes upstream does, and input[] gets its own injector: it appends the prompt as an input_text block to an existing system/developer message item, prepends one when the conversation has none, and widens a client-supplied string content into blocks rather than leaving a string behind. An item without type:"message" is never adopted. Chat Completions bodies are u...
Read more

v1.9.12-exp.5

v1.9.12-exp.5 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 13 Oct 07:47

Experimental build. Not published to latest, and 9router-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.5

Issues 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/responses client routed to a Responses-speaking upstream (codex, perplexity-agent, opencode, opencode-zen, grok-cli) failed with 400 {"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=400 log line. Every request failed while the same model worked with token savers turned off — and for gpt-6-sol / gpt-6-luna the whole Responses-Lite prefix the executor adds is affected, so no other model on the connection worked either. Closes #307.

  • Why. tokensaver.InjectSystemPrompt had one injector for both messages[] and input[]. On a Responses body it wrote a Chat system turn, {"role":"system","content":"<text>"}, at input[0]. The Responses item schema wants {"type":"message","role":"system","content":[{"type":"input_text","text":"…"}]} — a turn with no type and a string content where the validator reads a block array. Upstream dispatches by wire shape and keeps its two injectors apart (open-sse/rtk/systemInject.js: injectChatSystem vs injectResponsesInputSystem); 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. InjectSystemPrompt now dispatches on the same three shapes upstream does, and input[] gets its own injector: it appends the prompt as an input_text block to an existing system/developer message item, prepends one when the conversation has none, and widens a client-supplied string content into blocks rather than leaving a string behind. An item without type:"message" is never adopted. Chat Completions bodies are untouched.

  • Verified. internal/tokensaver covers the three Responses paths; the new internal/integration/codex_responses_tokensaver_test.go boots the real router with a codex connection aimed at a fake /responses upstream and asserts on the outbound body — a control case with the saver off and the regression case with Caveman on. The regression case fails on main with input[1].content is string, want an array of content blocks and 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 one systemdScope helper,
    which keeps --user from being dropped and the command from landing on the
    system bus (root prompt, user unit left enabled). There is no system-unit
    scope or --system flag.
  • launchd (macOS), runit (root, or ~/service under a user runsvdir),
    and cron @reboot plus an XDG autostart desktop entry, all tagged with a
    # 9router-go-autostart marker 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 install refuses without it rather than silently installing a
    different backend (run with sudo, or use --backend cron).
  • Windows — a scheduled task created with schtasks at logon, run as the
    installing user rather than SYSTEM (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: sumopod registered as an API-key provider (bearer transport, OpenAI-compatible) pointing at ai.sumopod.com/v1/chat/completions with the sp short alias and a 61-model catalogue synced from the live /v1/models response (embeddings excluded). Live /v1/models import is wired via modelsListURL (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: fetchProviderUsage had no
    claude case, 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 renders session (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 / maxOutput carried 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 as pi-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-1 gets 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 Responses reasoning.effort) to a Claude
    upstream got thinking: {"display": "summarized"} with no type, and Anthropic answered
    400 thinking.type: Field required.
  • The request translation now sets the missing type, mirroring the claude-adaptive /
    claude-budget branches of upstream thinkingUnified.js: adaptive + output_config.effort
    for ada...
Read more

v1.9.12-exp.4

v1.9.12-exp.4 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 13 Oct 02:36

Experimental build. Not published to latest, and 9router-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.4

Issues 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 one systemdScope helper,
    which keeps --user from being dropped and the command from landing on the
    system bus (root prompt, user unit left enabled). There is no system-unit
    scope or --system flag.
  • launchd (macOS), runit (root, or ~/service under a user runsvdir),
    and cron @reboot plus an XDG autostart desktop entry, all tagged with a
    # 9router-go-autostart marker 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 install refuses without it rather than silently installing a
    different backend (run with sudo, or use --backend cron).
  • Windows — a scheduled task created with schtasks at logon, run as the
    installing user rather than SYSTEM (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: sumopod registered as an API-key provider (bearer transport, OpenAI-compatible) pointing at ai.sumopod.com/v1/chat/completions with the sp short alias and a 61-model catalogue synced from the live /v1/models response (embeddings excluded). Live /v1/models import is wired via modelsListURL (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: fetchProviderUsage had no
    claude case, 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 renders session (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 / maxOutput carried 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 as pi-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-1 gets 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 Responses reasoning.effort) to a Claude
    upstream got thinking: {"display": "summarized"} with no type, and Anthropic answered
    400 thinking.type: Field required.
  • The request translation now sets the missing type, mirroring the claude-adaptive /
    claude-budget branches of upstream thinkingUnified.js: adaptive + output_config.effort
    for adaptive models, enabled + budget_tokens otherwise. max_tokens is 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 explicit thinking.type from the
    client is never overwritten.
  • reasoning_effort: none/off (or Responses effort: none) with a captured display now 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 hardcoded claude.
  • claude-fable-5 is declared claude-adaptive in the per-model table; it used to fall through the
    *claude*fable* glob to claude-budget, which would have sent the manual enabled/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...

Read more

v1.9.12-exp.3

v1.9.12-exp.3 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 12 Oct 12:37

Experimental build. Not published to latest, and 9router-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.3

Issues 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: sumopod registered as an API-key provider (bearer transport, OpenAI-compatible) pointing at ai.sumopod.com/v1/chat/completions with the sp short alias and a 61-model catalogue synced from the live /v1/models response (embeddings excluded). Live /v1/models import is wired via modelsListURL (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: fetchProviderUsage had no
    claude case, 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 renders session (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 / maxOutput carried 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 as pi-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-1 gets 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: requestDetails previously 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 to errors_only,
    only failed/fallback transactions are recorded.
  • Configurable Retention: Added retentionDays and maxRequestRecords (default 5,000) settings.
  • Maintenance Endpoints: Added GET /api/settings/database/stats, POST /api/settings/database/prune,
    and POST /api/settings/database/vacuum allowing 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() and NewSafeTransport() in internal/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 with ErrSSRFBlocked.
  • Operational Override: Operators running local inference engines (such as Ollama on localhost)
    can explicitly permit private networking by setting ALLOW_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 (5h and weekly).
  • Dashboard UI: Introduced AntigravityQuotaCard.svelte rendering 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/test is
    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:
    tryForwardWithConnection called logUsage outside the
    IsProbeContext guard, so a single click wrote a real usageHistory row and
    a usageDaily.byProvider bucket. 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.
    tryForwardWithConnection now 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 in AnalyticsView from five sources, and four of them are
    usage-windowed through stats.byProvider. The fifth was not: the connections
    loop drew any non-inactive providerConnections row. 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 ...

Read more

v1.9.12-exp.2

v1.9.12-exp.2 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 12 Oct 09:28

Experimental build. Not published to latest, and 9router-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.2

Issues 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: fetchProviderUsage had no
    claude case, 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 renders session (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 / maxOutput carried 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 as pi-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-1 gets 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: requestDetails previously 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 to errors_only,
    only failed/fallback transactions are recorded.
  • Configurable Retention: Added retentionDays and maxRequestRecords (default 5,000) settings.
  • Maintenance Endpoints: Added GET /api/settings/database/stats, POST /api/settings/database/prune,
    and POST /api/settings/database/vacuum allowing 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() and NewSafeTransport() in internal/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 with ErrSSRFBlocked.
  • Operational Override: Operators running local inference engines (such as Ollama on localhost)
    can explicitly permit private networking by setting ALLOW_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 (5h and weekly).
  • Dashboard UI: Introduced AntigravityQuotaCard.svelte rendering 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/test is
    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:
    tryForwardWithConnection called logUsage outside the
    IsProbeContext guard, so a single click wrote a real usageHistory row and
    a usageDaily.byProvider bucket. 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.
    tryForwardWithConnection now 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 in AnalyticsView from five sources, and four of them are
    usage-windowed through stats.byProvider. The fifth was not: the connections
    loop drew any non-inactive providerConnections row. 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...

Read more

v1.9.12-exp.1

v1.9.12-exp.1 Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 12 Oct 07:37
896439b

Experimental build. Not published to latest, and 9router-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.1

Issues 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: fetchProviderUsage had no
    claude case, 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 renders session (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 / maxOutput carried 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 as pi-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-1 gets 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: requestDetails previously 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 to errors_only,
    only failed/fallback transactions are recorded.
  • Configurable Retention: Added retentionDays and maxRequestRecords (default 5,000) settings.
  • Maintenance Endpoints: Added GET /api/settings/database/stats, POST /api/settings/database/prune,
    and POST /api/settings/database/vacuum allowing 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() and NewSafeTransport() in internal/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 with ErrSSRFBlocked.
  • Operational Override: Operators running local inference engines (such as Ollama on localhost)
    can explicitly permit private networking by setting ALLOW_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 (5h and weekly).
  • Dashboard UI: Introduced AntigravityQuotaCard.svelte rendering a 2x2 grid (Gemini 5h,
    Gemini Weekly, Claude/GPT 5h, Claude/GPT Weekly) with color-coded progress bars and countdown timers.

Fixed

  • ollama-cloud provider connections were stuck on a legacy provider id that
    never existed in the catalog (only ollama does — the id came from a
    sibling project's naming, synced in by hand). Chat requests on those
    connections failed with has 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.
    MigrateLegacyProviderIDs now rewrites ollama-cloud → ollama on every
    boot (idempotent, best-effort — a failure logs and does not block
    startup), and topologyName in AnalyticsView.svelte rejects 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).