CrispStrobe
feat(providers): add OVHcloud, STACKIT, Nscale; fix Mistral & Nebius fetchers
49e63bd
Raw
History Blame Contribute Delete
4.53 kB
'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);
});
}