← 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;
}