File size: 6,659 Bytes
795835b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
'use strict';

/**
 * 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);
    });
}