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.

  1. It is seeded only where it is configured — OpenRouterEU:Models or OpenRouterEU:Endpoint set. 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.
  2. 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.3 exists as Provider/OpenRouter/z-ai/glm-5.3 AND Provider/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 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.

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:

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.

A provider without its models says so.

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:

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.