File size: 5,046 Bytes
ffba252
 
 
 
 
49e63bd
 
 
 
 
 
 
 
 
 
 
 
ffba252
 
49e63bd
d135f12
ffba252
49e63bd
ffba252
 
 
 
 
 
49e63bd
ec9cfc9
 
 
49e63bd
ec9cfc9
 
49e63bd
ffba252
49e63bd
 
ffba252
 
 
49e63bd
ffba252
 
d135f12
ffba252
 
 
 
 
 
 
49e63bd
 
ffba252
 
49e63bd
ffba252
49e63bd
 
 
 
ffba252
49e63bd
 
 
 
ffba252
49e63bd
 
 
 
 
ffba252
49e63bd
 
 
 
 
 
 
 
 
 
 
 
ffba252
49e63bd
6f9105d
 
49e63bd
ec9cfc9
49e63bd
6f9105d
 
49e63bd
 
 
 
 
 
ec9cfc9
 
49e63bd
ffba252
 
 
 
 
49e63bd
 
 
 
ffba252
 
 
 
 
 
 
 
 
 
 
 
 
 
ec9cfc9
 
 
 
 
 
 
 
ffba252
 
 
 
 
 
 
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
'use strict';

/**
 * Mistral AI pricing fetcher.
 *
 * As of 2026 mistral.ai re-platformed onto Astro and moved API pricing to
 * /pricing/api/ (the old /pricing now shows Le Chat subscription plans, and the
 * previous Next.js RSC "apis" payload is gone). On the new page each model is a
 * card:
 *
 *   <p class="text-h5 font-mistral">Mistral Medium 3.5</p>
 *   ...
 *   <p>Input (/M tokens)</p>  <mistral-atom-text-price data-prices='{"priceUsd":1.5,...}'>
 *   <p>Output (/M tokens)</p> <mistral-atom-text-price data-prices='{"priceUsd":7.5,...}'>
 *
 * We walk the model-name headings and price atoms in document order, reading the
 * USD price out of each atom's data-prices JSON and the unit from its row label.
 */

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

const URL = 'https://mistral.ai/pricing/api/';

const getSizeB = (name) => {
  const match = (name || '').match(/[^.\d](\d+)[Bb]/) || (name || '').match(/^(\d+)[Bb]/);
  return match ? parseInt(match[1]) : undefined;
};

const getModelType = (name) => {
  const n = (name || '').toLowerCase();
  if (n.includes('voxtral')) return 'audio';
  if (n.includes('embed')) return 'embedding';
  return 'chat';
};

const priceUsd = ($, atom) => {
  try {
    const d = JSON.parse($(atom).attr('data-prices') || '{}');
    return typeof d.priceUsd === 'number' ? d.priceUsd : null;
  } catch {
    return null;
  }
};

async function fetchMistral() {
  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',
    },
  });

  if (html.includes('cf-browser-verification') || html.includes('Just a moment')) {
    throw new Error('Blocked by Cloudflare');
  }

  const $ = cheerio.load(html);

  // Ordered walk: model-name headings interleaved with price atoms. Each atom's
  // unit comes from the label <p> that shares its row (its parent's first <p>).
  const acc = new Map();
  let currentName = null;

  $('p.text-h5.font-mistral, mistral-atom-text-price').each((_, el) => {
    if (el.tagName === 'p') {
      currentName = $(el).text().trim();
      return;
    }
    if (!currentName) return;

    const label = $(el).parent().find('p').first().text().trim().toLowerCase();
    const val = priceUsd($, el);
    if (val === null) return;

    const m = acc.get(currentName) || { name: currentName };
    if (label.startsWith('input') && label.includes('m tokens')) m.input = val;
    else if (label.startsWith('output') && label.includes('m tokens')) m.output = val;
    else if (label.includes('min')) m.perMin = val;
    acc.set(currentName, m);
  });

  const models = [];
  for (const m of acc.values()) {
    // Only emit rows we could price as a model (token or per-minute). This drops
    // Agent-API feature rows (per-call / per-1K-images) and free entries.
    if (m.input == null && m.output == null && m.perMin == null) continue;

    const type = getModelType(m.name);
    const caps = [];
    if (type === 'audio') caps.push('audio');
    if (/magistral/i.test(m.name)) caps.push('reasoning');

    const model = { name: m.name, type, currency: 'USD' };
    if (caps.length) model.capabilities = caps;

    if (m.perMin != null) model.price_per_minute = m.perMin;
    if (m.input != null) model.input_price_per_1m = m.input;
    if (m.output != null) model.output_price_per_1m = m.output;
    // Chat/embedding models should always expose an output field for the UI.
    if (type !== 'audio' && model.input_price_per_1m != null && model.output_price_per_1m == null) {
      model.output_price_per_1m = 0;
    }

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

    models.push(model);
  }

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

  return models;
}

module.exports = { fetchMistral, providerName: 'Mistral AI' };

// Run standalone: node scripts/providers/mistral.js
if (require.main === module) {
  fetchMistral()
    .then((models) => {
      console.log(`Fetched ${models.length} models from Mistral AI:\n`);
      const byType = {};
      models.forEach((m) => { (byType[m.type] = byType[m.type] || []).push(m); });
      for (const [type, ms] of Object.entries(byType)) {
        console.log(`  [${type}]`);
        ms.forEach((m) => {
          let priceStr = '';
          if (m.price_per_minute !== undefined) priceStr += `$${m.price_per_minute}/min `;
          if (m.input_price_per_1m !== undefined || m.output_price_per_1m !== undefined) {
            priceStr += `$${m.input_price_per_1m ?? 0} / $${m.output_price_per_1m ?? 0}`;
          }
          console.log(`    ${m.name.padEnd(40)} ${priceStr.trim()}`);
        });
      }
    })
    .catch((err) => {
      console.error('Error:', err.message);
      process.exit(1);
    });
}