File size: 5,813 Bytes
49e63bd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
'use strict';

/**
 * STACKIT AI Model Serving — German sovereign cloud (Schwarz Group), Germany.
 * OpenAI-compatible, pay-per-token. Requires a STACKIT account/project to call.
 *
 * The model catalog is scraped from the public docs "available shared models"
 * page (static HTML): each model is an <h3> name followed, in document order,
 * by its Hugging Face model-card link and a detail table carrying the pricing
 * Category (LLM-Premium / LLM-Plus / LLM-Standard / Embedding-*), the model
 * Type, Modalities, Features and parameter count.
 *
 * Per-token prices are NOT on that page — they live in the official price list
 * and are keyed by Category, so we map Category -> price here. Update CATEGORY_
 * PRICING when STACKIT revises the price list.
 *
 * Sources:
 *   Catalog: https://docs.stackit.cloud/products/data-and-ai/ai-model-serving/basics/available-shared-models/
 *   Prices:  https://stackit.com/en/asset/download/37788/file/STACKIT_price_list.pdf  (verified 2026-07-03)
 */

const cheerio = require('cheerio');
const { getText } = require('../fetch-utils');

const DOCS_URL =
  'https://docs.stackit.cloud/products/data-and-ai/ai-model-serving/basics/available-shared-models/';

// EUR per 1M tokens, keyed by the docs "Category" field. Embeddings bill input only.
const CATEGORY_PRICING = {
  'llm-premium': { input: 1.5, output: 1.75 },
  'llm-plus': { input: 0.45, output: 0.65 },
  'llm-standard': { input: 0.15, output: 0.25 },
  'embedding-standard': { input: 0.02, output: 0 },
  'embedding-plus': { input: 0.08, output: 0 },
};

const parseParamsB = (text) => {
  // "70.6 Billion in 8 bit quantization" -> 70.6 ; "235 Billion ..." -> 235
  const m = (text || '').match(/([\d.]+)\s*Billion/i);
  if (!m) return undefined;
  const n = parseFloat(m[1]);
  return n > 0 && n < 2000 ? n : undefined;
};

// Read a labelled row's value from a detail table.
const rowValue = ($, table, labelRe) => {
  let val = null;
  $(table).find('tr').each((_, r) => {
    const cells = $(r).find('td, th').map((_, x) => $(x).text().trim()).get();
    if (cells[0] && labelRe.test(cells[0])) val = cells.slice(1).join(' ').trim();
  });
  return val;
};

const isModelCardLink = (href) =>
  /^https?:\/\/huggingface\.co\/[^/]+\/[^/]+$/.test(href || '');

function buildModel(name, hfId, table, $) {
  const category = (rowValue($, table, /^category$/i) || '').trim();
  const catKey = category.toLowerCase();
  const pricing = CATEGORY_PRICING[catKey];
  if (!pricing) {
    console.warn(`  ⚠ STACKIT: unknown pricing category "${category}" for ${name} — skipping`);
    return null;
  }

  const declaredType = (rowValue($, table, /^type$/i) || '').toLowerCase();
  const modalities = (rowValue($, table, /modalities/i) || '').toLowerCase();
  const features = (rowValue($, table, /features/i) || '').toLowerCase();
  const paramsText = rowValue($, table, /parameters/i);

  const hasImageIn = /image/.test(modalities);
  let type;
  if (declaredType.includes('embedding')) type = 'embedding';
  else if (hasImageIn) type = 'vision';
  else type = 'chat';

  const capabilities = [];
  if (hasImageIn) capabilities.push('vision');
  if (/tool calling/.test(features)) capabilities.push('tools');
  if (/reasoning/.test(features)) capabilities.push('reasoning');

  const model = {
    name,
    type,
    currency: 'EUR',
    input_price_per_1m: pricing.input,
    output_price_per_1m: pricing.output,
  };
  if (capabilities.length) model.capabilities = capabilities;
  if (hfId) model.hf_id = hfId;

  const size_b = parseParamsB(paramsText);
  if (size_b) model.size_b = size_b;

  return model;
}

async function fetchStackit() {
  const html = await getText(DOCS_URL, {
    headers: {
      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
      'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
      'Accept-Language': 'en-US,en;q=0.9',
    },
  });

  const $ = cheerio.load(html);
  const models = [];

  // Walk headings, model-card links and tables in document order. Each model is
  // an <h3> name, then its HF model-card link, then a detail table (with a
  // Category row). A model is emitted when its category table is reached.
  let currentName = null;
  let currentHfId = null;
  $('h3, a[href*="huggingface.co"], table').each((_, el) => {
    const tag = el.tagName ? el.tagName.toLowerCase() : '';
    if (tag === 'h3') {
      currentName = $(el).text().trim();
      currentHfId = null;
      return;
    }
    if (tag === 'a') {
      const href = $(el).attr('href');
      if (!currentHfId && isModelCardLink(href)) {
        currentHfId = href.replace(/^https?:\/\/huggingface\.co\//, '').replace(/\/+$/, '');
      }
      return;
    }
    // table
    if (!currentName) return;
    if (!rowValue($, el, /^category$/i)) return; // not a model detail table
    const model = buildModel(currentName, currentHfId, el, $);
    if (model) models.push(model);
    currentName = null; // consumed
    currentHfId = null;
  });

  if (models.length === 0) {
    throw new Error('No models parsed from STACKIT docs (page structure changed?)');
  }

  return models;
}

module.exports = { fetchStackit, providerName: 'STACKIT' };

// Run standalone: node scripts/providers/stackit.js
if (require.main === module) {
  fetchStackit()
    .then((models) => {
      console.log(`Fetched ${models.length} models from STACKIT:\n`);
      models.forEach((m) =>
        console.log(
          `  ${m.name.padEnd(34)} ${m.type.padEnd(10)}${m.input_price_per_1m} / €${m.output_price_per_1m}` +
            (m.hf_id ? `   ${m.hf_id}` : '')
        )
      );
    })
    .catch((err) => {
      console.error('Error:', err.message);
      process.exit(1);
    });
}