| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629 |
- <!doctype html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>API Key Settings — Tasmanian Planning Scheme Assistant</title>
- <meta name="description" content="Configure your own LLM API key to use Claude, GPT-4 or Grok with the Tasmanian Planning Scheme Assistant.">
- <link rel="canonical" href="https://tasplanning.report/byok-settings">
- <meta name="robots" content="noindex,nofollow">
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&display=swap" rel="stylesheet">
- <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
- <link rel="icon" href="/favicon.ico">
- <link rel="stylesheet" href="/css/design-tokens.css">
- <style>
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- body {
- font-family: var(--sans); background: var(--bg); color: var(--text-primary);
- font-size: 15px; line-height: 1.65; -webkit-font-smoothing: antialiased;
- min-height: 100vh;
- }
- ::selection { background: var(--accent); color: #0b0f0e; }
- /* Nav */
- .site-nav {
- background: rgba(11,15,14,0.95); backdrop-filter: blur(12px);
- border-bottom: 1px solid var(--border);
- position: sticky; top: 0; z-index: 100;
- }
- .nav-inner {
- max-width: 900px; margin: 0 auto; padding: 0 24px;
- display: flex; align-items: center; justify-content: space-between; height: 54px;
- }
- .nav-brand {
- display: flex; align-items: center; gap: 9px;
- font-size: 0.85rem; font-weight: 500; color: var(--text-primary); text-decoration: none;
- }
- .nav-back {
- font-size: 0.8rem; color: var(--text-secondary); text-decoration: none;
- display: flex; align-items: center; gap: 5px;
- transition: color var(--transition);
- }
- .nav-back:hover { color: var(--accent); }
- /* Page */
- .page { max-width: 720px; margin: 0 auto; padding: 40px 24px 80px; }
- .page-header { margin-bottom: 32px; }
- .page-header h1 { font-family: var(--serif); font-size: 2rem; font-weight: 400; margin-bottom: 8px; }
- .page-header h1 em { font-style: italic; color: var(--accent); }
- .page-header p { color: var(--text-secondary); font-size: 0.9rem; max-width: 520px; }
- /* Security notice */
- .security-notice {
- background: var(--warn-dim); border: 1px solid rgba(240,192,96,0.25);
- border-radius: var(--radius); padding: 14px 18px;
- display: flex; gap: 12px; align-items: flex-start; margin-bottom: 28px;
- }
- .security-notice i { color: var(--warn); font-size: 1.1rem; flex-shrink: 0; margin-top: 2px; }
- .security-notice p { font-size: 0.82rem; color: var(--text-secondary); line-height: 1.6; }
- .security-notice strong { color: var(--warn); }
- /* Provider cards */
- .provider-grid { display: flex; flex-direction: column; gap: 16px; }
- .provider-card {
- background: var(--bg-card); border: 1px solid var(--border);
- border-radius: var(--radius-lg); overflow: hidden;
- transition: border-color var(--transition);
- }
- .provider-card.has-key { border-color: rgba(45,220,138,0.3); }
- .provider-card.active-provider { border-color: var(--accent); }
- .provider-header {
- padding: 16px 20px; display: flex; align-items: center; gap: 14px;
- cursor: pointer; user-select: none;
- }
- .provider-logo {
- width: 36px; height: 36px; border-radius: 8px;
- display: flex; align-items: center; justify-content: center;
- font-size: 1.1rem; flex-shrink: 0;
- }
- .provider-info { flex: 1; }
- .provider-name { font-size: 0.95rem; font-weight: 500; margin-bottom: 2px; }
- .provider-desc { font-size: 0.75rem; color: var(--text-muted); }
- .provider-status {
- display: flex; align-items: center; gap: 6px;
- font-size: 0.72rem; font-weight: 500;
- }
- .status-pill {
- padding: 3px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 500;
- }
- .status-pill.configured {
- background: var(--accent-dim); color: var(--accent);
- border: 1px solid rgba(45,220,138,0.25);
- }
- .status-pill.not-set {
- background: var(--bg-2); color: var(--text-muted);
- border: 1px solid var(--border);
- }
- .status-pill.active {
- background: var(--accent); color: #0b0f0e;
- }
- .expand-icon { color: var(--text-muted); transition: transform var(--transition); }
- .provider-card.expanded .expand-icon { transform: rotate(180deg); }
- .provider-body {
- display: none; padding: 0 20px 20px; border-top: 1px solid var(--border);
- }
- .provider-card.expanded .provider-body { display: block; }
- /* Form elements */
- .field-group { margin-top: 16px; }
- .field-label {
- display: block; font-size: 0.7rem; font-weight: 500;
- letter-spacing: 0.09em; text-transform: uppercase;
- color: var(--text-muted); margin-bottom: 6px;
- }
- .key-input-wrap { position: relative; }
- .key-input {
- width: 100%; background: var(--bg-2); border: 1px solid var(--border);
- border-radius: var(--radius-sm); padding: 10px 44px 10px 12px;
- color: var(--text-primary); font-family: var(--mono); font-size: 0.8rem;
- outline: none; transition: border-color var(--transition);
- }
- .key-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); }
- .key-input::placeholder { color: var(--text-muted); font-family: var(--sans); }
- .key-toggle {
- position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
- background: none; border: none; color: var(--text-muted); cursor: pointer;
- font-size: 0.9rem; padding: 4px;
- transition: color var(--transition);
- }
- .key-toggle:hover { color: var(--text-secondary); }
- .model-select {
- width: 100%; background: var(--bg-2); border: 1px solid var(--border);
- border-radius: var(--radius-sm); padding: 8px 10px;
- color: var(--text-primary); font-family: var(--sans); font-size: 0.82rem;
- outline: none; transition: border-color var(--transition);
- }
- .model-select:focus { border-color: var(--accent); }
- .model-select option { background: var(--bg-2); }
- .field-hint { font-size: 0.72rem; color: var(--text-muted); margin-top: 6px; }
- .field-hint a { color: var(--text-secondary); }
- .field-hint a:hover { color: var(--accent); }
- /* Buttons */
- .btn-row { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
- .btn {
- display: inline-flex; align-items: center; gap: 6px;
- padding: 9px 18px; border-radius: var(--radius-sm);
- font-family: var(--sans); font-size: 0.82rem; font-weight: 500;
- cursor: pointer; transition: all var(--transition); border: none;
- }
- .btn-primary { background: var(--accent); color: #0b0f0e; }
- .btn-primary:hover { background: #3bf59a; transform: translateY(-1px); }
- .btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-hover); }
- .btn-outline:hover { border-color: var(--accent); color: var(--accent); }
- .btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
- .btn-ghost:hover { border-color: var(--border-hover); color: var(--text-secondary); }
- .btn-danger-ghost { background: transparent; color: var(--danger); border: 1px solid rgba(240,128,128,0.3); }
- .btn-danger-ghost:hover { background: rgba(240,128,128,0.08); }
- /* Test result */
- .test-result {
- margin-top: 12px; padding: 10px 14px; border-radius: var(--radius-sm);
- font-size: 0.78rem; display: none;
- }
- .test-result.ok { background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.25); color: var(--accent); }
- .test-result.fail { background: rgba(240,128,128,0.08); border: 1px solid rgba(240,128,128,0.25); color: var(--danger); }
- /* Active provider selector */
- .active-section {
- background: var(--bg-1); border: 1px solid var(--border);
- border-radius: var(--radius-lg); padding: 20px;
- margin-bottom: 24px;
- }
- .active-section h2 { font-size: 0.82rem; font-weight: 500; margin-bottom: 12px; color: var(--text-secondary); }
- .provider-option {
- display: flex; align-items: center; gap: 12px;
- padding: 10px 14px; border-radius: var(--radius-sm);
- border: 1px solid var(--border); background: var(--bg-2);
- cursor: pointer; transition: all var(--transition); margin-bottom: 8px;
- }
- .provider-option:last-child { margin-bottom: 0; }
- .provider-option:hover { border-color: var(--border-hover); }
- .provider-option.selected { border-color: var(--accent); background: var(--accent-dim); }
- .provider-option input[type=radio] { accent-color: var(--accent); flex-shrink: 0; }
- .provider-option-label { flex: 1; }
- .provider-option-name { font-size: 0.85rem; font-weight: 500; }
- .provider-option-detail { font-size: 0.72rem; color: var(--text-muted); margin-top: 1px; }
- .provider-option.disabled { opacity: 0.4; cursor: not-allowed; }
- .provider-option.disabled:hover { border-color: var(--border); }
- /* Divider */
- .divider { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
- /* Spinner */
- .spinner {
- width: 13px; height: 13px; border: 2px solid var(--border);
- border-top-color: var(--accent); border-radius: 50%;
- animation: spin .65s linear infinite; display: inline-block;
- }
- @keyframes spin { to { transform: rotate(360deg); } }
- ::-webkit-scrollbar { width: 5px; }
- ::-webkit-scrollbar-track { background: transparent; }
- ::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
- </style>
- </head>
- <body>
- <nav class="site-nav">
- <div class="nav-inner">
- <a class="nav-brand" href="/">
- <svg width="22" height="22" viewBox="0 0 28 28" fill="none">
- <rect width="28" height="28" rx="6" fill="var(--accent-dim)" stroke="rgba(45,220,138,0.25)" stroke-width="1"/>
- <path d="M8 20 L14 8 L20 20" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
- <path d="M10.5 16 L17.5 16" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round"/>
- </svg>
- Tasmanian Planning Scheme
- </a>
- <a class="nav-back" href="/local_state-planning-scheme.php">
- <i class="bi bi-arrow-left"></i> Back to Assistant
- </a>
- </div>
- </nav>
- <div class="page">
- <div class="page-header">
- <h1>Bring your <em>own API key</em></h1>
- <p>Use Claude, GPT-4o or Grok as the AI behind your planning queries. Your Qdrant vector search and clause retrieval stay on our servers — only the final LLM call uses your key.</p>
- </div>
- <!-- Security notice -->
- <div class="security-notice">
- <i class="bi bi-shield-lock"></i>
- <p>
- <strong>Your key never leaves your browser.</strong>
- Keys are stored in <code style="font-size:0.78rem;color:var(--warn);">localStorage</code> only and sent
- directly to the provider's API — never to our servers. Anyone with access to
- this browser can read keys from DevTools. Use a key with
- <strong>spending limits</strong> set in your provider's dashboard.
- </p>
- </div>
- <!-- Active provider selector -->
- <div class="active-section">
- <h2><i class="bi bi-toggles" style="margin-right:6px;color:var(--accent);"></i>Active LLM provider</h2>
- <div id="providerOptions">
- <!-- Rendered by JS -->
- </div>
- </div>
- <hr class="divider">
- <!-- Provider config cards -->
- <div class="provider-grid" id="providerGrid">
- <!-- Rendered by JS -->
- </div>
- </div>
- <script>
- 'use strict';
- /* ── Storage keys ────────────────────────────────────────────────────── */
- const ACTIVE_KEY = 'tpr_byok_active'; // which provider is active: 'internal'|'anthropic'|'openai'|'grok'|'ollama'
- const KEY_PREFIX = 'tpr_byok_key_'; // + provider id
- const MODEL_PREFIX= 'tpr_byok_model_'; // + provider id
- /* ── Provider definitions ────────────────────────────────────────────── */
- const PROVIDERS = [
- {
- id: 'anthropic',
- name: 'Anthropic Claude',
- desc: 'claude-sonnet-4-5 — best reasoning for planning documents',
- icon: '✦',
- iconBg: '#1a1f2e',
- iconColor: '#c084fc',
- keyPlaceholder: 'sk-ant-api03-…',
- keyHint: 'Get your key at <a href="https://console.anthropic.com/keys" target="_blank" rel="noopener">console.anthropic.com/keys</a>. Set a monthly spend limit.',
- models: [
- { value: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5 (recommended)' },
- { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 (faster, cheaper)' },
- { value: 'claude-opus-4-5', label: 'Claude Opus 4.5 (most capable)' },
- ],
- test: testAnthropic,
- },
- {
- id: 'openai',
- name: 'OpenAI',
- desc: 'gpt-4o-mini — fast and cost-effective',
- icon: '⬡',
- iconBg: '#0f2027',
- iconColor: '#74aa9c',
- keyPlaceholder: 'sk-proj-…',
- keyHint: 'Get your key at <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener">platform.openai.com/api-keys</a>. Set usage limits.',
- models: [
- { value: 'gpt-4o-mini', label: 'GPT-4o mini (recommended)' },
- { value: 'gpt-4o', label: 'GPT-4o (more capable)' },
- { value: 'gpt-4.1-mini', label: 'GPT-4.1 mini' },
- ],
- test: testOpenAI,
- },
- {
- id: 'grok',
- name: 'xAI Grok',
- desc: 'grok-3-mini — OpenAI-compatible, strong reasoning',
- icon: '𝕏',
- iconBg: '#0a0a0a',
- iconColor: '#e5e5e5',
- keyPlaceholder: 'xai-…',
- keyHint: 'Get your key at <a href="https://console.x.ai" target="_blank" rel="noopener">console.x.ai</a>.',
- models: [
- { value: 'grok-3-mini', label: 'Grok 3 mini (recommended)' },
- { value: 'grok-3', label: 'Grok 3 (most capable)' },
- ],
- test: testOpenAICompat,
- },
- {
- id: 'ollama',
- name: 'Ollama (local)',
- desc: 'Your own local Ollama instance — full privacy',
- icon: '⬢',
- iconBg: '#0f1a0f',
- iconColor: '#2ddc8a',
- keyPlaceholder: 'http://localhost:11434',
- keyHint: 'Enter the base URL of your Ollama instance. No API key needed.',
- isUrlField: true,
- models: [
- { value: 'llama3.1:8b', label: 'llama3.1:8b' },
- { value: 'mistral:7b', label: 'mistral:7b' },
- { value: 'custom', label: 'Custom (type below)' },
- ],
- test: testOllama,
- },
- ];
- /* ── State ───────────────────────────────────────────────────────────── */
- function getActive() { return localStorage.getItem(ACTIVE_KEY) || 'internal'; }
- function setActive(id) { localStorage.setItem(ACTIVE_KEY, id); }
- function getKey(id) { return localStorage.getItem(KEY_PREFIX + id) || ''; }
- function setKey(id, val) { val ? localStorage.setItem(KEY_PREFIX + id, val) : localStorage.removeItem(KEY_PREFIX + id); }
- function getModel(id, fallback) { return localStorage.getItem(MODEL_PREFIX + id) || fallback || ''; }
- function setModel(id, val) { localStorage.setItem(MODEL_PREFIX + id, val); }
- function hasKey(id) { return !!getKey(id); }
- /* ── Render ──────────────────────────────────────────────────────────── */
- function render() {
- renderActiveSelector();
- renderProviderCards();
- }
- function renderActiveSelector() {
- const active = getActive();
- const wrap = document.getElementById('providerOptions');
- const options = [
- { id: 'internal', name: 'Our server ', detail: 'Uses the built-in custom model — no key needed', always: true },
- ...PROVIDERS.map(p => ({
- id: p.id, name: p.name,
- detail: hasKey(p.id) ? `Key configured · ${getModel(p.id, p.models[0].value)}` : 'No key set — configure below',
- always: false,
- }))
- ];
- wrap.innerHTML = options.map(o => {
- const sel = active === o.id;
- const disabled = !o.always && !hasKey(o.id);
- return `
- <label class="provider-option ${sel ? 'selected' : ''} ${disabled ? 'disabled' : ''}"
- onclick="${disabled ? 'event.preventDefault()' : `selectProvider('${o.id}')`}">
- <input type="radio" name="activeProvider" value="${o.id}"
- ${sel ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
- <div class="provider-option-label">
- <div class="provider-option-name">${o.name}</div>
- <div class="provider-option-detail">${o.detail}</div>
- </div>
- ${sel ? '<i class="bi bi-check-circle-fill" style="color:var(--accent);font-size:1rem;"></i>' : ''}
- </label>`;
- }).join('');
- }
- function renderProviderCards() {
- const grid = document.getElementById('providerGrid');
- grid.innerHTML = PROVIDERS.map(p => {
- const key = getKey(p.id);
- const model = getModel(p.id, p.models[0].value);
- const hasK = !!key;
- const isActive= getActive() === p.id;
- const masked = key ? maskKey(key) : '';
- const modelOptions = p.models.map(m =>
- `<option value="${m.value}" ${model === m.value ? 'selected' : ''}>${m.label}</option>`
- ).join('');
- return `
- <div class="provider-card ${hasK ? 'has-key' : ''} ${isActive ? 'active-provider' : ''}" id="card-${p.id}">
- <div class="provider-header" onclick="toggleCard('${p.id}')">
- <div class="provider-logo" style="background:${p.iconBg};color:${p.iconColor};">
- ${p.icon}
- </div>
- <div class="provider-info">
- <div class="provider-name">${p.name}</div>
- <div class="provider-desc">${p.desc}</div>
- </div>
- <div class="provider-status">
- ${isActive ? '<span class="status-pill active"><i class="bi bi-stars"></i> Active</span>' :
- hasK ? '<span class="status-pill configured"><i class="bi bi-check2"></i> Configured</span>' :
- '<span class="status-pill not-set">Not set</span>'}
- </div>
- <i class="bi bi-chevron-down expand-icon" style="margin-left:10px;font-size:0.8rem;"></i>
- </div>
- <div class="provider-body" id="body-${p.id}">
- <div class="field-group">
- <label class="field-label">${p.isUrlField ? 'Base URL' : 'API Key'}</label>
- <div class="key-input-wrap">
- <input type="password" class="key-input" id="key-${p.id}"
- value="${esc(key)}"
- placeholder="${p.keyPlaceholder}"
- autocomplete="off" spellcheck="false">
- <button class="key-toggle" onclick="toggleKeyVis('${p.id}')" title="Show/hide">
- <i class="bi bi-eye" id="eye-${p.id}"></i>
- </button>
- </div>
- <p class="field-hint">${p.keyHint}</p>
- </div>
- <div class="field-group">
- <label class="field-label">Model</label>
- <select class="model-select" id="model-${p.id}"
- onchange="setModel('${p.id}', this.value)">
- ${modelOptions}
- </select>
- ${p.id === 'ollama' ? `
- <div id="ollama-custom-wrap" style="${model === 'custom' ? '' : 'display:none;'}margin-top:8px;">
- <input type="text" class="key-input" id="model-custom-${p.id}"
- style="font-family:var(--sans);"
- value="${model !== 'custom' ? model : ''}"
- placeholder="e.g. codellama:13b">
- </div>` : ''}
- </div>
- <div class="test-result" id="test-${p.id}"></div>
- <div class="btn-row">
- <button class="btn btn-primary" onclick="saveProvider('${p.id}')">
- <i class="bi bi-floppy"></i> Save
- </button>
- <button class="btn btn-outline" onclick="testProvider('${p.id}')">
- <i class="bi bi-lightning"></i> Test connection
- </button>
- ${hasK ? `<button class="btn btn-danger-ghost" onclick="removeProvider('${p.id}')">
- <i class="bi bi-trash3"></i> Remove key
- </button>` : ''}
- </div>
- </div>
- </div>`;
- }).join('');
- // Wire up Ollama custom model toggle
- document.getElementById('model-ollama')?.addEventListener('change', function() {
- const wrap = document.getElementById('ollama-custom-wrap');
- if (wrap) wrap.style.display = this.value === 'custom' ? '' : 'none';
- });
- }
- /* ── Interactions ────────────────────────────────────────────────────── */
- window.toggleCard = function(id) {
- const card = document.getElementById(`card-${id}`);
- card?.classList.toggle('expanded');
- };
- window.toggleKeyVis = function(id) {
- const input = document.getElementById(`key-${id}`);
- const eye = document.getElementById(`eye-${id}`);
- if (!input) return;
- const isHidden = input.type === 'password';
- input.type = isHidden ? 'text' : 'password';
- eye.className = isHidden ? 'bi bi-eye-slash' : 'bi bi-eye';
- };
- window.selectProvider = function(id) {
- setActive(id);
- render();
- };
- window.saveProvider = function(id) {
- const p = PROVIDERS.find(p => p.id === id);
- const keyEl = document.getElementById(`key-${id}`);
- const key = (keyEl?.value || '').trim();
- let model = document.getElementById(`model-${id}`)?.value || p?.models[0]?.value || '';
- if (id === 'ollama' && model === 'custom') {
- model = document.getElementById(`model-custom-${id}`)?.value.trim() || 'llama3.1:8b';
- }
- if (!key) {
- showTestResult(id, false, `Please enter a ${p?.isUrlField ? 'URL' : 'key'} first.`);
- return;
- }
- setKey(id, key);
- setModel(id, model);
- // If this is the only configured key, auto-select it
- if (getActive() === 'internal') setActive(id);
- showTestResult(id, true, 'Saved. Click "Test connection" to verify it works.');
- render();
- };
- window.removeProvider = function(id) {
- if (!confirm(`Remove ${PROVIDERS.find(p=>p.id===id)?.name} key?`)) return;
- setKey(id, '');
- if (getActive() === id) setActive('internal');
- render();
- };
- window.testProvider = async function(id) {
- const btn = document.querySelector(`#card-${id} .btn-outline`);
- const orig = btn?.innerHTML;
- if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Testing…'; }
- const key = document.getElementById(`key-${id}`)?.value || getKey(id);
- let model = document.getElementById(`model-${id}`)?.value || getModel(id);
- if (id === 'ollama' && model === 'custom')
- model = document.getElementById(`model-custom-${id}`)?.value || 'llama3.1:8b';
- const p = PROVIDERS.find(p => p.id === id);
- try {
- await p.test(key, model);
- showTestResult(id, true, `Connected to ${p.name} (${model}) ✓`);
- } catch(e) {
- showTestResult(id, false, `Failed: ${e.message}`);
- } finally {
- if (btn) { btn.disabled = false; btn.innerHTML = orig; }
- }
- };
- function showTestResult(id, ok, msg) {
- const el = document.getElementById(`test-${id}`);
- if (!el) return;
- el.className = `test-result ${ok ? 'ok' : 'fail'}`;
- el.innerHTML = `<i class="bi bi-${ok ? 'check-circle' : 'x-circle'}"></i> ${esc(msg)}`;
- el.style.display = 'block';
- }
- /* ── Provider test functions ─────────────────────────────────────────── */
- async function testAnthropic(key, model) {
- const res = await fetch('https://api.anthropic.com/v1/messages', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'x-api-key': key,
- 'anthropic-version': '2023-06-01',
- 'anthropic-dangerous-direct-browser-access': 'true',
- },
- body: JSON.stringify({
- model,
- max_tokens: 32,
- messages: [{ role: 'user', content: 'Reply with just the word CONNECTED.' }]
- })
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- throw new Error(err?.error?.message || `HTTP ${res.status}`);
- }
- const data = await res.json();
- const text = data?.content?.[0]?.text || '';
- if (!text) throw new Error('Empty response');
- }
- async function testOpenAI(key, model) {
- await testOpenAICompat(key, model, 'https://api.openai.com/v1');
- }
- async function testOpenAICompat(key, model, baseUrl = 'https://api.x.ai/v1') {
- const res = await fetch(`${baseUrl}/chat/completions`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` },
- body: JSON.stringify({
- model,
- max_tokens: 16,
- messages: [{ role: 'user', content: 'Reply with just the word CONNECTED.' }]
- })
- });
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- throw new Error(err?.error?.message || `HTTP ${res.status}`);
- }
- }
- async function testOllama(baseUrl, model) {
- const url = baseUrl.replace(/\/$/, '') + '/api/generate';
- const res = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ model, prompt: 'Reply with CONNECTED.', stream: false })
- });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- }
- /* ── Utilities ───────────────────────────────────────────────────────── */
- function maskKey(key) {
- if (!key || key.length < 12) return '••••••••';
- return key.slice(0, 8) + '••••••••' + key.slice(-4);
- }
- function esc(s) {
- return String(s || '').replace(/[&<>"']/g, c =>
- ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])
- );
- }
- /* ── Boot ────────────────────────────────────────────────────────────── */
- render();
- // Expand first card if no keys configured yet
- const configured = PROVIDERS.filter(p => hasKey(p.id));
- if (!configured.length) {
- document.getElementById('card-anthropic')?.classList.add('expanded');
- }
- </script>
- </body>
- </html>
|