Skip to content

feat(openrouter): pin and sort chat providers; LoCoMo v21 on glm-5.3-flash - #359

Open
fazpu wants to merge 5 commits into
mainfrom
feat/chat-provider-routing
Open

feat(openrouter): pin and sort chat providers; LoCoMo v21 on glm-5.3-flash#359
fazpu wants to merge 5 commits into
mainfrom
feat/chat-provider-routing

Conversation

@fazpu

@fazpu fazpu commented Sep 3, 2026

Copy link
Copy Markdown
Member

Adds provider routing for chat completions (it existed only for embeddings), rolls the LoCoMo protocol to v21 on z-ai/glm-5.3-flash, and records what a real ingestion run taught us about pinning providers.

Why

Chat completions always used OpenRouter's ordinary marketplace routing, so a benchmark could not be confined to named hosts — and two runs on the same model but different providers are not necessarily comparable, since quantization, context window and throughput all differ per host.

What's here

REMEMBERSTACK_OPENROUTER_CHAT_PROVIDER_ONLY — allowlist of provider slugs (provider.only). allow_fallbacks stays on: only already bounds the pool, so failover moves between listed providers and cannot leave them.

REMEMBERSTACK_OPENROUTER_CHAT_PROVIDER_SORTprice | throughput | latency (provider.sort).

RS-LoCoMo-Full-v21 — every chat seat on z-ai/glm-5.3-flash, providers pinned to z-ai,novita,deepinfra,gmicloud, embeddings unchanged on qwen3-embedding-8b via nebius. The model swap could not be config: the protocol asserts its ingest bindings by exact dict equality, so a deployment on a different model fails readiness outright. Answer agent and judge move too, on operator instruction — so v21 numbers are not comparable with v19/v20, and a score change cannot be attributed to the memory system rather than the grader.

The finding worth reading

plan/analysis/openrouter_provider_routing_overload.md.

A conv-48 ingestion dead-lettered 28 items, all engine_overloaded 429s from one provider. The first diagnosis was price-first routing picking the cheapest, most-contended host. It fit every observation, and it was wrong — it predicted sort: throughput would fix things, and after deploying that, all 28 replays failed identically.

The real cause is capability, not preference. Every chat call sends a strict json_schema, and of the four allowed providers only DeepInfra advertises structured_outputs for this model:

Provider structured_outputs
DeepInfra true
Z.AI / Novita / GMICloud false

So provider.only bounded the pool by name to four while the request bounded it by capability to one. sort had nothing to reorder; allow_fallbacks had nowhere to go. Isolated one parameter at a time: max_tokens alone and reasoning alone route to Novita; the strict schema alone pins to DeepInfra and 429s.

Two things also documented because they cost real time:

  • allow_fallbacks does not give you 429 failover. The docs say "when the primary is unavailable" without naming codes; a provider-returned 429 arrives as "Provider returned error" and is not re-routed.
  • Concurrency is part of the routing decision. The identical allowlist ran 1,842 calls with one failure at 1 worker per stage and collapsed at the reference topology.

The wrong diagnosis is kept in the analysis next to the right one, so the next reader sees how it was caught rather than just the conclusion.

Also fixed

Adding the allowlist to the selfhost attestation dict broke the exact-equality readiness gate for every deployment. The key is now expected by the protocol and replayed by the shard runner, so a restored store reproduces the routing it was built with.

Verification

323 tests pass; ruff and pyright clean. Behaviour is unchanged when both settings are unset, so existing deployments are untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_018eazkrM7j7scrwQUcUWbbS

fazpu and others added 5 commits September 1, 2026 14:49
Provider routing existed only for embeddings; chat completions always used
OpenRouter's ordinary marketplace routing, so a run could not be confined to
a named set of hosts. That matters for benchmarking: two runs on the same
model but different providers are not necessarily comparable, because
quantization, context window and throughput all differ per host.

Adds REMEMBERSTACK_OPENROUTER_CHAT_PROVIDER_ONLY (comma-separated or JSON
list of provider slugs, e.g. z-ai,novita,deepinfra,gmicloud), mirroring the
existing embedding parser.

allow_fallbacks stays ON, which is not a loophole: 'only' already bounds the
candidate pool, so failover moves between the listed providers and can never
leave them. A long ingestion run would otherwise dead-letter a stage on one
host's 5xx or 429.

The allowlist is recorded in the selfhost attestation dict, because which
hosts served a run is measurement provenance, not incidental configuration.

Unset preserves today's behaviour exactly, so existing deployments are
untouched. Tests cover the allowlist payload, the unset case, non-leakage
into the embeddings path, and both env shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eazkrM7j7scrwQUcUWbbS
Rolls the pinned protocol v19 -> v20 and moves every chat seat from
openai/gpt-5.6-luna to z-ai/glm-5.3-flash, with chat completions restricted
to the operator-named providers z-ai, novita, deepinfra and gmicloud.
Embeddings stay on qwen/qwen3-embedding-8b via nebius.

The model swap could not be a config change. The protocol ASSERTS its ingest
bindings -- runner.py checks readiness.model_bindings == the expected map by
exact dict equality -- so a deployment on a different model fails readiness
and refuses to run. Rolling the protocol is the mechanism the repo already
uses for this (v17 -> v18 -> v19), and the registry holds one current
protocol at a time.

The answer agent and judge move too, on operator instruction. That is worth
stating plainly: the judge IS the scorer, so v20 numbers are NOT comparable
with v19/v18 runs, and a score change cannot be attributed to the memory
system rather than the grader. The protocol fingerprint changes accordingly
(the golden is rolled to 76ac6811...), which is the intended signal.

Also fixes a defect from the preceding commit: adding
openrouter_chat_provider_only to the selfhost attestation dict broke the
exact-equality readiness gate for every deployment. The key is now expected
by the protocol and replayed by the shard runner, so a restored store
reproduces the routing it was built with.

Reasoning effort stays high for the ingest seats and none for
answer/judge, exactly as v19 had it, so this run changes the model and the
provider set and nothing else.

321 tests pass; ruff and pyright clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eazkrM7j7scrwQUcUWbbS
main merged #348 ('keep complete direct answers') and took the v20 protocol
name for it, with answer/judge still on gpt-5.6-luna -- a second session had
already run conv-26 under that v20 on the benchmark host. Two protocols
under one name would make those runs indistinguishable, so this rolls to
v21 and keeps main's answer-prompt fix underneath.

That is the third naming collision with parallel work this session, after
the migration revision id and the resolver generation.

v21 = main's v20 answer-completeness fix + every chat seat on
z-ai/glm-5.3-flash + chat completions pinned to z-ai, novita, deepinfra,
gmicloud. Fingerprint ba5ab840.

321 tests pass; ruff and pyright clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eazkrM7j7scrwQUcUWbbS
A conv-48 ingestion dead-lettered 28 adjudicate_observations items, every
one a 429 from the SAME provider:

  provider_name:       DeepInfra
  provider_error_code: engine_overloaded
  limit_source:        upstream_provider_shared_pool
  is_byok:             false

Two things made that concentrate on one host. OpenRouter's default routing
weights price, and DeepInfra was both the cheapest and the slowest of the
four allowed providers (measured: $0.0001003 at 30.8 tok/s, against Z.AI's
$0.0001581 at 43.9) -- so it was selected first on every call and on all
three engine retries.

And allow_fallbacks did not rescue it, despite the preceding commit arguing
it would. The docs define it as allowing backups when the primary is
'unavailable' and never enumerate the codes; empirically a provider-returned
429 arrives as 'Provider returned error' and is not re-routed. That
assumption is now corrected in the code comments and covered by a test.

Adds REMEMBERSTACK_OPENROUTER_CHAT_PROVIDER_SORT (price|throughput|latency)
and sets throughput for the benchmark protocol.

Sorting beats the obvious fix of dropping DeepInfra from the allowlist:
a denylist hard-codes today's congested host, while sorting moves load off
whichever host is slow at the time, and it preserves the operator's stated
provider set instead of silently narrowing it.

It is a bias, not a guarantee -- if every allowed provider is busy at once,
calls still 429. Concurrency is part of the same decision: the identical
allowlist ran 1,842 calls with one failure at 1 worker per stage and
collapsed at extract 8 / normalize 6 / adjudicate 4 / embed 2.

Written up in plan/analysis/openrouter_provider_routing_overload.md.

323 tests pass; ruff and pyright clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eazkrM7j7scrwQUcUWbbS
The preceding commit blamed price-first routing: DeepInfra was the cheapest
AND slowest of the four allowed providers, so the story went that it won
selection on every call and every retry. That fit every observation and was
wrong.

It predicted that sort=throughput would fix the problem. After deploying the
sort and replaying all 28 dead letters, all 28 failed again on DeepInfra --
which is what falsified it.

The real cause is capability, not preference. Every chat completion here
sends response_format json_schema strict, and of the four allowed providers
only DeepInfra advertises structured_outputs for this model:

  DeepInfra  structured_outputs=true
  Z.AI       false
  Novita     false
  GMICloud   false

So provider.only bounded the pool by NAME to four while the request bounded
it by CAPABILITY to one. sort had nothing to reorder and allow_fallbacks had
nowhere to fall back to. Isolated one parameter at a time: max_tokens alone
and reasoning alone both route to Novita; the strict schema alone pins to
DeepInfra and 429s.

chat_provider_sort is kept -- biasing toward throughput is still sensible
when more than one host is eligible -- but its docstring and test no longer
claim it solves this, and the analysis records the wrong diagnosis alongside
the right one so the next reader sees how it was caught.

Practical rule now written down: when pinning providers, intersect each
endpoint's supported_parameters with what the caller actually sends. An
allowlist of four that supports one is a single point of failure in disguise.

323 tests pass; ruff and pyright clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eazkrM7j7scrwQUcUWbbS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant