Spaces:
Paused
Paused
crispasr integration
Claude Opus 5 (1M context)
feat: add EUrouter and HostYourAI as EU-sovereign providers
795835b Download scripts/providers/eurouter.js from cstr/LLMProviders: direct link, hf CLI and curl.
- Browser
- Download file 6.66 kB
-
https://huggingface.co/spaces/cstr/LLMProviders/resolve/main/scripts/providers/eurouter.js
- Command line
-
hf download hf://spaces/cstr/LLMProviders/scripts/providers/eurouter.js
-
curl -L -o eurouter.js https://huggingface.co/spaces/cstr/LLMProviders/resolve/main/scripts/providers/eurouter.js
6.66 kB
| ; | |
| /** | |
| * EUrouter — EU-sovereign model router, OpenAI-compatible. | |
| * EUrouter B.V. (KVK 42054357), Jacob van Lennepstraat 78H, Amsterdam, NL. | |
| * Own infrastructure runs on Scaleway (Amsterdam/Paris); the published | |
| * sub-processor list is EU-only (Scaleway, Mollie, PostHog Cloud EU, Brevo, | |
| * CaptchaFox, Better Stack EU). | |
| * | |
| * Catalog + pricing come from the PUBLIC OpenAI-compatible models endpoint — | |
| * no API key needed, so this fetcher runs unattended in CI. | |
| * Schema is OpenRouter-shaped: | |
| * { data: [{ id, name, canonical_slug, hugging_face_id, context_length, | |
| * architecture: { modality, input_modalities, output_modalities }, | |
| * supported_parameters: [...], supported_api_endpoints: [...], | |
| * reasoning: { mandatory, supported_efforts }, | |
| * providers: [{ name, slug }], | |
| * pricing: { prompt, completion, ..., discount, currency } }] } | |
| * | |
| * Pricing is per SINGLE token as a decimal string (e.g. "0.0000005"), and the | |
| * currency is per-model (EUR for EU-hosted upstreams, USD for the rest) — so | |
| * we multiply by 1e6 and carry `currency` through rather than converting. | |
| * | |
| * Sovereignty caveat: EUrouter is an EU entity, but a minority of its routes | |
| * sit on US hyperscalers (AWS Bedrock, Microsoft Foundry) and are therefore | |
| * Cloud Act-exposed. Those models are NOT tagged `eu-endpoint`; the upstream | |
| * is recorded in `provider_upstream` either way so the exposure is visible. | |
| * | |
| * Source: https://api.eurouter.ai/v1/models (verified 2026-09-11, 147 models) | |
| */ | |
| const { getJson } = require('../fetch-utils'); | |
| const API_URL = 'https://api.eurouter.ai/v1/models'; | |
| // Upstreams that are not EU-established — routing to them forfeits the | |
| // sovereignty claim even though EUrouter itself is a Dutch controller. | |
| const NON_EU_UPSTREAMS = new Set(['aws bedrock', 'microsoft foundry']); | |
| // Per-token decimal string -> per-1M. Prices go down to 1e-8/token, so keep | |
| // six decimals rather than the four other fetchers use. | |
| const perMillion = (v) => { | |
| const n = parseFloat(v); | |
| if (!Number.isFinite(n)) return 0; | |
| return Math.round(n * 1e6 * 1e6) / 1e6; | |
| }; | |
| const getSizeB = (s) => { | |
| const match = (s || '').match(/(?:\b|-)(\d+(?:\.\d+)?)\s*[Bb](?:\b|-|:|$)/); | |
| if (!match) return undefined; | |
| const n = parseFloat(match[1]); | |
| return n > 0 && n < 2000 ? n : undefined; | |
| }; | |
| function classify(m) { | |
| const arch = m.architecture || {}; | |
| const inputs = arch.input_modalities || []; | |
| const outputs = arch.output_modalities || []; | |
| const endpoints = m.supported_api_endpoints || []; | |
| const hasImageIn = inputs.includes('image'); | |
| const hasVideoIn = inputs.includes('video'); | |
| const hasAudioIn = inputs.includes('audio'); | |
| const caps = []; | |
| if (hasImageIn) caps.push('vision'); | |
| if (hasVideoIn) caps.push('video'); | |
| if (hasAudioIn) caps.push('audio'); | |
| if ((m.supported_parameters || []).includes('tools')) caps.push('tools'); | |
| const r = m.reasoning || {}; | |
| if (r.mandatory || (r.supported_efforts || []).length > 0) caps.push('reasoning'); | |
| // Rerankers score inputs rather than generate — group them with embeddings, | |
| // which is the closest type this schema expresses. | |
| if (outputs.includes('embedding') || endpoints.includes('embeddings') || endpoints.includes('rerank')) { | |
| return { type: 'embedding', caps: caps.filter((c) => c === 'vision' || c === 'video') }; | |
| } | |
| if (endpoints.includes('audio.transcriptions')) return { type: 'audio', caps }; | |
| if (outputs.includes('image')) return { type: 'image', caps }; | |
| return { type: hasImageIn || hasVideoIn ? 'vision' : 'chat', caps }; | |
| } | |
| async function fetchEurouter() { | |
| const data = await getJson(API_URL, { headers: { Accept: 'application/json' } }); | |
| const list = Array.isArray(data.data) ? data.data : []; | |
| if (list.length === 0) { | |
| throw new Error('EUrouter returned an empty model list (API shape changed?)'); | |
| } | |
| const models = []; | |
| let skippedUnpriced = 0; | |
| let discountSeen = false; | |
| for (const m of list) { | |
| const id = m.id; | |
| if (!id) continue; | |
| // A handful of catalog entries (some embeddings/rerankers/ASR) carry no | |
| // pricing block at all — nothing to compare, so leave them out rather | |
| // than publish them as free. | |
| if (!m.pricing) { skippedUnpriced++; continue; } | |
| // `discount` has been a constant 1 (= no discount) since launch. If that | |
| // ever changes the prices below silently stop matching the invoice, so | |
| // surface it instead of guessing at the semantics. | |
| if (m.pricing.discount != null && Number(m.pricing.discount) !== 1) discountSeen = true; | |
| const { type, caps } = classify(m); | |
| const upstreams = (m.providers || []).map((p) => p.name).filter(Boolean); | |
| const allEu = upstreams.length > 0 && !upstreams.some((u) => NON_EU_UPSTREAMS.has(u.toLowerCase())); | |
| if (allEu) caps.push('eu-endpoint'); | |
| const entry = { | |
| name: id, | |
| type, | |
| currency: (m.pricing.currency || 'USD').toUpperCase(), | |
| input_price_per_1m: perMillion(m.pricing.prompt), | |
| output_price_per_1m: perMillion(m.pricing.completion), | |
| }; | |
| if (m.name && m.name !== id) entry.display_name = m.name; | |
| if (caps.length) entry.capabilities = caps; | |
| if (m.context_length) entry.context_window = m.context_length; | |
| if (m.hugging_face_id) entry.hf_id = m.hugging_face_id; | |
| if (upstreams.length) entry.provider_upstream = upstreams.join(', '); | |
| const size_b = getSizeB(m.hugging_face_id || m.name || id); | |
| if (size_b) entry.size_b = size_b; | |
| models.push(entry); | |
| } | |
| if (skippedUnpriced) { | |
| console.warn(` (skipped ${skippedUnpriced} EUrouter model(s) with no pricing block)`); | |
| } | |
| if (discountSeen) { | |
| console.warn(' ⚠ EUrouter: pricing.discount is no longer 1 — listed prices may not match billing'); | |
| } | |
| models.sort((a, b) => a.input_price_per_1m - b.input_price_per_1m); | |
| return models; | |
| } | |
| module.exports = { fetchEurouter, providerName: 'EUrouter' }; | |
| // Run standalone: node scripts/providers/eurouter.js | |
| if (require.main === module) { | |
| fetchEurouter() | |
| .then((models) => { | |
| console.log(`Fetched ${models.length} models from EUrouter\n`); | |
| models.slice(0, 25).forEach((m) => | |
| console.log( | |
| ` ${m.name.padEnd(42)} ${m.type.padEnd(10)} ${m.currency} ${String(m.input_price_per_1m).padStart(8)} / ${String(m.output_price_per_1m).padEnd(8)}` + | |
| (m.provider_upstream ? ` via ${m.provider_upstream}` : '') | |
| ) | |
| ); | |
| if (models.length > 25) console.log(` ... and ${models.length - 25} more`); | |
| }) | |
| .catch((err) => { | |
| console.error('Error:', err.message); | |
| process.exit(1); | |
| }); | |
| } | |