OpenRouter's EU route from configuration
OpenRouter serves the same account on two endpoints: https://openrouter.ai/api/v1 (global) and
https://eu.openrouter.ai/api/v1, where requests are decrypted and served in the EU only and a
model with no EU provider answers 404. The key is account-level, so the EU endpoint is a second
route to the same account, never a second account.
What the configuration says
The OpenAI provider module registers an OpenRouterEU catalog source next to OpenRouter. It is
configured like the OpenRouter section:
| Key | Meaning |
|---|---|
OpenRouterEU:Models:N |
The model ids to seed under Provider/OpenRouterEU |
OpenRouterEU:Endpoint |
An override of the EU default https://eu.openrouter.ai/api/v1 |
There is no OpenRouterEU:ApiKey. The seeded Provider/OpenRouterEU node carries
credentialFrom: Provider/OpenRouter and no key: the resolver follows that reference one hop, root
catalog only, to whatever key the instance binds on Provider/OpenRouter (vault-seeded or set in
Settings → Models). There is one key to rotate.
The route ranks at Order 7, after the global route's 6, so a bare wire id both routes carry keeps
resolving where it did; a tier that means the EU node names it by path (below). There is no
OpenRouterEU:Order and no Features:Ai:Providers:OpenRouterEU switch: providers are gated by
their MODULE being loaded, and nothing reads a per-provider feature flag.
On a Hosting record the same keys come from the ai.openRouterEU block (models, endpoint),
rendered to OpenRouterEU__Models__N and OpenRouterEU__Endpoint (an order or enabled on that
block renders nothing);
ai.requiredDataResidency renders AI__RequiredDataResidency, and each section's
dataResidency / dataRetention renders {Section}__DataResidency / __DataRetention.
Two ways a route differs from an ordinary source
A route is a LanguageModelCatalogSource with CredentialFrom set.
- It is seeded only where it is configured —
OpenRouterEU:ModelsorOpenRouterEU:Endpointset. An ordinary source always seeds its provider node so an admin can add a key later; a route has no key of its own to wait for, so an unconfigured one would be an empty duplicate of the account on every instance. - Its model ids are de-duplicated within the route only. The catalog otherwise lets the first
source win a shared wire id. For a route the shared id is the point:
z-ai/glm-5.3exists asProvider/OpenRouter/z-ai/glm-5.3ANDProvider/OpenRouterEU/z-ai/glm-5.3, told apart by node path.
Naming an EU model in a tier
With the same wire id on both routes, a bare id does not say which endpoint is meant. A tier
(ModelTier:Heavy, i.e. the record's ai.tiers.heavy) may therefore name the model by node path:
ModelTier__Heavy = Provider/OpenRouterEU/z-ai/glm-5.3
Tier resolution matches the configured value against each candidate's node path first, then its wire id, and answers the matched candidate's selection: the path when the id is shared, the bare id when it is not. A bare shared id is answered with the path of the lowest-ranked node carrying it (order, then path), never with the ambiguous id.
A route's model never falls back to the global route
A node path selects one node, and its credential is that node's bound provider. When the exact
node yields no key, a node that pins nothing may fall through to the same-id walk (the keyless
catalog entry against a user's keyed copy). A node on a route may not: the same id on the other
route is the same model on a DIFFERENT endpoint, so falling through would send an EU selection to
the global endpoint with the global key and nothing to say so. A route's node resolves through its
reference or not at all. OpenRouterEURouteMeshTest.ARouteWhoseReferencedKeyIsMissing_FailsClosed_NeverOnTheGlobalRoute
pins it (removing the guard turns it red with the global endpoint).
The OpenRouter prompt-cache policy applies to the route as to any *.openrouter.ai host. The
upstream-routing guard (OpenRouterProviderRoutingPolicy) applies to any node on it that pins
providerRouting — the governed review model does; the config-seeded models pin nothing.
Which Provider/OpenRouterEU wins
Two things can create the node: the governed standard pr.review-provider-eu.create (the PR
steward's setup) and a deployment's OpenRouterEU configuration. Both write the same meaning —
the EU endpoint and credentialFrom: Provider/OpenRouter.
The authored node wins. The static import skips a target whose syncBehavior is not Include,
so the standard creates its node CLAIMED (syncBehavior: ExcludeThisOnly, a CreateNode executor
argument). A deployment that later configures the route then imports the models UNDER the governed
node and leaves the node itself alone. An unclaimed authored node would be overwritten on the next
import whose fingerprint changed; AnUnclaimedAuthoredRouteNode_IsOverwritten_WhichIsWhyTheStandardClaimsIt
shows it.
In the other order, the configuration seeds the node first (claimed the same way, as every seeded provider is), and the standard's create is refused as an existing node — there the standard is not needed.
A replica that has not registered the route prunes nothing of it
The route's catalog source is registered by the MeshWeaver.AI.OpenAI module, and a module
activates per replica, on its own schedule. A replica that runs the Provider import before that
module registered its sources enumerates a catalog WITHOUT the route. The prune used to read that
absence as "the build dropped the route" and deleted every Provider/OpenRouterEU/* model.
Measured on memex.systemorph.com on 2026-10-06, after the roll to 3.0.0-ci.10043: the
Provider imports alternated between 9 nodes and 31 nodes as replicas booted. Every 9-node run
pruned the fifteen EU models, for example
Provider/_Activity/import-513c9a4d9d873686-20261006071048764-230c38bd, which reports "pruned 15".
An instance that requires EU residency may use no other model, so every review round failed with
"no model in the catalogue has usable credentials". The models came back only when a 31-node run
re-created them.
The rule now: ModelStaticRepoSource.IsExcludedFromMirror withholds every Provider/{name}
subtree whose catalog source is not registered in the importing process
(BuiltInLanguageModelProvider.SpeaksFor). Still in the listing, and still mirrored:
- the platform's own
Autorouter andTierregistry; - every REGISTERED source's subtree. A model dropped from its configured list is still pruned (MeshWeaver#2211), and a route the deployment stops configuring is still cleaned up.
The direction this closes is the one the prune doctrine prefers
(The Prune Requires a Complete Listing). The
cost is that a provider whose module is uninstalled everywhere keeps its models until an admin
deletes the provider. A stale extra is a nuisance; pruning a live catalog takes the instance's
rounds down. Tests: ProviderPruneOutsideRegisteredSourcesTest, which includes the negative
control that runs the same import without the guard and prunes every EU model.
Declared, not derived
The guard above stops a replica from pruning what it does not know. It does not put back what no
replica can see. After core #6127 gave every module its own container, the MeshWeaver.AI.OpenAI
registrations became invisible to the AI module on every replica, so no import listed
Provider/OpenRouterEU at all. The record still declared all fifteen models
(OpenRouterEU__Models__0..14, OpenRouterEU__DataResidency=Eu, OpenRouterEU__DataRetention=ZDR).
The rule now: what an instance DECLARES in its configuration is part of the catalogue, read by the AI
module directly from IConfiguration (DeclaredLanguageModelCatalog). It does not depend on any
provider module's registration.
- What counts as declared. A section from the profile table (
Anthropic,AzureFoundry,AzureOpenAI,OpenAI,OpenRouter,OpenRouterEU) whoseModelsarray names at least one model. A declared route also declares the provider it takes its key from:OpenRouterEUdeclaresProvider/OpenRouter. A key alone declares nothing. - Effective sources. The catalogue projects every REGISTERED source first, then every declared
provider that no registered source names (
BuiltInLanguageModelProvider.EffectiveSources). When the module's own registration is visible, it wins.DeclaredProfilesMatchModuleRegistrationsTestkeeps the profile table identical to what the OpenAI module registers. - Protected, not frozen.
SpeaksForanswerstruefor a declared provider, so its subtree is mirrored against the declaration on every replica. A declared model is never pruned while the declaration stands. A model the declaration drops is pruned. A provider that is neither declared nor registered falls back to the guard above. - Same declaration, same everything. The credential seed (
ProviderCredentialSeed), the route check (ChatClientCredentialResolver.IsOnRoute) and the registered-id check read the same effective sources. A declared route therefore still fails closed while its module is invisible. - A declaration is not a client. Rounds still need the provider module's
IChatClientFactory. Where the module is not loaded, the provider node stays in the catalogue, and its page says "Client module: MeshWeaver.AI.OpenAI — ⚠️ provider module not loaded on this instance" (English and German). The check reads this process's load contexts live. The catalogue also logs one Warning per provider that it serves from the declaration only. - Tiers (
ModelTier__*) were already read from configuration at resolution time. They name the declared nodes by path (Provider/OpenRouterEU/z-ai/glm-5.3), and those nodes now exist by declaration.
Every instance that runs agents therefore declares its providers, models, residency and tiers in its
Hosting record's ai block. Where the client module is VERIFIED installed and loaded on the live
instance, the record also lists it in requiredModules. Then an instance that loses the module stalls
its rollout instead of rolling out with no client. Requiring a module that is not there holds every new
replica out of readiness, so verify before requiring (Memex#673 records the evidence per instance). The control
image carries MeshWeaver.AI.OpenAI itself (Modules__Assemblies__102), because control has no plugin
repository to install it from. Tests: DeclaredLanguageModelsSurviveImportTest. It has a negative control:
the same import with declarations off prunes every EU model.
Models come back when they go missing
The two sections above stop the import from pruning a declared model. They do not bring back a model that left the partition some other way, and before Plugins#3143 nothing did.
Why a lost model stayed lost. Only one thing writes the LanguageModel children: the Provider
partition's static-repo import of ModelStaticRepoSource. It runs at boot, and it short-circuits on
a content-addressed marker. If Provider/_Activity/import-{fingerprint} is Succeeded, the import
answers Skipped without reading the partition. Its skip arm re-checks only the partition root and
_Policy (StaticRepoImporter, core, SkipWithGovernanceHeal). So a model removed after a
Succeeded import stayed absent through every later boot with the same catalogue:
- a person deleted and recreated the provider (the delete cascades to its children);
- an import at another fingerprint pruned the model (the 2026-10-06 9-node replica).
It came back only when the catalogue's content changed and minted a new fingerprint. Until then every
headless round on that provider ended NO_USABLE_MODEL.
What happens now. ProviderCatalogConvergence (MeshWeaver.AI, DB-synced Provider partition only)
starts once the boot import has settled.
- It keeps ONE live listing of the partition's
ModelProviderandLanguageModelnodes and compares it with what the catalogue emits. The provider node counts too: a delete cascades bottom-up, so a reconcile started by the first vanished model can land before the provider itself is gone. - When an emitted model is absent, it re-runs the same import with
ImportConflictPolicy.Reconcile. That is the importer's own "the partition was measured to disagree with the marker" mode. The provider node stays create-if-absent, and a write the importer refuses stays refused. - It then re-runs the credential seed, because a recreated provider node has no key.
- Nothing is polled. A delete is answered when the listing sees it, and a replica that boots after the loss repairs it on its first listing.
- It runs one reconcile per DISTINCT gap. A gap the last reconcile was already sent to close is not sent again, so a model that cannot be written gives one attempt and a reading, never a loop.
- 🚨 It takes
ModelStaticRepoSourceas itself (the module registers it under its own type and forwardsIStaticRepoSourceto that one instance). It must never pick it out ofIEnumerable<IStaticRepoSource>by concrete type: on a portal the AI module's services live in a module scope, and every interface-typed registration reaches the root as a forwarding proxy (coreModuleServiceRoute.Proxy), so anOfType<ModelStaticRepoSource>()over that list finds nothing. The first version did exactly that. Measured on the control instance, 2026-10-09: every replica logged[ProviderCatalog] no ModelStaticRepoSource is registeredat boot, andprovider_modelsread "not measured yet" for hours, so the convergence never ran in production while every test, which wires the module without that scope, passed.ConvergenceFindsItsSourceThroughModuleProxiesTestwires the module behind forwarders and pins it.
A provider without its models says so.
/healthcarriesprovider_models, a census entry that prints whatever its status. It is Degraded, never Unhealthy, and has no probe tag, so it never takes a replica out of rotation. It names each provider, the models it lacks, and what the last reconciling import answered. Example: "Provider/OpenRouterEU: 14 of 15 catalogue node(s) missing (…) — a round on these models ends NO_USABLE_MODEL until they are back".- The provider page no longer says "No models configured" for a provider whose own node lists models. It says "⚠️ This provider lists N model(s) — … — and none of them is in the catalogue" (English and German).
Consequence: to remove a model, drop it from the declaration. Deleting its node only lasts until the next listing.
Tests: ProviderModelsComeBackTest. Its negative control deletes and recreates the provider, re-runs
the boot import, and gets Skipped with the model still absent. The same sequence with the
convergence running brings the model back.
Personal data is masked before it leaves — precisely
Every chat-completion request on a data-protection-bound route — a model that declares a
DataResidency, or any request to the EU endpoint itself (config-seeded models carry no node-level
declaration) — passes OpenRouterPersonalDataPolicy before it leaves the process. It masks the
strings under the body's messages (system and user text, assistant text, tool-call arguments, tool
results) with PersonalDataScrubber, and leaves the model id, the tool schemas and the provider
object alone. A body it cannot read, or a detector that times out, refuses the request: a protected
route that cannot scrub does not send.
What is masked, and why each detector cannot fire on code:
| Placeholder | What | Why code survives |
|---|---|---|
[EMAIL] |
an address with a real domain | reserved domains (example.com/net/org, .test, .example, .invalid, .local, .localhost) and role mailboxes (noreply, git, …) are kept; pkg@1.2.3, @Attribute and image@sha256: have no domain |
[PHONE] |
+ country code, 8–15 digits |
a + between operands is followed by a space or is short |
[IBAN_NUMBER] |
an IBAN that passes the mod-97 check | an identifier of the same shape fails the checksum 96 times in 97 |
[ADDRESS] |
Bahnhofstrasse 12, 8001 Zürich, 221 Baker Street |
needs a street word and a house number |
[PERSON_NAME] |
a name the request DECLARES to be a person's, and every other whole-word occurrence of it in the same request | see below |
A name has no shape of its own, so the scrubber does not guess. It learns names from the request: the
two to four words after an attribution cue (on behalf of, Reporter:, submitted by,
Signed-off-by:, Co-Authored-By:, …), in a person-name field ("submittedByName": "…"), or before an
address in angle brackets. A word is capitalised (Erika) or all capitals (MUSTERMANN), and any word
but the last may be an initial (J. Smith); the declared position is what makes those forms safe to
learn. A name word never has a capital after a lowercase letter, so prSweepStuck, OpenRouterEU and
ChangeType can never be one; a name followed by a version number (Claude Opus 5.5) is a product. A
learned name is then masked wherever it stands — inside code too, because a real person's name in a
test fixture is still personal data. A SINGLE word in a person-name field ("fullName": "Madonna") is
masked where it is declared but not learned: one word cannot be told from Admin or System, and
masking it across the request would corrupt the code. All learned names of a request are matched by
one compiled alternation, so masking is one pass per string, not one per name.
The placeholders are the ones the provider guardrail used, so the write-back refusals that already
know them keep working: PushFix refuses a fix that would add one (BugFixActions.ScrubberTokens), and
the PR fixer refuses a file that gains one (PrFixer.NewRedactionMarker).
Why this replaces the provider's guardrail (Plugins#2567, #3043)
Measured on the filed issues: prSweepStuck is 393 became [PERSON_NAME] is 393, dataResidency: Eu
became dataResidency: [PERSON_NAME], a meshService.… call and Blazor were masked, and file reads
reached the fixer with [PERSON_NAME]/[ADDRESS] spliced into code. No code in either repository
writes those tokens: they come from OpenRouter's Sensitive Info guardrail on the account, whose
person-name and address presets are statistical recognisers OpenRouter marks beta. It scans
only the INPUT side (messages, tool-call arguments), offers no per-request override and no code
exclusion — so a model behind it reads masked code and writes the placeholder back. That is a
provider setting, and only the account owner can change it:
- Turn off
person-name,address,emailandphonefor the key the EU route uses: this policy masks all four on that route, precisely.emailmust go too — measured on #3043, a test fixture read back asSubmittedBy = [EMAIL]; the provider runs AFTER this policy and masks a reserved-domain fixture address this policy deliberately keeps, so leaving it on keeps corrupting code. The key is the account keyProvider/OpenRouteralso uses (the route borrows it, above), so the GLOBAL route loses those provider-side presets with it unless the EU route gets its own function key or the guardrail is scoped to a key the global route does not use. credit-card(Luhn) andsecrets(known key formats) can stay on; theirblockaction answers 403 for the whole request, which is a separate decision.ip-addressmasks addresses in configuration and logs, which are rarely personal — the owner's call.
What the scrubber does NOT do: recognise a name that nothing in the request declares (a bare "Erika said" with no cue anywhere in the request). The provider's beta recogniser does — at the price of masking identifiers. That trade is the account owner's to make with the settings above.
Recycling after a deploy
The catalog is served from Provider; after the AI module reaches an instance, recycle
Provider/OpenRouterEU (and Provider/OpenRouter when its models changed) so a live activation
re-reads.