feat(providers): add EURouter as an OpenAI-compatible gateway (#12985) (#13025)

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.
This commit is contained in:
Nguyen Thanh Dat
2026-09-11 04:16:32 +07:00
committed by GitHub
parent 2b9e7fb3ec
commit 22473dee50
7 changed files with 122 additions and 0 deletions

View File

@@ -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.

View File

@@ -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<string, RegistryEntry> = {
"llm-kiwi": llmKiwiProvider,
literouter: literouterProvider,
greenpt: greenptProvider,
eurouter: eurouterProvider,
"mnn-ai": mnnAiProvider,
"meganova-ai": meganovaAiProvider,
mixlayer: mixlayerProvider,

View File

@@ -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,
});

View File

@@ -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",

View File

@@ -123,6 +123,7 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([
"llmgateway",
"llm-kiwi",
"literouter",
"eurouter",
"mnn-ai",
"meganova-ai",
"mixlayer",

View File

@@ -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"],

View File

@@ -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<string, unknown>;
for (const key of ["supportsTools", "supportsVision", "capabilities"]) {
assert.equal(metadata[key], undefined, `${key} must not be declared unverified`);
}
});