File size: 4,529 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
'use strict';

/**
 * Nscale Serverless Inference β€” pay-per-token, OpenAI-compatible.
 * HQ London (UK); serverless inference runs on Nscale's sustainable European
 * (Norway/EEA) data centres. Not EU-headquartered, so jurisdiction is UK GDPR
 * + EEA hosting rather than full EU sovereignty β€” see the provider `region`.
 *
 * The catalog + pricing come from the authenticated OpenAI-compatible
 * `GET /v1/models` endpoint (docs page is access-gated, so we use the API).
 * Schema: { data: [{ id, owned_by, pricing: { input, output }, context_length }] }
 * where pricing.input/output are USD per 1M tokens (per 1M pixels for image
 * output). The endpoint carries no type/capability fields, so those are
 * derived from the model id (as the Requesty/Scaleway fetchers do).
 *
 * Requires NSCALE_API_KEY (local ../AIToolkit/.env or a CI secret). Without it
 * the fetcher skips and the provider keeps its existing data.
 */

const { loadEnv } = require('../load-env');
loadEnv();
const { getJson } = require('../fetch-utils');

const API_URL = 'https://inference.api.nscale.com/v1/models';

const loadApiKey = () => process.env.NSCALE_API_KEY || null;

const EMBED_KEYWORDS = ['embed', 'bge', 'gte', 'e5-', 'stella', 'arctic-embed', 'nomic-embed'];
const IMAGE_KEYWORDS = ['flux', 'stable-diffusion', 'sdxl', 'sd3', 'text-to-image'];
const VISION_KEYWORDS = ['-vl', 'vl-', 'vision', 'pixtral', 'llava', 'llama-4'];
const REASON_KEYWORDS = ['deepseek-r1', 'qwq', 'thinking', 'magistral', 'gpt-oss', 'reason'];

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

// USD per 1M β€” the endpoint already reports per-million, keep as-is.
const perMillion = (v) => (v == null ? 0 : Math.round(parseFloat(v) * 10000) / 10000);

function classify(id) {
  const s = (id || '').toLowerCase();
  const isImage = IMAGE_KEYWORDS.some((k) => s.includes(k));
  if (isImage) return { type: 'image', caps: [] };
  if (EMBED_KEYWORDS.some((k) => s.includes(k))) return { type: 'embedding', caps: [] };

  const caps = [];
  const isVision = VISION_KEYWORDS.some((k) => s.includes(k));
  if (isVision) caps.push('vision');
  if (REASON_KEYWORDS.some((k) => s.includes(k))) caps.push('reasoning');
  return { type: isVision ? 'vision' : 'chat', caps };
}

async function fetchNscale() {
  const apiKey = loadApiKey();
  if (!apiKey) {
    console.warn('  (no NSCALE_API_KEY found – skipping Nscale)');
    return [];
  }

  const data = await getJson(API_URL, {
    headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
  });

  const models = [];
  let skippedImage = 0;

  for (const m of data.data || []) {
    const id = m.id;
    if (!id) continue;

    const input = perMillion(m.pricing?.input);
    const output = perMillion(m.pricing?.output);
    const { type, caps } = classify(id);

    // Image pricing is per-megapixel (not per-image / per-token), a different
    // unit than our schema expresses β€” skip rather than publish misleading data.
    if (type === 'image') { skippedImage++; continue; }

    const entry = {
      name: id,
      type,
      input_price_per_1m: input,
      output_price_per_1m: output,
      currency: 'USD',
    };
    if (caps.length) entry.capabilities = caps;
    if (m.context_length) entry.context_window = m.context_length;

    // Nscale ids are Hugging Face style (owner/model) β€” use as hf_id for enrichment.
    if (/^[^/\s]+\/[^/\s]+$/.test(id)) entry.hf_id = id;

    const size_b = getSizeB(id);
    if (size_b) entry.size_b = size_b;

    models.push(entry);
  }

  if (skippedImage) console.warn(`  (skipped ${skippedImage} image model(s) β€” per-megapixel pricing not representable)`);

  models.sort((a, b) => a.input_price_per_1m - b.input_price_per_1m);
  return models;
}

module.exports = { fetchNscale, providerName: 'Nscale' };

// Run standalone: node scripts/providers/nscale.js
if (require.main === module) {
  fetchNscale()
    .then((models) => {
      console.log(`Fetched ${models.length} models from Nscale API\n`);
      models.slice(0, 20).forEach((m) =>
        console.log(`  ${m.name.padEnd(50)} ${m.type.padEnd(10)} $${m.input_price_per_1m} / $${m.output_price_per_1m}`)
      );
      if (models.length > 20) console.log(`  ... and ${models.length - 20} more`);
    })
    .catch((err) => {
      console.error('Error:', err.message);
      process.exit(1);
    });
}