File size: 5,993 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
166
167
168
'use strict';

/**
 * OVHcloud AI Endpoints — EU-sovereign (France), pay-per-token, OpenAI-compatible.
 *
 * The public catalog page is a Next.js app that embeds the full model list
 * (categories, capabilities, HF source links and per-unit pricing) in its
 * streamed `self.__next_f.push([...])` payload. We reconstruct that payload,
 * pull out the `"models":[ ... ]` array and map it to our schema. This is far
 * more robust than scraping the rendered price DOM.
 */

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

const URL = 'https://www.ovhcloud.com/en/public-cloud/ai-endpoints/catalog/';

// Reconstruct the decoded Next.js flight payload from all push() chunks.
function decodeNextPayload(html) {
  const $ = cheerio.load(html);
  let decoded = '';
  const re = /self\.__next_f\.push\(\[\d+,("(?:[^"\\]|\\.)*")\]\)/g;
  $('script').each((_, el) => {
    const c = $(el).html() || '';
    if (!c.includes('__next_f.push')) return;
    let m;
    while ((m = re.exec(c))) {
      // The second arg is a JSON string literal — JSON.parse decodes the escapes.
      try { decoded += JSON.parse(m[1]); } catch (e) { /* skip malformed chunk */ }
    }
  });
  return decoded;
}

// Bracket-match the JSON array that follows the first `"models":[` occurrence.
function extractModelsArray(decoded) {
  const key = '"models":[';
  const at = decoded.indexOf(key);
  if (at === -1) return null;
  const start = at + key.length - 1; // position of the opening '['
  let depth = 0, inStr = false, esc = false;
  for (let j = start; j < decoded.length; j++) {
    const ch = decoded[j];
    if (esc) { esc = false; continue; }
    if (ch === '\\') { esc = true; continue; }
    if (ch === '"') { inStr = !inStr; continue; }
    if (inStr) continue;
    if (ch === '[') depth++;
    else if (ch === ']') { depth--; if (depth === 0) { return decoded.slice(start, j + 1); } }
  }
  return null;
}

const getType = (category, caps) => {
  const c = (category || '').toLowerCase();
  if (c.includes('embedding')) return 'embedding';
  if (c.includes('speech') || c.includes('audio')) return 'audio';
  if (c.includes('image generation')) return 'image';
  if ((caps.input_modality || []).includes('image')) return 'vision';
  return 'chat';
};

const getSizeB = (name) => {
  const match = (name || '').match(/(?:\b|-)([\d.]+)[Bb](?:\b|-|:|$)/);
  if (!match) return undefined;
  const num = parseFloat(match[1]);
  return (num > 0 && num < 2000) ? num : undefined;
};

const hfIdFromModel = (m) => {
  const link = m.metadata?.publishing_information?.source_link || '';
  if (link.includes('huggingface.co/')) {
    return link.split('huggingface.co/')[1].replace(/\/+$/, '') || undefined;
  }
  // Fall back to an alias that looks like an HF id (publisher/model).
  const alias = (m.metadata?.aliases || []).find((a) => a.includes('/'));
  return alias || undefined;
};

function mapModel(m) {
  const caps = m.metadata?.model_specs?.capabilities || {};
  const type = getType(m.category, caps);
  const pricing = m.metadata?.usage_information?.pricing || [];

  const model = { name: m.name || m.id, type, currency: 'EUR' };

  // Capabilities
  const capList = [];
  if ((caps.input_modality || []).includes('image')) capList.push('vision');
  if (caps.function_calling) capList.push('tools');
  if (caps.reasoning) capList.push('reasoning');
  if (type === 'image') capList.push('image-gen');
  if (type === 'audio') capList.push('audio');
  if (capList.length) model.capabilities = capList;

  // Pricing — map OVH's per-unit entries onto our fields.
  const priceBy = {};
  pricing.forEach((p) => { priceBy[p.price_unit] = p.price; });

  if (priceBy.per_image !== undefined) {
    model.price_per_image = priceBy.per_image;
  } else if (priceBy.audio_duration_seconds !== undefined) {
    // Store per-minute to match the app's audio convention.
    model.price_per_minute = Math.round(priceBy.audio_duration_seconds * 60 * 1e6) / 1e6;
  } else {
    // Token/char based (chat, vision, embedding, code, moderation, TTS).
    const input = priceBy.million_input_tokens ?? priceBy.million_input_chars ?? 0;
    const output = priceBy.million_output_tokens ?? 0;
    model.input_price_per_1m = input;
    model.output_price_per_1m = output;
  }

  const hf_id = hfIdFromModel(m);
  if (hf_id) model.hf_id = hf_id;

  const size_b = getSizeB(m.name || m.id);
  if (size_b) model.size_b = size_b;

  return model;
}

async function fetchOvhcloud() {
  const html = await getText(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 decoded = decodeNextPayload(html);
  const raw = extractModelsArray(decoded);
  if (!raw) throw new Error('Could not locate embedded models payload (page structure changed?)');

  let models;
  try {
    models = JSON.parse(raw);
  } catch (e) {
    throw new Error(`Failed to parse embedded models JSON: ${e.message}`);
  }

  return models
    .filter((m) => m && m.name && m.available !== false)
    .map(mapModel);
}

module.exports = { fetchOvhcloud, providerName: 'OVHcloud' };

// Run standalone: node scripts/providers/ovhcloud.js
if (require.main === module) {
  fetchOvhcloud()
    .then((models) => {
      console.log(`Fetched ${models.length} models from OVHcloud:\n`);
      models.forEach((m) => {
        const price = m.price_per_image !== undefined
          ? `€${m.price_per_image}/img`
          : m.price_per_minute !== undefined
            ? `€${m.price_per_minute}/min`
            : `€${m.input_price_per_1m} / €${m.output_price_per_1m}`;
        console.log(`  ${m.name.padEnd(42)} ${m.type.padEnd(10)} ${price}`);
      });
    })
    .catch((err) => {
      console.error('Error:', err.message);
      process.exit(1);
    });
}