← back to Model Wars
providers.js
246 lines
// providers.js — real API adapters for each champion.
// Every adapter streams so we can measure true time-to-first-token (TTFT).
// No adapter ever fabricates output: if a key is missing or a call fails,
// the caller learns the truth and the UI blocks live battle for that champion.
const env = process.env;
// Pricing in USD per 1,000,000 tokens: [input, output]. Best-effort public
// list prices; used only for the "estimated API cost" display.
const PRICING = {
'gpt-4o-mini': [0.15, 0.60],
'gpt-4o': [2.50, 10.00],
'gpt-4.1-mini': [0.40, 1.60],
'claude-3-5-sonnet-latest': [3.00, 15.00],
'claude-3-5-haiku-latest': [0.80, 4.00],
'gemini-1.5-flash': [0.075, 0.30],
'gemini-2.0-flash': [0.10, 0.40],
'grok-2-latest': [2.00, 10.00],
'grok-beta': [5.00, 15.00],
'deepseek-chat': [0.27, 1.10],
'llama-3.3-70b-versatile': [0.59, 0.79],
};
function priceFor(model) {
return PRICING[model] || [0.5, 1.5]; // conservative fallback
}
export function costOf(model, inTok, outTok) {
const [pin, pout] = priceFor(model);
return (inTok / 1e6) * pin + (outTok / 1e6) * pout;
}
// The canonical champion roster. `key`/`model` resolved from env at runtime.
export const CHAMPIONS = [
{ id: 'gpt', name: 'GPT', title: 'The Emerald Knight', provider: 'openai', color: '#2ecc71', crest: '⚔️', img: '/assets/champ-gpt.png' },
{ id: 'claude', name: 'Claude', title: 'The Golden Scholar', provider: 'anthropic',color: '#e8b64c', crest: '📜', img: '/assets/champ-claude.png' },
{ id: 'gemini', name: 'Gemini', title: 'The Celestial Mage', provider: 'google', color: '#6aa8ff', crest: '✦', img: '/assets/champ-gemini.png' },
{ id: 'grok', name: 'Grok', title: 'The Black Knight', provider: 'xai', color: '#9aa0a6', crest: '🗡️', img: '/assets/champ-grok.png' },
{ id: 'deepseek', name: 'DeepSeek', title: 'The Eastern Strategist', provider: 'deepseek', color: '#7c5cff', crest: '☯', img: '/assets/champ-deepseek.png' },
{ id: 'llama', name: 'Llama', title: 'The Crimson Ranger', provider: 'groq', color: '#e05a5a', crest: '🏹', img: '/assets/champ-llama.png' },
];
const PROVIDER_ENV = {
openai: { key: 'OPENAI_API_KEY', model: 'OPENAI_MODEL', default: 'gpt-4o-mini' },
anthropic: { key: 'ANTHROPIC_API_KEY', model: 'ANTHROPIC_MODEL', default: 'claude-3-5-sonnet-latest' },
google: { key: 'GEMINI_API_KEY', model: 'GEMINI_MODEL', default: 'gemini-1.5-flash' },
xai: { key: 'XAI_API_KEY', model: 'XAI_MODEL', default: 'grok-2-latest' },
deepseek: { key: 'DEEPSEEK_API_KEY', model: 'DEEPSEEK_MODEL', default: 'deepseek-chat' },
groq: { key: 'GROQ_API_KEY', model: 'GROQ_MODEL', default: 'llama-3.3-70b-versatile' },
};
export function providerConfig(provider) {
const cfg = PROVIDER_ENV[provider];
if (!cfg) return null;
return {
provider,
apiKey: env[cfg.key] || '',
model: env[cfg.model] || cfg.default,
available: !!(env[cfg.key] && env[cfg.key].trim()),
};
}
export function championWithAvailability(champ) {
const cfg = providerConfig(champ.provider);
return { ...champ, model: cfg ? cfg.model : null, available: cfg ? cfg.available : false };
}
// Estimate tokens when a provider doesn't report usage. ~4 chars/token.
const estTok = (s) => Math.max(1, Math.round((s || '').length / 4));
// ── Streaming adapters ────────────────────────────────────────────────
// Each returns: { text, ttft, total, inTok, outTok, model, cost }
// ttft/total in ms. Throws on any failure (the caller reports it honestly).
async function readSSE(res, onEvent) {
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (line.startsWith('data:')) onEvent(line.slice(5).trim());
}
}
if (buf.trim().startsWith('data:')) onEvent(buf.trim().slice(5).trim());
}
async function openAICompatible({ base, apiKey, model, prompt, extraHeaders = {} }) {
const t0 = performance.now();
const res = await fetch(`${base}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, ...extraHeaders },
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
stream: true,
stream_options: { include_usage: true },
temperature: 0.7,
}),
});
if (!res.ok) throw new Error(`${model}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
let text = '', ttft = null, inTok = 0, outTok = 0;
await readSSE(res, (data) => {
if (data === '[DONE]') return;
let j; try { j = JSON.parse(data); } catch { return; }
const delta = j.choices?.[0]?.delta?.content;
if (delta) { if (ttft === null) ttft = performance.now() - t0; text += delta; }
if (j.usage) { inTok = j.usage.prompt_tokens || inTok; outTok = j.usage.completion_tokens || outTok; }
});
const total = performance.now() - t0;
if (!inTok) inTok = estTok(prompt);
if (!outTok) outTok = estTok(text);
return { text, ttft: ttft ?? total, total, inTok, outTok, model, cost: costOf(model, inTok, outTok) };
}
async function anthropic({ apiKey, model, prompt }) {
const t0 = performance.now();
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({ model, max_tokens: 1024, stream: true, messages: [{ role: 'user', content: prompt }] }),
});
if (!res.ok) throw new Error(`${model}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
let text = '', ttft = null, inTok = 0, outTok = 0;
await readSSE(res, (data) => {
let j; try { j = JSON.parse(data); } catch { return; }
if (j.type === 'content_block_delta' && j.delta?.text) {
if (ttft === null) ttft = performance.now() - t0;
text += j.delta.text;
}
if (j.type === 'message_start' && j.message?.usage) inTok = j.message.usage.input_tokens || inTok;
if (j.usage?.output_tokens) outTok = j.usage.output_tokens;
if (j.type === 'message_delta' && j.usage?.output_tokens) outTok = j.usage.output_tokens;
});
const total = performance.now() - t0;
if (!inTok) inTok = estTok(prompt);
if (!outTok) outTok = estTok(text);
return { text, ttft: ttft ?? total, total, inTok, outTok, model, cost: costOf(model, inTok, outTok) };
}
async function google({ apiKey, model, prompt }) {
const t0 = performance.now();
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }] }),
});
if (!res.ok) throw new Error(`${model}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
let text = '', ttft = null, inTok = 0, outTok = 0;
await readSSE(res, (data) => {
let j; try { j = JSON.parse(data); } catch { return; }
const part = j.candidates?.[0]?.content?.parts?.[0]?.text;
if (part) { if (ttft === null) ttft = performance.now() - t0; text += part; }
if (j.usageMetadata) {
inTok = j.usageMetadata.promptTokenCount || inTok;
outTok = j.usageMetadata.candidatesTokenCount || outTok;
}
});
const total = performance.now() - t0;
if (!inTok) inTok = estTok(prompt);
if (!outTok) outTok = estTok(text);
return { text, ttft: ttft ?? total, total, inTok, outTok, model, cost: costOf(model, inTok, outTok) };
}
// Dispatch a real streamed call for a champion. Throws if unavailable/failed.
export async function callChampion(champ, prompt) {
const cfg = providerConfig(champ.provider);
if (!cfg) throw new Error(`Unknown provider ${champ.provider}`);
if (!cfg.available) throw new Error(`${champ.name} has no API key configured`);
const { apiKey, model } = cfg;
switch (champ.provider) {
case 'openai': return openAICompatible({ base: 'https://api.openai.com/v1', apiKey, model, prompt });
case 'xai': return openAICompatible({ base: 'https://api.x.ai/v1', apiKey, model, prompt });
case 'deepseek': return openAICompatible({ base: 'https://api.deepseek.com', apiKey, model, prompt });
case 'groq': return openAICompatible({ base: 'https://api.groq.com/openai/v1', apiKey, model, prompt });
case 'anthropic':return anthropic({ apiKey, model, prompt });
case 'google': return google({ apiKey, model, prompt });
default: throw new Error(`No adapter for ${champ.provider}`);
}
}
// A non-streaming JSON call used by the AI judge. Returns parsed JSON or throws.
export async function judgeCall(provider, prompt) {
const cfg = providerConfig(provider);
if (!cfg || !cfg.available) throw new Error(`Judge provider ${provider} unavailable`);
const { apiKey, model } = cfg;
const sys = 'You are a strict, impartial tournament judge. Respond with ONLY valid minified JSON, no markdown.';
if (provider === 'anthropic') {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: JSON.stringify({ model, max_tokens: 1024, system: sys, messages: [{ role: 'user', content: prompt }] }),
});
if (!res.ok) throw new Error(`judge HTTP ${res.status}`);
const j = await res.json();
return extractJSON(j.content?.[0]?.text || '');
}
if (provider === 'google') {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ systemInstruction: { parts: [{ text: sys }] }, contents: [{ role: 'user', parts: [{ text: prompt }] }], generationConfig: { responseMimeType: 'application/json' } }),
});
if (!res.ok) throw new Error(`judge HTTP ${res.status}`);
const j = await res.json();
return extractJSON(j.candidates?.[0]?.content?.parts?.[0]?.text || '');
}
// OpenAI-compatible (openai/xai/deepseek/groq)
const base = { openai: 'https://api.openai.com/v1', xai: 'https://api.x.ai/v1', deepseek: 'https://api.deepseek.com', groq: 'https://api.groq.com/openai/v1' }[provider];
const res = await fetch(`${base}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model, messages: [{ role: 'system', content: sys }, { role: 'user', content: prompt }], temperature: 0, response_format: { type: 'json_object' } }),
});
if (!res.ok) throw new Error(`judge HTTP ${res.status}`);
const j = await res.json();
return extractJSON(j.choices?.[0]?.message?.content || '');
}
function extractJSON(s) {
if (!s) throw new Error('empty judge response');
const a = s.indexOf('{'), b = s.lastIndexOf('}');
if (a < 0 || b < 0) throw new Error('no JSON in judge response');
return JSON.parse(s.slice(a, b + 1));
}
export function judgeProvider() {
const forced = env.JUDGE_PROVIDER;
if (forced && providerConfig(forced)?.available) return forced;
for (const p of ['anthropic', 'openai', 'google', 'deepseek', 'xai', 'groq']) {
if (providerConfig(p)?.available) return p;
}
return null;
}