From 22473dee50357708b107a89ebb6239970081a8ba Mon Sep 17 00:00:00 2001 From: Nguyen Thanh Dat Date: Fri, 11 Sep 2026 04:16:32 +0700 Subject: [PATCH] feat(providers): add EURouter as an OpenAI-compatible gateway (#12985) (#13025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto the release tip after #13024 landed: both PRs extend the same three registration files, so the sibling merge turned this into a conflict. The resolution is additive — both catalog entries kept, both registry imports kept, both base URLs kept — and EURouter stays in AGGREGATOR_PROVIDER_IDS while GreenPT stays out, exactly as each PR argued. 14 provider tests pass on the rebased branch and the file-size gate is green under the annotated rebaseline. Thank you for re-checking the endpoint live instead of trusting the report, and for the sovereignty caveat. Naming the upstreams from EURouter's own catalog — Claude Sonnet served by AWS Bedrock, 19 models owned by openai — and then writing an apiHint that says routing rather than residency is the kind of care that keeps a provider entry honest. The test asserting the copy contains none of "residency", "stays in the EU", "EU-hosted" or "sovereign" is a good guard against that drifting later. --- .../features/12985-eurouter-provider.md | 1 + open-sse/config/providers/index.ts | 2 + .../providers/registry/eurouter/index.ts | 11 +++ src/shared/constants/config.ts | 1 + src/shared/constants/providers.ts | 1 + .../constants/providers/apikey/gateways.ts | 21 +++++ tests/unit/eurouter-provider.test.ts | 85 +++++++++++++++++++ 7 files changed, 122 insertions(+) create mode 100644 changelog.d/features/12985-eurouter-provider.md create mode 100644 open-sse/config/providers/registry/eurouter/index.ts create mode 100644 tests/unit/eurouter-provider.test.ts diff --git a/changelog.d/features/12985-eurouter-provider.md b/changelog.d/features/12985-eurouter-provider.md new file mode 100644 index 0000000000..9fe6ddb2ef --- /dev/null +++ b/changelog.d/features/12985-eurouter-provider.md @@ -0,0 +1 @@ +- **feat(providers):** Added EURouter as an OpenAI-compatible API-key gateway (`https://api.eurouter.ai/v1`), with live model discovery via `passthroughModels`. Its copy states that models are served by third-party upstreams listed per model, so an EU-based router is not read as EU data residency for inference. diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 5cfaba2c4d..8c670411b1 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -250,6 +250,7 @@ import { llmgatewayProvider } from "./registry/llmgateway/index.ts"; import { llmKiwiProvider } from "./registry/llm-kiwi/index.ts"; import { literouterProvider } from "./registry/literouter/index.ts"; import { greenptProvider } from "./registry/greenpt/index.ts"; +import { eurouterProvider } from "./registry/eurouter/index.ts"; import { mnnAiProvider } from "./registry/mnn-ai/index.ts"; import { meganovaAiProvider } from "./registry/meganova-ai/index.ts"; import { mixlayerProvider } from "./registry/mixlayer/index.ts"; @@ -526,6 +527,7 @@ export const REGISTRY: Record = { "llm-kiwi": llmKiwiProvider, literouter: literouterProvider, greenpt: greenptProvider, + eurouter: eurouterProvider, "mnn-ai": mnnAiProvider, "meganova-ai": meganovaAiProvider, mixlayer: mixlayerProvider, diff --git a/open-sse/config/providers/registry/eurouter/index.ts b/open-sse/config/providers/registry/eurouter/index.ts new file mode 100644 index 0000000000..045c921fee --- /dev/null +++ b/open-sse/config/providers/registry/eurouter/index.ts @@ -0,0 +1,11 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +export const eurouterProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "eurouter", + alias: "eurouter", + baseUrl: "https://api.eurouter.ai/v1/chat/completions", + modelsUrl: "https://api.eurouter.ai/v1/models", + models: [], + passthroughModels: true, +}); diff --git a/src/shared/constants/config.ts b/src/shared/constants/config.ts index ccba7157d1..3bb4c91b02 100644 --- a/src/shared/constants/config.ts +++ b/src/shared/constants/config.ts @@ -18,6 +18,7 @@ export const PROVIDER_ENDPOINTS = { "llm-kiwi": "https://api.llm.kiwi/v1/chat/completions", literouter: "https://api.literouter.com/v1/chat/completions", greenpt: "https://api.greenpt.ai/v1/chat/completions", + eurouter: "https://api.eurouter.ai/v1/chat/completions", "mnn-ai": "https://api.mnnai.ru/v1/chat/completions", "meganova-ai": "https://api.meganova.ai/v1/chat/completions", mixlayer: "https://models.mixlayer.ai/v1/chat/completions", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index ef8d8ac94e..390675ec10 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -123,6 +123,7 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([ "llmgateway", "llm-kiwi", "literouter", + "eurouter", "mnn-ai", "meganova-ai", "mixlayer", diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 327a24d813..c35390a37d 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -285,6 +285,27 @@ export const APIKEY_PROVIDERS_GATEWAYS = { apiHint: "Create a GreenPT API key, then use https://api.greenpt.ai/v1 as the OpenAI-compatible base URL. Review jurisdiction, privacy and regional data-transfer requirements before use.", }, + eurouter: { + id: "eurouter", + serviceKinds: ["llm"], + alias: "eurouter", + name: "EURouter", + icon: "router", + color: "#1D4ED8", + textIcon: "EUR", + passthroughModels: true, + website: "https://eurouter.ai", + // No free allowance is published, so no badge. A key was accepted but the + // account had no credits, so nothing about pricing tiers is claimed here. + hasFree: false, + // Deliberately says routing, not residency. EURouter is a router: its own + // catalog names the upstream that serves each model (claude-sonnet-5 -> + // AWS Bedrock, and 19 models owned by openai, 9 by anthropic, 7 by amazon). + // An EU-based router is a routing layer in the EU; where a model actually + // executes, and under whose terms, is a per-upstream property (#12985). + apiHint: + "Create an EURouter API key, then use https://api.eurouter.ai/v1 as the OpenAI-compatible base URL. Models are served by third-party upstreams listed per model in the EURouter catalog; check each upstream jurisdiction, privacy and data-transfer terms before use.", + }, "mnn-ai": { id: "mnn-ai", serviceKinds: ["llm"], diff --git a/tests/unit/eurouter-provider.test.ts b/tests/unit/eurouter-provider.test.ts new file mode 100644 index 0000000000..bb010fbe11 --- /dev/null +++ b/tests/unit/eurouter-provider.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { eurouterProvider } from "../../open-sse/config/providers/registry/eurouter/index.ts"; + +const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); +const { DefaultExecutor, getExecutor } = await import("../../open-sse/executors/index.ts"); +const { PROVIDER_ENDPOINTS } = await import("../../src/shared/constants/config.ts"); +const { isValidModel } = await import("../../src/shared/constants/models.ts"); +const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers/apikey/index.ts"); +const { AGGREGATOR_PROVIDER_IDS } = await import("../../src/shared/constants/providers.ts"); + +const CHAT_URL = "https://api.eurouter.ai/v1/chat/completions"; +const MODELS_URL = "https://api.eurouter.ai/v1/models"; + +test("eurouter is an OpenAI-compatible Bearer registry entry", () => { + assert.equal(eurouterProvider.id, "eurouter"); + assert.equal(eurouterProvider.alias, "eurouter"); + assert.equal(eurouterProvider.format, "openai"); + assert.equal(eurouterProvider.executor, "default"); + assert.equal(eurouterProvider.authType, "apikey"); + assert.equal(eurouterProvider.authHeader, "bearer"); + assert.equal(eurouterProvider.baseUrl, CHAT_URL); + assert.equal(eurouterProvider.modelsUrl, MODELS_URL); + assert.equal(eurouterProvider.passthroughModels, true); +}); + +test("eurouter leaves its 147-model catalog to live discovery", () => { + assert.deepEqual(eurouterProvider.models, []); +}); + +test("eurouter is wired through registry, metadata, endpoint and default executor", async () => { + assert.equal(REGISTRY.eurouter?.baseUrl, CHAT_URL); + assert.equal(PROVIDER_ENDPOINTS.eurouter, CHAT_URL); + assert.equal(APIKEY_PROVIDERS.eurouter?.id, "eurouter"); + assert.equal(APIKEY_PROVIDERS.eurouter?.alias, "eurouter"); + assert.ok((await getExecutor("eurouter")) instanceof DefaultExecutor); + assert.equal(isValidModel("eurouter", "future/live-catalog-model"), true); +}); + +test("eurouter is listed as an aggregator", () => { + // It routes to third-party upstreams rather than serving its own inference, + // which is what that set means -- the opposite call from GreenPT (#12986). + assert.equal(AGGREGATOR_PROVIDER_IDS.has("eurouter"), true); +}); + +test("eurouter advertises no free allowance", () => { + // A key was accepted (HTTP 402 Insufficient balance) but the account had no + // credits, so no pricing tier was observed and none is claimed. + assert.equal(APIKEY_PROVIDERS.eurouter?.hasFree, false); + assert.equal(APIKEY_PROVIDERS.eurouter?.freeNote, undefined); +}); + +test("eurouter copy does not imply EU residency for inference", () => { + // The name invites that reading and the catalog contradicts it: models are + // served by upstreams such as AWS Bedrock. Being EU-based is a property of + // the routing layer, not of where a model executes (#12985). + const hint = String(APIKEY_PROVIDERS.eurouter?.apiHint ?? ""); + assert.ok(hint.length > 0, "an apiHint is required to carry the caveat"); + for (const claim of [ + "data residency", + "residency", + "stays in the EU", + "EU-hosted", + "sovereign", + ]) { + assert.ok( + !hint.toLowerCase().includes(claim.toLowerCase()), + `apiHint must not claim "${claim}"` + ); + } + assert.ok( + hint.toLowerCase().includes("third-party upstream"), + "apiHint must say the models are served by third-party upstreams" + ); +}); + +test("eurouter claims no capability that was not exercised", () => { + // Streaming SSE conformance was not exercised -- the usual place these + // gateways diverge, and a passthrough entry breaks there silently. + const metadata = APIKEY_PROVIDERS.eurouter as Record; + for (const key of ["supportsTools", "supportsVision", "capabilities"]) { + assert.equal(metadata[key], undefined, `${key} must not be declared unverified`); + } +});