summaryrefslogtreecommitdiff
path: root/internal/adminui/assets
diff options
context:
space:
mode:
authorChia <Chia@93.nz>2026-08-06 09:29:41 +1200
committerChia <Chia@93.nz>2026-08-06 09:32:46 +1200
commit41e322c53d7b4b796eb377d0df9c29ecd10ba431 (patch)
treec730526150e55e39b822d5197e4a20318ecaa449 /internal/adminui/assets
parenteadb2ffe85c43cf6fc741c9823cd28eedb4a844c (diff)
feat: complete commercial control plane, billing, auth, and model catalog
- add PostgreSQL control-plane persistence with Redis-degraded hot reload - implement prepaid balance, usage ledger, Stripe top-up and reconciliation - add registration, email verification, password reset, invitations and RBAC - support TOTP, Passkey MFA, device sessions, quotas and rate limits - add tenant billing profiles, audit logs and operational readiness checks - build authenticated admin console, Quickstart, Playground and usage analytics - add public model catalog with pricing, filtering and cost estimation - support OpenAI Responses providers and provider health failover - validate real upstream usage reporting and balance settlement
Diffstat (limited to '')
-rw-r--r--internal/adminui/assets/app.js307
-rw-r--r--internal/adminui/assets/index.html139
-rw-r--r--internal/adminui/assets/models.css64
-rw-r--r--internal/adminui/assets/models.html50
-rw-r--r--internal/adminui/assets/models.js126
-rw-r--r--internal/adminui/assets/style.css132
6 files changed, 790 insertions, 28 deletions
diff --git a/internal/adminui/assets/app.js b/internal/adminui/assets/app.js
index 8d29e23..e30a0c8 100644
--- a/internal/adminui/assets/app.js
+++ b/internal/adminui/assets/app.js
@@ -1,8 +1,10 @@
const state = {
token: '', csrf: '', actor: {}, permissions: new Set(), overview: {},
tenants: [], projects: [], keys: [], providers: [], models: [], billingAccounts: [], ledger: [],
- usage: [], usageSummary: [], limits: [], users: [], audit: [], orders: [], refunds: [], disputes: [], invoices: [], sessions: [],
- mfa: {totp_enabled:false,passkeys:[]}, pendingMFA: null, authConfig: {}
+ usage: [], usageSummary: [], usageDaily: [], usageAnalytics: {models:[],providers:[]}, limits: [], users: [], audit: [], orders: [], refunds: [], disputes: [], invoices: [], sessions: [],
+ developerConfig: {base_url:'',endpoints:{}}, developerModels: [], preferences: {},
+ autoTopUp: {}, billingProfile: {},
+ mfa: {totp_enabled:false,passkeys:[]}, pendingMFA: null, authConfig: {}, playgroundKey: '', playgroundController: null, detailModel: null, detailUsage: null
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
@@ -26,7 +28,7 @@ async function api(path, options = {}) {
function setConnected(connected) {
$('#auth-screen').classList.toggle('hidden', connected);
$('#console-app').classList.toggle('hidden', !connected);
- if (!connected) return;
+ if (!connected) { state.playgroundController?.abort();state.playgroundController=null;state.playgroundKey=''; const key=$('#playground-key');if(key)key.value=''; return; }
$('#connection-state').textContent = state.actor.role?.replaceAll('_', ' ') || 'connected';
$('#actor-label').textContent = state.actor.display_name || state.actor.email || 'Operator';
}
@@ -42,6 +44,7 @@ function scaledToDecimal(value, digits) { const number = BigInt(value || 0); con
function currencyDigits(currency) { return ['bif','clp','djf','gnf','jpy','kmf','krw','mga','pyg','rwf','ugx','vnd','vuv','xaf','xof','xpf'].includes(currency) ? 0 : ['bhd','jod','kwd','omr','tnd'].includes(currency) ? 3 : 2; }
function money(micros, currency = state.overview.billing_currency || 'usd') { return new Intl.NumberFormat(undefined, { style:'currency', currency:currency.toUpperCase(), minimumFractionDigits:2, maximumFractionDigits:6 }).format(Number(micros || 0) / 1_000_000); }
function integer(value) { return new Intl.NumberFormat().format(Number(value || 0)); }
+function chartHeightClass(value, maximum) { return `chart-height-${Math.max(1, Math.min(20, Math.ceil(Number(value || 0) / Math.max(1, Number(maximum || 0)) * 20)))}`; }
function emptyRow(span) { return `<tr><td colspan="${span}" class="empty">No records yet</td></tr>`; }
function showSecret(title, value) { $('#secret-title').textContent = title; $('#created-secret').textContent = value; $('#secret-dialog').showModal(); }
function cookie(name) { const prefix=`${encodeURIComponent(name)}=`; const value=document.cookie.split('; ').find(item=>item.startsWith(prefix)); return value ? decodeURIComponent(value.slice(prefix.length)) : ''; }
@@ -90,13 +93,16 @@ async function getPasskey(options) {
}
async function permitted(permission, path) { if (!can(permission)) return []; return api(path); }
+function defaultUsageQuery() { const to=new Date();const from=new Date(to.getTime()-29*86400000);return new URLSearchParams({from:from.toISOString().slice(0,10),to:to.toISOString().slice(0,10)}).toString(); }
async function loadAll(knownSession = null) {
try {
const session = knownSession || await api('/me'); state.actor = session.actor || {}; state.permissions = new Set(session.permissions || []);
state.overview = await api('/overview');
+ const usageQuery=defaultUsageQuery();
const results = await Promise.all([
permitted('tenants.read','/tenants'), permitted('projects.read','/projects'), permitted('keys.read','/keys'),
- permitted('platform.read','/providers'), permitted('platform.read','/models'), permitted('usage.read','/usage'),
+ permitted('platform.read','/providers'), permitted('platform.read','/models'), permitted('overview.read','/developer/config'),
+ permitted('overview.read','/developer/models'), can('preferences.read') ? api('/developer/preferences') : {}, permitted('usage.read',`/usage?${usageQuery}`),
permitted('usage.read','/usage/summary'), permitted('limits.read','/limits'), permitted('users.read','/users'),
permitted('audit.read','/audit'), state.overview.billing_enabled ? permitted('billing.read','/billing/accounts') : [],
state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : [],
@@ -105,9 +111,12 @@ async function loadAll(knownSession = null) {
state.overview.billing_enabled && can('billing.read') ? api('/billing/orders') : [],
state.overview.billing_enabled ? permitted('billing.read','/billing/refunds') : [],
state.overview.billing_enabled ? permitted('billing.read','/billing/disputes') : [],
- state.overview.billing_enabled ? permitted('billing.read','/billing/invoices') : []
+ state.overview.billing_enabled ? permitted('billing.read','/billing/invoices') : [],
+ permitted('usage.read',`/usage/daily?${usageQuery}`), permitted('usage.read',`/usage/analytics?${usageQuery}`),
+ state.overview.billing_enabled && state.actor.tenant_id && can('billing.read') ? api('/billing/auto-topup') : {},
+ state.overview.billing_enabled && state.actor.tenant_id && can('billing.read') ? api('/billing/profile') : {}
]);
- [state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger,state.mfa,state.sessions,state.orders,state.refunds,state.disputes,state.invoices] = results;
+ [state.tenants,state.projects,state.keys,state.providers,state.models,state.developerConfig,state.developerModels,state.preferences,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger,state.mfa,state.sessions,state.orders,state.refunds,state.disputes,state.invoices,state.usageDaily,state.usageAnalytics,state.autoTopUp,state.billingProfile] = results;
renderAll(); setConnected(true); return true;
} catch (error) { setConnected(false); if (error.status !== 401) toast(error.message, true); return false; }
}
@@ -116,11 +125,16 @@ function applyPermissions() {
$$('[data-permission]').forEach(node => node.classList.toggle('hidden', !can(node.dataset.permission)));
$('#billing-tab').classList.toggle('hidden', !state.overview.billing_enabled || !can('billing.read'));
$('#topup-form').classList.toggle('hidden', !state.overview.stripe_enabled || !can('billing.topup'));
+ $('#billing-portal').classList.toggle('hidden', !state.overview.stripe_enabled || !can('billing.topup'));
$('#account-tab').classList.toggle('hidden', !state.actor.id);
- const active = $('.tab.active'); if (active?.classList.contains('hidden')) $('.tab[data-section="overview"]').click();
+ $('#developer-preferences-form').classList.toggle('hidden', !state.actor.tenant_id || !can('preferences.read'));
+ $('#billing-preferences-form').classList.toggle('hidden', !state.actor.tenant_id || !state.overview.billing_enabled || !can('billing.read'));
+ $('#auto-topup-panel').classList.toggle('hidden', !state.actor.tenant_id || !state.overview.billing_enabled || !can('billing.read'));
+ $('#billing-profile-panel').classList.toggle('hidden', !state.actor.tenant_id || !state.overview.billing_enabled || !can('billing.read'));
+ const active = $('.tab.active'); if (active?.classList.contains('hidden')) $('.tab[data-section="quickstart"]').click();
}
function renderAll() {
- applyPermissions(); renderOverview(); renderTenants(); renderProjects(); renderKeys(); renderProviders(); renderModels(); renderBilling();
+ applyPermissions(); renderOverview(); renderQuickstart(); renderPreferences(); renderCatalog(); renderTenants(); renderProjects(); renderKeys(); renderProviders(); renderModels(); renderBilling();
renderUsage(); renderLimits(); renderUsers(); renderAudit(); renderRouteEditor(); renderSecurity();
}
function renderOverview() {
@@ -131,23 +145,228 @@ function renderOverview() {
$('#metrics').innerHTML = items.map(([label,value,sub]) => `<article class="metric"><span>${label}</span><strong>${esc(value)}</strong><small>${esc(sub)}</small></article>`).join('');
$('#overview-usage-body').innerHTML = current.map(item => `<tr><td><strong>${esc(item.project_name)}</strong></td><td>${integer(item.request_count)}</td><td>${percent(item.successful_requests,item.request_count)}</td><td>${integer(item.total_tokens)}</td><td>${money(item.cost_micros)}</td><td class="${item.uncollected_micros ? 'money-negative':''}">${money(item.uncollected_micros)}</td></tr>`).join('') || emptyRow(6);
}
+function goTo(section) { const node=$(`.tab[data-section="${section}"]`); if(node){ node.click(); window.scrollTo({top:0,behavior:'smooth'}); } }
+function latestAvailableBalance() { return state.billingAccounts.find(item => !state.actor.tenant_id || item.tenant_id===state.actor.tenant_id)?.available_micros || 0; }
+function inferenceBaseURL() { return String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,''); }
+function developerEndpoint(name) { const defaults={chat_completions:'/v1/chat/completions',responses:'/v1/responses',messages:'/anthropic/v1/messages',models:'/v1/models'};const path=state.developerConfig.endpoints?.[name]||defaults[name]||'';return `${inferenceBaseURL()}${path}`; }
+function modelIsUnavailable(model) { return model?.health_status==='unavailable'; }
+function developerModelOption(item) { const unavailable=modelIsUnavailable(item);return `<option value="${esc(item.public_id)}" ${unavailable?'disabled':''}>${esc(item.display_name||item.public_id)}${unavailable?' (unavailable)':''}</option>`; }
+function providerHealthSummary(item) {
+ const total=Number(item.provider_count||0);const available=Number(item.available_provider_count||0);const status=item.health_status||'online';
+ if(status==='unavailable')return {status,label:'Unavailable',detail:'No provider is currently accepting traffic'};
+ if(status==='degraded')return {status,label:'Degraded',detail:`${available} of ${total} providers accepting traffic`};
+ return {status:'online',label:'Online',detail:total?`${available} of ${total} providers accepting traffic`:'Routing is available'};
+}
+function renderQuickstart() {
+ const balance=latestAvailableBalance();
+ const balanceText=state.overview.billing_enabled && can('billing.read') ? `Available balance ${money(balance)}` : state.overview.billing_enabled ? 'Balance managed by billing admin' : 'Prepaid billing disabled';
+ $('#quickstart-balance').textContent=balanceText;
+ const hasKey=state.keys.some(item=>item.status==='active');
+ const hasUsage=state.usage.some(item=>item.success);
+ const funded=!state.overview.billing_enabled || !can('billing.read') || balance>0;
+ const steps=[
+ {title:'Workspace ready',body:'Your account and default project are ready.',done:Boolean(state.actor.tenant_id||state.actor.bootstrap),action:'projects'},
+ {title:'Add balance',body:state.overview.billing_enabled?(!can('billing.read')?'Billing is managed by workspace billing members.':funded?'Funds are available for inference.':'Add funds before the first billable request.'):'Billing is not enabled.',done:funded,action:can('billing.read')?'billing':'team'},
+ {title:'Create an API key',body:hasKey?'An active key can call the gateway.':'Create a key and copy its secret once.',done:hasKey,action:hasKey?'keys':'',target:hasKey?'':'starter-key-form'},
+ {title:'Make a request',body:hasUsage?'Your first successful request is recorded.':'Run the example below and watch it appear here.',done:hasUsage,action:hasUsage?'usage':'',target:hasUsage?'':'playground-form'}
+ ];
+ $('#onboarding').innerHTML=steps.map(step=>`<button type="button" class="onboarding-step ${step.done?'done':''}" ${step.action?`data-goto="${esc(step.action)}"`:''} ${step.target?`data-scroll-to="${esc(step.target)}"`:''}><strong>${step.done?'✓ ':''}${esc(step.title)}</strong><small>${esc(step.body)}</small><span class="step-state">${step.done?'Complete':'Open step →'}</span></button>`).join('');
+ const signals=[['Models available',integer(state.developerModels.length)],['Active API keys',integer(state.keys.filter(item=>item.status==='active').length)],['This month',money(state.usageSummary.reduce((sum,item)=>sum+Number(item.cost_micros||0),0))],['Auto top-up',state.autoTopUp.enabled?'Enabled':state.autoTopUp.payment_method_configured?'Ready':'Not configured'],['Fallback model',state.preferences.fallback_model||'Not configured'],['Runtime',state.overview.redis_connected?'Live propagation':'PG fallback']];
+ $('#quickstart-signals').innerHTML=signals.map(([label,value])=>`<div class="signal"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
+ const recent=state.usage.slice(0,5);
+ $('#quickstart-usage-body').innerHTML=recent.map(item=>`<tr><td>${date(item.started_at)}</td><td><strong>${esc(item.public_model)}</strong></td><td><span class="badge ${item.success?'active':'suspended'}">${item.status_code}</span></td><td>${integer(item.total_tokens)}</td><td>${money(item.charged_micros||item.cost_micros)}</td><td>${integer(item.duration_ms)} ms</td></tr>`).join('')||emptyRow(6);
+ const modelSelect=$('#quickstart-model'); const previous=modelSelect.value||state.preferences.default_model;
+ modelSelect.innerHTML=state.developerModels.map(developerModelOption).join('')||'<option value="">No available model</option>';
+ if(state.developerModels.some(item=>item.public_id===previous&&!modelIsUnavailable(item)))modelSelect.value=previous;
+ else{const firstAvailable=state.developerModels.find(item=>!modelIsUnavailable(item));if(firstAvailable)modelSelect.value=firstAvailable.public_id;}
+ const playgroundModel=$('#playground-model'); const playgroundPrevious=playgroundModel.value||modelSelect.value;
+ playgroundModel.innerHTML=modelSelect.innerHTML;
+ if(state.developerModels.some(item=>item.public_id===playgroundPrevious&&!modelIsUnavailable(item)))playgroundModel.value=playgroundPrevious;
+ if(state.playgroundKey)$('#playground-key').value=state.playgroundKey;
+ $('#playground-endpoint').textContent=inferenceBaseURL();
+ syncQuickstartProtocols(); syncPlaygroundProtocols(); renderQuickstartCode(); renderDeveloperAccess();
+}
+function renderDeveloperAccess() {
+ const form=$('#starter-key-form');const projects=state.projects.filter(item=>item.status==='active'&&(!state.actor.tenant_id||item.tenant_id===state.actor.tenant_id));const select=$('#starter-project');const previous=select.value;
+ select.innerHTML=projects.map(item=>`<option value="${esc(item.id)}">${esc(item.name)}</option>`).join('')||'<option value="">No active project</option>';
+ if(projects.some(item=>item.id===previous))select.value=previous;else{const preferred=projects.find(item=>item.slug==='default')||projects[0];if(preferred)select.value=preferred.id;}
+ const model=selectedDeveloperModel();$('#starter-model-label').textContent=model.public_id||'No model';
+ form.classList.toggle('hidden',!state.actor.tenant_id||!can('keys.write'));
+ $('#starter-key-submit').disabled=!select.value||!model.public_id||modelIsUnavailable(model);
+ const rows=[['OpenAI SDK base',`${inferenceBaseURL()}/v1`],['Anthropic SDK base',`${inferenceBaseURL()}/anthropic`],['Chat Completions',developerEndpoint('chat_completions')],['Responses',developerEndpoint('responses')],['Anthropic Messages',developerEndpoint('messages')],['Models',developerEndpoint('models')]];
+ $('#endpoint-list').innerHTML=rows.map(([label,value],index)=>`<div class="endpoint-row"><span>${esc(label)}</span><code>${esc(value)}</code><button class="text-button" type="button" data-copy-endpoint="${index}">Copy</button></div>`).join('');
+ $('#endpoint-list').dataset.values=JSON.stringify(rows.map(([,value])=>value));
+ $('#endpoint-env').textContent=`export AIGW_API_KEY="your-key"\nexport OPENAI_BASE_URL="${inferenceBaseURL()}/v1"\nexport ANTHROPIC_BASE_URL="${inferenceBaseURL()}/anthropic"`;
+}
+function renderPreferences() {
+ const preferences=state.preferences||{}; const modelOptions=state.developerModels.map(item=>`<option value="${esc(item.public_id)}">${esc(item.display_name||item.public_id)}</option>`).join('');
+ const defaultSelect=$('#preference-default-model'); const fallbackSelect=$('#preference-fallback-model');
+ defaultSelect.innerHTML=`<option value="">First available model</option>${modelOptions}`;
+ fallbackSelect.innerHTML=`<option value="">No workspace fallback</option>${modelOptions}`;
+ if(state.developerModels.some(item=>item.public_id===preferences.default_model))defaultSelect.value=preferences.default_model;
+ if(state.developerModels.some(item=>item.public_id===preferences.fallback_model))fallbackSelect.value=preferences.fallback_model;
+ const canWriteDeveloper=can('developer.preferences.write'); defaultSelect.disabled=!canWriteDeveloper; fallbackSelect.disabled=!canWriteDeveloper;
+ $('#low-balance-enabled').checked=preferences.low_balance_enabled!==false;
+ $('#low-balance-threshold').value=scaledToDecimal(preferences.low_balance_threshold_micros||0,6);
+ const canWriteBilling=can('billing.preferences.write'); $('#low-balance-enabled').disabled=!canWriteBilling; $('#low-balance-threshold').disabled=!canWriteBilling;
+ $('#balance-alert-status').textContent=state.authConfig.email_delivery_enabled?'Verified billing members receive at most one low-balance alert per day.':'The preference is saved now and activates when SMTP delivery is configured.';
+}
+function selectedDeveloperModel() { return state.developerModels.find(item=>item.public_id===$('#quickstart-model')?.value) || state.developerModels.find(item=>!modelIsUnavailable(item)) || state.developerModels[0] || {}; }
+function syncQuickstartProtocols() {
+ const model=selectedDeveloperModel(); const select=$('#quickstart-protocol'); const previous=select.value;
+ select.innerHTML=(model.supported_wire_apis||[]).map(apiName=>`<option value="${esc(apiName)}">${esc(apiName==='chat_completions'?'OpenAI Chat Completions':apiName==='responses'?'OpenAI Responses':apiName==='messages'?'Anthropic Messages':apiName)}</option>`).join('')||'<option value="chat_completions">OpenAI Chat Completions</option>';
+ if((model.supported_wire_apis||[]).includes(previous))select.value=previous;
+ syncProviderSelect('#quickstart-provider',model,select.value);
+}
+function selectedPlaygroundModel() { return state.developerModels.find(item=>item.public_id===$('#playground-model')?.value) || state.developerModels.find(item=>!modelIsUnavailable(item)) || state.developerModels[0] || {}; }
+function syncPlaygroundProtocols() {
+ const model=selectedPlaygroundModel(); const select=$('#playground-protocol'); const previous=select.value;
+ select.innerHTML=(model.supported_wire_apis||[]).map(apiName=>`<option value="${esc(apiName)}">${esc(apiName==='chat_completions'?'OpenAI Chat Completions':apiName==='responses'?'OpenAI Responses':apiName==='messages'?'Anthropic Messages':apiName)}</option>`).join('')||'<option value="chat_completions">OpenAI Chat Completions</option>';
+ if((model.supported_wire_apis||[]).includes(previous))select.value=previous;
+ syncProviderSelect('#playground-provider',model,select.value);
+ const max=Number(model.max_output_tokens||4096);$('#playground-max-output').max=String(max>0?max:4096);
+ if(Number($('#playground-max-output').value)>max&&max>0)$('#playground-max-output').value=String(max);
+}
+function syncProviderSelect(selector,model,wire) {
+ const select=$(selector);const previous=select.value;const providers=(model.providers||[]).filter(item=>item.wire_api===wire);
+ select.innerHTML=`<option value="">Automatic routing</option>${providers.map(item=>`<option value="${esc(item.slug)}" ${item.state==='open'?'disabled':''}>${esc(item.name||item.slug)} (${esc(item.slug)})${item.state==='open'?' — unavailable':''}</option>`).join('')}`;
+ if(providers.some(item=>item.slug===previous&&item.state!=='open'))select.value=previous;
+}
+function modelSelector(model,providerSelector) { const provider=$(providerSelector)?.value||'';const publicID=model.public_id||'model-id';return provider?`${publicID}:${provider}`:publicID; }
+function quickstartEndpoint(wire) { return wire==='messages'?developerEndpoint('messages'):wire==='responses'?developerEndpoint('responses'):developerEndpoint('chat_completions'); }
+function renderQuickstartCode() {
+ const model=selectedDeveloperModel(); const selectedModel=modelSelector(model,'#quickstart-provider'); const wire=$('#quickstart-protocol').value||'chat_completions'; const language=$('#quickstart-language').value||'curl'; const endpoint=quickstartEndpoint(wire); const key='$AIGW_API_KEY';
+ let code='';
+ if(language==='curl') {
+ const headers=wire==='messages'?`-H "x-api-key: ${key}"\n -H "anthropic-version: 2023-06-01"`:`-H "Authorization: Bearer ${key}"`;
+ const body=wire==='messages'?`{"model":"${selectedModel}","max_tokens":256,"messages":[{"role":"user","content":"Say hello in one sentence."}]}`:wire==='responses'?`{"model":"${selectedModel}","input":"Say hello in one sentence."}`:`{"model":"${selectedModel}","messages":[{"role":"user","content":"Say hello in one sentence."}],"stream":false}`;
+ code=`export AIGW_API_KEY="your-key"\ncurl ${endpoint} \\\n ${headers} \\\n -H "Content-Type: application/json" \\\n -d '${body}'`;
+ } else if(language==='python') {
+ code=wire==='messages'?`import os\nfrom anthropic import Anthropic\n\nclient = Anthropic(api_key=os.environ["AIGW_API_KEY"], base_url="${String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,'')}/anthropic")\nmessage = client.messages.create(model="${selectedModel}", max_tokens=256, messages=[{"role":"user", "content":"Say hello in one sentence."}])\nprint(message.content[0].text)`:wire==='responses'?`import os\nfrom openai import OpenAI\n\nclient = OpenAI(api_key=os.environ["AIGW_API_KEY"], base_url="${String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,'')}/v1")\nresponse = client.responses.create(model="${selectedModel}", input="Say hello in one sentence.")\nprint(response.output_text)`: `import os\nfrom openai import OpenAI\n\nclient = OpenAI(api_key=os.environ["AIGW_API_KEY"], base_url="${String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,'')}/v1")\nresponse = client.chat.completions.create(model="${selectedModel}", messages=[{"role":"user", "content":"Say hello in one sentence."}])\nprint(response.choices[0].message.content)`;
+ } else {
+ code=wire==='messages'?`import Anthropic from "@anthropic-ai/sdk";\n\nconst client = new Anthropic({ apiKey: process.env.AIGW_API_KEY, baseURL: "${String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,'')}/anthropic" });\nconst message = await client.messages.create({ model: "${selectedModel}", max_tokens: 256, messages: [{ role: "user", content: "Say hello in one sentence." }] });\nconsole.log(message.content[0].text);`: `import OpenAI from "openai";\n\nconst client = new OpenAI({ apiKey: process.env.AIGW_API_KEY, baseURL: "${String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,'')}/v1" });\nconst response = await client.${wire==='responses'?'responses.create({ model: "'+selectedModel+'", input: "Say hello in one sentence." })':'chat.completions.create({ model: "'+selectedModel+'", messages: [{ role: "user", content: "Say hello in one sentence." }] })'};\nconsole.log(${wire==='responses'?'response.output_text':'response.choices[0].message.content'});`;
+ }
+ $('#quickstart-code-block code').textContent=code;
+}
+function playgroundBody(wire,model,prompt,maxOutput) {
+ if(wire==='messages')return {model,max_tokens:maxOutput,messages:[{role:'user',content:prompt}]};
+ if(wire==='responses')return {model,input:prompt,max_output_tokens:maxOutput,stream:false};
+ return {model,messages:[{role:'user',content:prompt}],max_tokens:maxOutput,stream:false};
+}
+function playgroundTokenCount(payload) {
+ const usage=payload?.usage;if(!usage)return 0;
+ return Number(usage.total_tokens ?? (Number(usage.input_tokens||usage.prompt_tokens||0)+Number(usage.output_tokens||usage.completion_tokens||0)));
+}
+function playgroundDiagnosis(status,network=false) {
+ if(network)return {title:'Gateway unreachable',message:'Verify the inference public URL, TLS certificate, and exact admin Origin allowed by the gateway.',action:'Open API keys',target:'keys'};
+ if(status===400)return {title:'Request rejected',message:'Check the selected protocol, prompt, and output limit against the model details.',action:'Review model',target:'catalog'};
+ if(status===401||status===403)return {title:'API key not authorized',message:'Use an active inference key and check its expiry, scopes, model allowlist, and monthly spend cap.',action:'Open API keys',target:'keys'};
+ if(status===402)return {title:'Balance required',message:'Add funds to the prepaid wallet, then retry this request. Automatic top-up can prevent future interruptions.',action:'Add funds',target:'billing'};
+ if(status===404)return {title:'Model route unavailable',message:'The model may be unavailable to this key or may not support the selected protocol.',action:'Choose a model',target:'catalog'};
+ if(status===409)return {title:'Request conflict',message:'Retry with a new request after the in-flight billing reservation or account change completes.',action:'View usage',target:'usage'};
+ if(status===429)return {title:'Limit reached',message:'Review request, token, concurrency, project, and API key limits before retrying.',action:'Review limits',target:'limits'};
+ if(status>=500)return {title:'Provider unavailable',message:'The gateway could not complete the upstream request after routing and failover. Keep the request ID for support.',action:'Choose another model',target:'catalog'};
+ return {title:'Request failed',message:'Review the response body and request ID. The request was not accepted as successful.',action:'View usage',target:'usage'};
+}
+function hidePlaygroundDiagnostic(){const node=$('#playground-diagnostic');node.classList.add('hidden');$('#playground-diagnostic-action').classList.add('hidden');}
+function showPlaygroundDiagnostic(status,network=false) {
+ const diagnosis=playgroundDiagnosis(status,network);const action=$('#playground-diagnostic-action');
+ $('#playground-diagnostic-title').textContent=diagnosis.title;$('#playground-diagnostic-message').textContent=diagnosis.message;
+ action.textContent=diagnosis.action;action.dataset.target=diagnosis.target;action.classList.toggle('hidden',!$(`.tab[data-section="${diagnosis.target}"]`)||$(`.tab[data-section="${diagnosis.target}"]`).classList.contains('hidden'));
+ $('#playground-diagnostic').classList.remove('hidden');
+}
+async function runPlayground(event) {
+ event.preventDefault();
+ const key=$('#playground-key').value.trim();const model=modelSelector(selectedPlaygroundModel(),'#playground-provider');const wire=$('#playground-protocol').value;const prompt=$('#playground-prompt').value.trim();const maxOutput=Number($('#playground-max-output').value);
+ if(!key||!model||!prompt||!Number.isSafeInteger(maxOutput)||maxOutput<1)return toast('Complete the API request fields',true);
+ state.playgroundKey=key;
+ const endpoint=quickstartEndpoint(wire);const headers={'Content-Type':'application/json'};
+ if(wire==='messages'){headers['X-API-Key']=key;headers['Anthropic-Version']='2023-06-01';}else headers.Authorization=`Bearer ${key}`;
+ const result=$('#playground-result');const status=$('#playground-status');const send=$('#playground-send');const stop=$('#playground-stop');
+ result.classList.remove('hidden');hidePlaygroundDiagnostic();status.className='badge';status.textContent='Sending';$('#playground-request-id').textContent='';$('#playground-duration').textContent='';$('#playground-tokens').textContent='';$('#playground-response').textContent='';
+ send.disabled=true;stop.classList.remove('hidden');const controller=new AbortController();state.playgroundController=controller;const started=performance.now();
+ try {
+ const response=await fetch(endpoint,{method:'POST',mode:'cors',credentials:'omit',cache:'no-store',headers,body:JSON.stringify(playgroundBody(wire,model,prompt,maxOutput)),signal:controller.signal});
+ const raw=await response.text();let payload;try{payload=raw?JSON.parse(raw):{};}catch{payload=raw;}
+ const requestID=response.headers.get('X-AIGW-Request-ID')||payload?.error?.request_id||'';const duration=Math.round(performance.now()-started);const tokens=playgroundTokenCount(payload);
+ status.className=`badge ${response.ok?'active':'suspended'}`;status.textContent=`${response.status} ${response.ok?'OK':'Error'}`;
+ $('#playground-request-id').textContent=requestID?`Request ${requestID}`:'';$('#playground-duration').textContent=`${integer(duration)} ms`;$('#playground-tokens').textContent=tokens?`${integer(tokens)} tokens`:'';
+ $('#playground-response').textContent=typeof payload==='string'?payload:JSON.stringify(payload,null,2);
+ if(!response.ok){showPlaygroundDiagnostic(response.status);toast(payload?.error?.message||`Request failed (${response.status})`,true);}else{hidePlaygroundDiagnostic();toast('Request completed');setTimeout(()=>loadAll(),900);}
+ } catch(error) {
+ const stopped=error.name==='AbortError';status.className='badge suspended';status.textContent=stopped?'Stopped':'Network error';$('#playground-duration').textContent=`${integer(Math.round(performance.now()-started))} ms`;$('#playground-response').textContent=stopped?'Request cancelled.':error.message;if(stopped)hidePlaygroundDiagnostic();else showPlaygroundDiagnostic(0,true);toast(stopped?'Request stopped':error.message,true);
+ } finally {
+ if(state.playgroundController===controller)state.playgroundController=null;send.disabled=false;stop.classList.add('hidden');
+ }
+}
+function renderCatalog() {
+ const ownerSelect=$('#catalog-owner');const ownerValue=ownerSelect?.value||'';const owners=[...new Set(state.developerModels.map(item=>String(item.owned_by||'').trim()).filter(Boolean))].sort((a,b)=>a.localeCompare(b));
+ if(ownerSelect){ownerSelect.innerHTML=`<option value="">All developers</option>${owners.map(owner=>`<option value="${esc(owner)}">${esc(owner)}</option>`).join('')}`;if(owners.includes(ownerValue))ownerSelect.value=ownerValue;}
+ const search=String($('#catalog-search')?.value||'').trim().toLowerCase(); const protocol=$('#catalog-protocol')?.value||''; const input=$('#catalog-input')?.value||'';const owner=ownerSelect?.value||'';const sort=$('#catalog-sort')?.value||'newest';
+ const models=state.developerModels.filter(item=>{const hay=[item.public_id,item.display_name,item.owned_by,item.description,...(item.capabilities||[])].join(' ').toLowerCase();return (!search||hay.includes(search))&&(!protocol||(item.supported_wire_apis||[]).includes(protocol))&&(!input||(item.input_modalities||[]).includes(input))&&(!owner||item.owned_by===owner);});
+ const compareText=(a,b)=>String(a.display_name||a.public_id).localeCompare(String(b.display_name||b.public_id));
+ models.sort((a,b)=>sort==='name'?compareText(a,b):sort==='input_price'?Number(a.input_price_micros_per_million||0)-Number(b.input_price_micros_per_million||0)||compareText(a,b):sort==='output_price'?Number(a.output_price_micros_per_million||0)-Number(b.output_price_micros_per_million||0)||compareText(a,b):sort==='context'?Number(b.context_window||0)-Number(a.context_window||0)||compareText(a,b):new Date(b.released_at||0)-new Date(a.released_at||0)||compareText(a,b));
+ $('#catalog-count').textContent=`${models.length} of ${state.developerModels.length} models`;
+ $('#catalog-grid').innerHTML=models.map(item=>{const health=providerHealthSummary(item);return `<article class="catalog-card"><div><span class="eyebrow">${esc(item.owned_by||'MODEL')}</span><h2>${esc(item.display_name||item.public_id)}</h2><div class="model-id">${esc(item.public_id)}</div></div><div class="provider-summary"><span class="badge ${health.status==='online'?'active':health.status}">${esc(health.label)}</span><small>${esc(health.detail)}</small></div><p>${esc(item.description||'No description provided.')}</p><div class="catalog-meta">${(item.supported_wire_apis||[]).map(apiName=>`<span class="tag">${esc(apiName)}</span>`).join('')}${(item.input_modalities||[]).map(modality=>`<span class="tag">${esc(modality)} input</span>`).join('')}</div><div class="catalog-price">${money(item.input_price_micros_per_million,item.price_currency)} in · ${money(item.output_price_micros_per_million,item.price_currency)} out / 1M tokens</div><small class="muted">${integer(item.context_window)} context · ${integer(item.max_output_tokens)} max output</small><div class="catalog-actions"><button type="button" class="button subtle" data-model-details="${esc(item.public_id)}">Details</button><button type="button" class="button secondary" data-use-model="${esc(item.public_id)}" ${modelIsUnavailable(item)?'disabled':''}>Use this model</button></div></article>`;}).join('')||'<div class="panel empty">No models match these filters.</div>';
+}
+function modelProtocolLabel(value) { return value==='chat_completions'?'OpenAI Chat Completions':value==='responses'?'OpenAI Responses':value==='messages'?'Anthropic Messages':value; }
+function showModelDetails(publicID) {
+ const model=state.developerModels.find(item=>item.public_id===publicID);if(!model)return;
+ state.detailModel=model;$('#model-dialog-title').textContent=model.display_name||model.public_id;$('#model-dialog-id').textContent=model.public_id;
+ const lifecycle=model.lifecycle||'active';const status=lifecycle==='retired'?'retired':lifecycle==='deprecated'?'deprecated':lifecycle==='preview'?'preview':'available';
+ const health=providerHealthSummary(model);const rows=[['Status',status],['Runtime',health.label],['Providers',health.detail],['Owner',model.owned_by||'—'],['Protocols',(model.supported_wire_apis||[]).map(modelProtocolLabel).join(', ')||'—'],['Input',(model.input_modalities||[]).join(', ')||'—'],['Output',(model.output_modalities||[]).join(', ')||'—'],['Context',`${integer(model.context_window)} tokens`],['Max output',`${integer(model.max_output_tokens)} tokens`],['Released',date(model.released_at)],['Capabilities',(model.capabilities||[]).join(', ')||'—'],['Aliases',(model.aliases||[]).join(', ')||'—']];
+ $('#model-detail-grid').innerHTML=rows.map(([label,value])=>`<div class="model-detail-row"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
+ const providers=model.providers||[];$('#model-provider-health').classList.toggle('hidden',providers.length===0);$('#model-provider-health-body').innerHTML=providers.map(item=>{const measured=Number(item.recent_samples||0)>0;const availability=measured?`${Number(item.availability_percent||0).toFixed(1)}%`:'Not measured';const latency=Number(item.header_latency_ewma_ms||0)>0?`${integer(item.header_latency_ewma_ms)} ms`:'Not measured';const retry=item.circuit_open_until?`Retry ${date(item.circuit_open_until)}`:'';return `<tr><td><strong>${esc(item.name||item.slug)}</strong><small class="price-line">${esc(item.slug)} · ${esc(modelProtocolLabel(item.wire_api)||item.protocol||'')}</small></td><td><span class="badge ${item.state==='healthy'?'active':item.state==='open'?'suspended':item.state==='degraded'?'degraded':''}">${esc(item.state==='open'?'Circuit open':item.state)}</span>${retry?`<small class="provider-retry">${esc(retry)}</small>`:''}</td><td>${esc(availability)}</td><td>${esc(latency)}</td><td>${integer(item.attempts||0)}</td></tr>`;}).join('');
+ $('#model-dialog-playground').disabled=modelIsUnavailable(model);$('#estimate-input').value='1000';$('#estimate-output').value='500';$('#estimate-cache-read').value='0';$('#estimate-cache-write').value='0';renderModelEstimate();$('#model-dialog').showModal();
+}
+function renderModelEstimate() {
+ const model=state.detailModel;if(!model)return;const input=Math.max(0,Number($('#estimate-input').value||0));const output=Math.max(0,Number($('#estimate-output').value||0));const cacheRead=Math.max(0,Number($('#estimate-cache-read').value||0));const cacheWrite=Math.max(0,Number($('#estimate-cache-write').value||0));const micros=(input*Number(model.input_price_micros_per_million||0)+output*Number(model.output_price_micros_per_million||0)+cacheRead*Number(model.cache_read_price_micros_per_million||0)+cacheWrite*Number(model.cache_write_price_micros_per_million||0))/1_000_000;$('#model-estimate').textContent=`Estimated cost ${money(Math.round(micros),model.price_currency||state.overview.billing_currency||'usd')}`;
+}
+function useModelInPlayground(publicID) { const model=state.developerModels.find(item=>item.public_id===publicID);if(!model||modelIsUnavailable(model)){toast('This model has no provider currently accepting traffic',true);return;}$('#quickstart-model').value=publicID;$('#playground-model').value=publicID;syncQuickstartProtocols();syncPlaygroundProtocols();$('#quickstart-provider').value='';$('#playground-provider').value='';renderQuickstartCode();if($('#model-dialog').open)$('#model-dialog').close();goTo('quickstart'); }
function renderTenants() {
$('#tenants-body').innerHTML = state.tenants.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${esc(item.slug)}</code></td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${date(item.created_at)}</td></tr>`).join('') || emptyRow(4);
['project-tenant','key-tenant','topup-tenant','adjustment-tenant','user-tenant'].forEach(id => { const node=$(`#${id}`); if (node) node.innerHTML=selectOptions(state.tenants,'id','name', state.actor.tenant_id ? 'Current tenant' : 'Select tenant…'); });
if (state.actor.tenant_id) ['project-tenant','key-tenant','topup-tenant','adjustment-tenant','user-tenant'].forEach(id => { const node=$(`#${id}`); if (node) node.value=state.actor.tenant_id; });
}
function renderProjects() { $('#projects-body').innerHTML = state.projects.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${shortID(item.tenant_id)}</code></td><td>${esc(item.slug)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td></tr>`).join('') || emptyRow(4); renderKeyProjects(); }
-function renderKeyProjects() { const tenant = $('#key-tenant').value; const projects = state.projects.filter(item => !tenant || item.tenant_id === tenant); $('#key-project').innerHTML = selectOptions(projects,'id','name'); }
-function renderKeys() { $('#keys-body').innerHTML = state.keys.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><code>${esc(item.key_prefix)}</code></td><td><code>${shortID(item.project_id)}</code></td><td>${(item.scopes||[]).map(scope=>`<span class="tag">${esc(scope)}</span>`).join('')}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status==='active'&&can('keys.write')?`<button class="text-button danger" data-revoke-key="${esc(item.id)}">Revoke</button>`:''}</td></tr>`).join('') || emptyRow(6); }
-function renderProviders() { $('#providers-body').innerHTML = state.providers.map(item => `<tr><td><strong>${esc(item.name)}</strong></td><td><span class="tag">${esc(item.protocol)}</span></td><td class="truncate">${esc(item.base_url)}</td><td>${integer(item.route_count)}</td><td><span class="badge ${item.enabled?'active':'suspended'}">${item.enabled?'enabled':'disabled'}</span></td><td>${can('platform.write')?`<button class="text-button" data-toggle-provider="${esc(item.id)}" data-enabled="${!item.enabled}">${item.enabled?'Disable':'Enable'}</button>`:''}</td></tr>`).join('') || emptyRow(6); }
+function renderKeyProjects() { const tenant=$('#key-tenant').value;const node=$('#key-project');const selected=node.value;const projects=state.projects.filter(item=>!tenant||item.tenant_id===tenant);node.innerHTML=selectOptions(projects,'id','name');if(projects.some(item=>item.id===selected))node.value=selected;else if(projects.length===1)node.value=projects[0].id; }
+function renderKeys() {
+ const picker=$('#key-models');const selected=new Set([...picker.selectedOptions].map(option=>option.value));picker.innerHTML=state.developerModels.map(item=>`<option value="${esc(item.public_id)}">${esc(item.display_name||item.public_id)} (${esc(item.public_id)})</option>`).join('');[...picker.options].forEach(option=>{option.selected=selected.has(option.value);});
+ $('#keys-body').innerHTML = state.keys.map(item => {
+ const models=item.allowed_models||[];const expires=item.expires_at?date(item.expires_at):'Never';const tags=(item.tags||[]).map(tag=>`<span class="tag">${esc(tag)}</span>`).join('');
+ const spent=Number(item.current_month_spend_micros||0);const reserved=Number(item.current_month_reserved_micros||0);const cap=Number(item.monthly_spend_micros||0);const remaining=Math.max(0,cap-spent-reserved);
+ const effectiveStatus=item.status==='active'&&item.expires_at&&new Date(item.expires_at)<=new Date()?'expired':item.status;
+ return `<tr><td><strong>${esc(item.name)}</strong>${tags?`<small class="key-tags">${tags}</small>`:''}</td><td><code>${esc(item.key_prefix)}</code></td><td><code>${shortID(item.project_id)}</code></td><td>${(item.scopes||[]).map(scope=>`<span class="tag">${esc(scope)}</span>`).join('')}<small class="price-line">${models.length?`${integer(models.length)} selected model${models.length===1?'':'s'}`:'All visible models'}</small></td><td><strong>${money(spent)}</strong><small class="price-line">${integer(item.current_month_requests)} requests · ${money(reserved)} reserved</small></td><td>${cap?money(cap):'Unlimited'}<small class="price-line">${cap?`${money(remaining)} remaining`:'No key-level cap'} · Expires: ${esc(expires)}</small></td><td><span class="badge ${effectiveStatus}">${esc(effectiveStatus)}</span><small class="price-line">Last used: ${esc(date(item.last_used_at))}</small></td><td>${item.status==='active'&&can('keys.write')?`<button class="text-button danger" data-revoke-key="${esc(item.id)}">Revoke</button>`:''}</td></tr>`;
+ }).join('') || emptyRow(8);
+}
+function renderProviders() { $('#providers-body').innerHTML = state.providers.map(item => `<tr><td><strong>${esc(item.name)}</strong><small class="price-line"><code>${esc(item.slug)}</code></small></td><td><span class="tag">${esc(item.protocol)}</span><small class="price-line">${esc(item.wire_api)}</small></td><td class="truncate">${esc(item.base_url)}</td><td>${integer(item.route_count)}</td><td><span class="badge ${item.enabled?'active':'suspended'}">${item.enabled?'enabled':'disabled'}</span></td><td>${can('platform.write')?`<button class="text-button" data-toggle-provider="${esc(item.id)}" data-enabled="${!item.enabled}">${item.enabled?'Disable':'Enable'}</button>`:''}</td></tr>`).join('') || emptyRow(6); }
function renderModels() { $('#models-body').innerHTML = state.models.map(item => `<tr><td><strong>${esc(item.public_id)}</strong><small class="price-line">${esc(item.display_name||'')} · ${integer(item.context_window)} ctx · ${esc((item.input_modalities||[]).join('+'))} → ${esc((item.output_modalities||[]).join('+'))}</small></td><td>${esc(item.owned_by||'—')}<small class="price-line">v${item.price_version||1} ${esc(item.price_currency||'usd')} · in ${money(item.input_price_micros_per_million,item.price_currency)}/1M · out ${money(item.output_price_micros_per_million,item.price_currency)}/1M</small></td><td><div class="route-list">${(item.routes||[]).map(route=>`<span>${esc(route.provider_name||route.provider_id).slice(0,24)} → ${esc(route.upstream_model)} <em>p${route.priority} / w${route.weight}</em></span>`).join('')}</div></td><td><span class="badge ${item.enabled&&item.lifecycle!=='retired'?'active':'suspended'}">${esc(item.lifecycle||'active')}</span></td><td>${can('platform.write')?`<button class="text-button" data-toggle-model="${esc(item.id)}" data-enabled="${!item.enabled}">${item.enabled?'Disable':'Enable'}</button>`:''}</td></tr>`).join('') || emptyRow(5); }
function renderBilling() {
$('#billing-currency').textContent=(state.overview.billing_currency||'').toUpperCase();
$('#billing-accounts-body').innerHTML=state.billingAccounts.map(item=>`<tr><td><strong>${esc(item.tenant_name)}</strong><br><code>${shortID(item.tenant_id)}</code></td><td>${money(item.balance_micros,item.currency)}</td><td>${money(item.reserved_micros,item.currency)}</td><td><strong>${money(item.available_micros,item.currency)}</strong></td><td>${date(item.updated_at)}</td></tr>`).join('')||emptyRow(5);
$('#billing-ledger-body').innerHTML=state.ledger.map(item=>`<tr><td>${date(item.created_at)}</td><td><code>${shortID(item.tenant_id)}</code></td><td><span class="tag">${esc(item.kind)}</span></td><td class="${item.amount_micros>=0?'money-positive':'money-negative'}">${money(item.amount_micros,item.currency)}</td><td>${money(item.balance_after_micros,item.currency)}</td><td title="${esc(item.description)}"><code>${shortID(item.source_id)}</code></td></tr>`).join('')||emptyRow(6);
- const orders=state.orders||[];$('#billing-orders-body').innerHTML=orders.map(item=>`<tr><td>${date(item.created_at)}</td><td>${money(item.amount_micros,item.currency)}</td><td><span class="badge ${item.status==='paid'?'active':'suspended'}">${esc(item.status)}</span></td><td title="${esc(item.reconciliation_error||'')}"><span class="badge ${['ok','repaired','resolved'].includes(item.reconciliation_status)?'active':'suspended'}">${esc(item.reconciliation_status||'unknown')}</span></td><td>${item.invoice_url?`<a href="${esc(item.invoice_url)}" target="_blank" rel="noopener">Invoice</a>`:''} ${item.invoice_pdf_url?`<a href="${esc(item.invoice_pdf_url)}" target="_blank" rel="noopener">PDF</a>`:''} ${item.receipt_url?`<a href="${esc(item.receipt_url)}" target="_blank" rel="noopener">Receipt</a>`:''}</td><td>${['failed','expired'].includes(item.status)?`<button class="text-button" data-retry-order="${esc(item.id)}">Retry</button>`:''}${can('billing.adjust')&&item.status==='pending'&&item.reconciliation_status==='missing'?` <button class="text-button danger" data-resolve-order="${esc(item.id)}" data-order-tenant="${esc(item.tenant_id)}">Resolve</button>`:''}${can('billing.adjust')&&item.status==='paid'&&item.reconciliation_status==='missing'&&!item.stripe_payment_intent_id?` <button class="text-button danger" data-reverse-order="${esc(item.id)}" data-order-tenant="${esc(item.tenant_id)}">Reverse</button>`:''}${can('billing.adjust')&&['paid','partially_refunded'].includes(item.status)?` <button class="text-button danger" data-refund-order="${esc(item.id)}" data-order-amount="${item.amount_minor}">Refund</button>`:''}</td></tr>`).join('')||emptyRow(6);
+ const orders=state.orders||[];$('#billing-orders-body').innerHTML=orders.map(item=>`<tr><td>${date(item.created_at)}${item.trigger_type==='auto'?'<small class="price-line">automatic</small>':''}</td><td>${money(item.amount_micros,item.currency)}</td><td><span class="badge ${item.status==='paid'?'active':'suspended'}">${esc(item.status)}</span></td><td title="${esc(item.reconciliation_error||'')}"><span class="badge ${['ok','repaired','resolved'].includes(item.reconciliation_status)?'active':'suspended'}">${esc(item.reconciliation_status||'unknown')}</span></td><td>${item.invoice_url?`<a href="${esc(item.invoice_url)}" target="_blank" rel="noopener">Invoice</a>`:''} ${item.invoice_pdf_url?`<a href="${esc(item.invoice_pdf_url)}" target="_blank" rel="noopener">PDF</a>`:''} ${item.receipt_url?`<a href="${esc(item.receipt_url)}" target="_blank" rel="noopener">Receipt</a>`:''}</td><td>${item.trigger_type!=='auto'&&['failed','expired'].includes(item.status)?`<button class="text-button" data-retry-order="${esc(item.id)}">Retry</button>`:''}${can('billing.adjust')&&item.status==='pending'&&item.reconciliation_status==='missing'?` <button class="text-button danger" data-resolve-order="${esc(item.id)}" data-order-tenant="${esc(item.tenant_id)}">Resolve</button>`:''}${can('billing.adjust')&&item.status==='paid'&&item.reconciliation_status==='missing'&&!item.stripe_payment_intent_id?` <button class="text-button danger" data-reverse-order="${esc(item.id)}" data-order-tenant="${esc(item.tenant_id)}">Reverse</button>`:''}${can('billing.adjust')&&['paid','partially_refunded'].includes(item.status)?` <button class="text-button danger" data-refund-order="${esc(item.id)}" data-order-amount="${item.amount_minor}">Refund</button>`:''}</td></tr>`).join('')||emptyRow(6);
$('#refunds-body').innerHTML=(state.refunds||[]).map(item=>`<tr><td>${date(item.created_at)}</td><td><code>${shortID(item.topup_order_id)}</code></td><td>${money(item.amount_micros,item.currency)}</td><td><span class="badge ${item.status==='succeeded'?'active':'suspended'}">${esc(item.status)}</span></td><td>${esc(item.last_error||'—')}</td></tr>`).join('')||emptyRow(5);
$('#disputes-body').innerHTML=(state.disputes||[]).map(item=>`<tr><td>${date(item.updated_at)}</td><td>${money(item.amount_micros,item.currency)}</td><td><span class="badge ${item.status==='won'?'active':'suspended'}">${esc(item.status)}</span></td><td>${esc(item.reason)}</td><td>${date(item.due_by)}</td></tr>`).join('')||emptyRow(5);
+ renderBillingProfile();
+ renderAutoTopUp();
+}
+function renderBillingProfile(){
+ if(!state.actor.tenant_id)return;const item=state.billingProfile||{};const form=$('#billing-profile-form');
+ for(const name of ['legal_name','billing_email','address_line1','address_line2','city','region','postal_code','country'])form.elements[name].value=item[name]||'';
+ const labels={not_configured:'Not configured',checkout_managed:'Stripe managed',disabled:'Saved locally',pending:'Syncing',synced:'Stripe synced',failed:'Sync needed'};const status=$('#billing-profile-status');const label=labels[item.stripe_sync_status]||'Not configured';status.textContent=label;status.className=`badge ${item.stripe_sync_status==='synced'?'active':item.stripe_sync_status==='failed'?'suspended':''}`;
+ $('#billing-profile-error').textContent=item.stripe_sync_error||'';form.querySelector('button[type=submit]').disabled=!can('billing.topup');
+ $('#billing-portal').disabled=!item.stripe_customer_configured;
+}
+function renderAutoTopUp(){
+ const item=state.autoTopUp||{};if(!state.actor.tenant_id)return;
+ const currency=item.currency||state.overview.billing_currency||'usd';const digits=currencyDigits(currency);
+ $('#auto-topup-enabled').checked=Boolean(item.enabled);$('#auto-topup-threshold').value=scaledToDecimal(item.threshold_micros||0,6);$('#auto-topup-amount').value=scaledToDecimal(item.topup_amount_minor||0,digits);
+ const status=$('#auto-topup-status');status.textContent=String(item.status||'not_configured').replaceAll('_',' ');status.className=`badge ${item.enabled&&item.status==='ready'?'active':item.status==='action_required'||item.status==='failed'?'suspended':''}`;
+ $('#auto-topup-payment-method').textContent=item.payment_method_configured?`${item.payment_method_brand||item.payment_method_type||'payment method'} •••• ${item.payment_method_last4||''}${item.payment_method_exp_month?` · ${String(item.payment_method_exp_month).padStart(2,'0')}/${item.payment_method_exp_year}`:''}`:'Not saved';
+ $('#auto-topup-error').textContent=item.last_error||'';
+ const writable=can('billing.topup');$$('#auto-topup-form input').forEach(node=>node.disabled=!writable);$('#auto-topup-form button[type=submit]').disabled=!writable;$('#auto-topup-payment-setup').disabled=!writable||!item.stripe_enabled;
+ $('#auto-topup-payment-setup').textContent=item.payment_method_configured?'Replace payment method':'Save payment method';
}
function renderSecurity() {
const totp=Boolean(state.mfa?.totp_enabled);
@@ -160,8 +379,30 @@ function renderSecurity() {
$('#orders-body').innerHTML=(state.orders||[]).map(item=>`<tr><td>${date(item.created_at)}</td><td>${money(item.amount_micros,item.currency)}</td><td><span class="badge ${item.status==='paid'?'active':'suspended'}">${esc(item.status)}</span></td><td><code>${shortID(item.id)}</code></td></tr>`).join('')||emptyRow(4);
}
function renderUsage() {
+ const project=$('#usage-project'),key=$('#usage-key'),model=$('#usage-model'),provider=$('#usage-provider');const projectValue=project.value,keyValue=key.value,modelValue=model.value,providerValue=provider.value;
+ const providers=new Map();state.developerModels.forEach(item=>(item.providers||[]).forEach(route=>providers.set(route.slug,route.name||route.slug)));
+ project.innerHTML=`<option value="">All projects</option>${state.projects.map(item=>`<option value="${esc(item.id)}">${esc(item.name)}</option>`).join('')}`;key.innerHTML=`<option value="">All API keys</option>${state.keys.map(item=>`<option value="${esc(item.id)}">${esc(item.name)} (${esc(item.key_prefix)})</option>`).join('')}`;model.innerHTML=`<option value="">All models</option>${state.developerModels.map(item=>`<option value="${esc(item.public_id)}">${esc(item.display_name||item.public_id)}</option>`).join('')}`;provider.innerHTML=`<option value="">All providers</option>${[...providers].sort((a,b)=>a[1].localeCompare(b[1])).map(([slug,name])=>`<option value="${esc(slug)}">${esc(name)} (${esc(slug)})</option>`).join('')}`;
+ if(projectValue)project.value=projectValue;if(keyValue)key.value=keyValue;if(modelValue)model.value=modelValue;if(providerValue)provider.value=providerValue;
+ if(!$('#usage-from').value){const query=new URLSearchParams(defaultUsageQuery());$('#usage-from').value=query.get('from');$('#usage-to').value=query.get('to');}
+ const points=state.usageDaily||[];const totals=points.reduce((acc,item)=>{acc.requests+=Number(item.request_count||0);acc.success+=Number(item.successful_requests||0);acc.tokens+=Number(item.total_tokens||0);acc.charged+=Number(item.charged_micros||0);acc.duration+=Number(item.average_duration_ms||0)*Number(item.request_count||0);acc.p95=Math.max(acc.p95,Number(item.p95_duration_ms||0));return acc;},{requests:0,success:0,tokens:0,charged:0,duration:0,p95:0});
+ const metrics=[['Requests',integer(totals.requests),'selected range'],['Success rate',percent(totals.success,totals.requests),'completed requests'],['Tokens',integer(totals.tokens),'input and output'],['Charged',money(totals.charged),'wallet debit'],['Average latency',totals.requests?`${integer(Math.round(totals.duration/totals.requests))} ms`:'—','request weighted'],['P95 latency',totals.p95?`${integer(totals.p95)} ms`:'—','highest daily P95']];$('#usage-metrics').innerHTML=metrics.map(([label,value,sub])=>`<article class="metric"><span>${label}</span><strong>${esc(value)}</strong><small>${esc(sub)}</small></article>`).join('');
+ const maxRequests=Math.max(1,...points.map(item=>Number(item.request_count||0)));$('#usage-chart').innerHTML=points.length?`<div class="chart-bars">${points.map(item=>`<div class="chart-day" title="${esc(new Date(item.day).toLocaleDateString())}: ${integer(item.request_count)} requests, ${percent(item.successful_requests,item.request_count)} success, ${money(item.charged_micros)} charged"><div class="chart-bar"><span class="${chartHeightClass(item.request_count,maxRequests)}"></span></div><small>${new Date(item.day).toLocaleDateString(undefined,{month:'short',day:'numeric'})}</small></div>`).join('')}</div>`:'<div class="empty">No usage in this range</div>';
$('#usage-summary-body').innerHTML=state.usageSummary.map(item=>`<tr><td>${new Date(item.period_start).toLocaleDateString(undefined,{year:'numeric',month:'short'})}</td><td><strong>${esc(item.project_name)}</strong></td><td>${integer(item.request_count)}</td><td>${percent(item.successful_requests,item.request_count)}</td><td>${integer(item.input_tokens)}</td><td>${integer(item.output_tokens)}</td><td>${money(item.cost_micros)}</td></tr>`).join('')||emptyRow(7);
- $('#usage-events-body').innerHTML=state.usage.map(item=>`<tr><td>${date(item.started_at)}</td><td><code title="${esc(item.request_id)}">${shortID(item.request_id)}</code></td><td>${esc(item.public_model)}</td><td><span class="badge ${item.success?'active':'suspended'}">${item.status_code}</span>${item.error_type?`<small class="error-label">${esc(item.error_type)}</small>`:''}</td><td>${integer(item.total_tokens)}</td><td>${money(item.cost_micros)}</td><td>${integer(item.duration_ms)} ms</td></tr>`).join('')||emptyRow(7);
+ const analytics=Array.isArray(state.usageAnalytics)?{models:[],providers:[]}:state.usageAnalytics||{models:[],providers:[]};
+ const changeLabel=item=>item.charge_change_percent==null?(Number(item.previous_charged_micros||0)===0&&Number(item.charged_micros||0)>0?'New':'—'):`${Number(item.charge_change_percent)>=0?'+':''}${Number(item.charge_change_percent).toFixed(1)}%`;
+ const changeClass=item=>item.charge_change_percent==null?(Number(item.charged_micros||0)>0?'positive':''):Number(item.charge_change_percent)>0?'money-negative':'positive';
+ const cacheRate=item=>{const denominator=Number(item.input_tokens||0)+Number(item.cache_read_input_tokens||0)+Number(item.cache_creation_input_tokens||0);return denominator?percent(item.cache_read_input_tokens,denominator):'—';};
+ $('#usage-model-analytics-body').innerHTML=(analytics.models||[]).map(item=>`<tr><td><strong>${esc(item.public_model)}</strong><small class="price-line">${integer(item.provider_count)} provider${Number(item.provider_count)===1?'':'s'}</small></td><td>${integer(item.request_count)}</td><td>${percent(item.successful_requests,item.request_count)}</td><td>${integer(item.total_tokens)}</td><td>${money(item.charged_micros)}</td><td class="${changeClass(item)}">${changeLabel(item)}</td><td>${integer(item.p95_duration_ms)} ms</td><td>${integer(item.missing_usage_requests)}</td></tr>`).join('')||emptyRow(8);
+ $('#usage-provider-analytics-body').innerHTML=(analytics.providers||[]).map(item=>`<tr><td><strong>${esc(item.provider_name)}</strong><small class="price-line">${esc(item.wire_api||'unknown')}</small></td><td>${integer(item.request_count)}</td><td>${percent(item.successful_requests,item.request_count)}</td><td>${integer(item.model_count)}</td><td>${cacheRate(item)}</td><td>${money(item.charged_micros)}</td><td class="${changeClass(item)}">${changeLabel(item)}</td><td>${integer(item.p95_duration_ms)} ms</td></tr>`).join('')||emptyRow(8);
+ $('#usage-events-body').innerHTML=state.usage.map((item,index)=>`<tr><td>${date(item.started_at)}</td><td><code title="${esc(item.request_id)}">${shortID(item.request_id)}</code><small class="price-line">${esc(item.protocol)}${item.attempts>1?` · ${item.attempts} attempts`:''}</small></td><td>${esc(item.project_name||shortID(item.project_id))}<small class="price-line">${esc(item.key_name||shortID(item.key_id))}</small></td><td>${esc(item.public_model)}<small class="price-line">${esc(item.provider_name||'—')}</small></td><td><span class="badge ${item.success?'active':'suspended'}">${item.status_code}</span>${item.error_type?`<small class="error-label">${esc(item.error_type)}</small>`:''}<small class="price-line">${esc(item.metering_status||'')}</small></td><td>${integer(item.total_tokens)}</td><td>${money(item.charged_micros)}${item.uncollected_micros?`<small class="error-label">${money(item.uncollected_micros)} uncollected</small>`:''}</td><td>${integer(item.duration_ms)} ms</td><td><button class="text-button" type="button" data-usage-details="${index}">Details</button></td></tr>`).join('')||emptyRow(9);
+}
+function usageDiagnostic(item) {
+ return {request_id:item.request_id,started_at:item.started_at,status_code:item.status_code,success:Boolean(item.success),error_type:item.error_type||'',project:{id:item.project_id,name:item.project_name||''},api_key:{id:item.key_id,name:item.key_name||''},model:{public_id:item.public_model,provider:item.provider_name||item.provider_id||'',upstream_id:item.upstream_model||''},transport:{protocol:item.protocol,stream:Boolean(item.stream),attempts:Number(item.attempts||0),duration_ms:Number(item.duration_ms||0)},usage:{input_tokens:Number(item.input_tokens||0),output_tokens:Number(item.output_tokens||0),total_tokens:Number(item.total_tokens||0),cache_read_input_tokens:Number(item.cache_read_input_tokens||0),cache_creation_input_tokens:Number(item.cache_creation_input_tokens||0),reported:Boolean(item.usage_reported)},billing:{cost_micros:Number(item.cost_micros||0),charged_micros:Number(item.charged_micros||0),uncollected_micros:Number(item.uncollected_micros||0),metering_status:item.metering_status||''}};
+}
+function showUsageDetails(index) {
+ const item=state.usage[Number(index)];if(!item)return;state.detailUsage=item;$('#usage-dialog-title').textContent=item.public_model||'Request details';$('#usage-dialog-id').textContent=item.request_id;
+ const throughput=item.duration_ms>0&&item.output_tokens>0?`${(Number(item.output_tokens)*1000/Number(item.duration_ms)).toFixed(1)} output tokens/s`:'—';const rows=[['Time',date(item.started_at)],['Status',`${item.status_code} · ${item.success?'success':'error'}`],['Project',item.project_name||shortID(item.project_id)],['API key',item.key_name||shortID(item.key_id)],['Protocol',`${item.protocol}${item.stream?' · stream':''}`],['Public model',item.public_model],['Provider',item.provider_name||item.provider_id||'—'],['Upstream model',item.upstream_model||'—'],['Attempts',integer(item.attempts)],['Latency',`${integer(item.duration_ms)} ms`],['Throughput',throughput],['Tokens',`${integer(item.input_tokens)} in · ${integer(item.output_tokens)} out`],['Cache',`${integer(item.cache_read_input_tokens)} read · ${integer(item.cache_creation_input_tokens)} write`],['Charged',money(item.charged_micros)],['Metering',item.metering_status||'—'],['Error',item.error_type||'—']];
+ $('#usage-detail-grid').innerHTML=rows.map(([label,value])=>`<div class="model-detail-row"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');$('#usage-diagnostic').textContent=JSON.stringify(usageDiagnostic(item),null,2);$('#usage-dialog').showModal();
}
function renderLimits() {
const existing=new Map(state.limits.map(item=>[item.project_id,item]));
@@ -179,6 +420,12 @@ function addRoute() { const wrapper=document.createElement('div');wrapper.classN
document.addEventListener('click',async(event)=>{
const authTab=event.target.closest('.auth-tab');if(authTab){$$('.auth-tab').forEach(node=>node.classList.toggle('active',node===authTab));$$('.auth-pane').forEach(node=>node.classList.toggle('active',node.id===authTab.dataset.authPane));authError();return;}
const tab=event.target.closest('.tab');if(tab){$$('.tab').forEach(node=>node.classList.toggle('active',node===tab));$$('.section').forEach(node=>node.classList.toggle('active',node.id===tab.dataset.section));return;}
+ const goto=event.target.closest('[data-goto]');if(goto){goTo(goto.dataset.goto);return;}
+ const scroll=event.target.closest('[data-scroll-to]');if(scroll){document.getElementById(scroll.dataset.scrollTo)?.scrollIntoView({behavior:'smooth',block:'start'});return;}
+ const endpointCopy=event.target.closest('[data-copy-endpoint]');if(endpointCopy){try{const values=JSON.parse($('#endpoint-list').dataset.values||'[]');await navigator.clipboard.writeText(values[Number(endpointCopy.dataset.copyEndpoint)]||'');toast('Endpoint copied');}catch(error){toast('Copy failed',true);}return;}
+ const details=event.target.closest('[data-model-details]');if(details){showModelDetails(details.dataset.modelDetails);return;}
+ const useModel=event.target.closest('[data-use-model]');if(useModel){useModelInPlayground(useModel.dataset.useModel);return;}
+ const usageDetails=event.target.closest('[data-usage-details]');if(usageDetails){showUsageDetails(usageDetails.dataset.usageDetails);return;}
if(event.target.id==='reload'){try{await api('/reload',{method:'POST',body:'{}'});await loadAll();toast('Snapshot reloaded');}catch(error){toast(error.message,true);}}
if(event.target.id==='add-route')addRoute();if(event.target.closest('.remove-route'))event.target.closest('.route-row').remove();
const revokeKey=event.target.closest('[data-revoke-key]');if(revokeKey&&confirm('Revoke this API key?')){try{await api(`/keys/${revokeKey.dataset.revokeKey}/revoke`,{method:'POST',body:'{}'});await loadAll();toast('API key revoked');}catch(error){toast(error.message,true);}}
@@ -211,10 +458,18 @@ $('#bootstrap-pane').addEventListener('submit',async(event)=>{event.preventDefau
$('#sign-out').addEventListener('click',async()=>{try{if(!state.token)await api('/auth/logout',{method:'POST',body:'{}'});}catch(error){if(error.status!==401)toast(error.message,true);}state.token='';state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);});
$('#tenant-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/tenants',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Tenant created');}catch(error){toast(error.message,true);}});
$('#project-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/projects',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Project created');}catch(error){toast(error.message,true);}});
-$('#key-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.scopes=data.scopes.split(',').map(value=>value.trim()).filter(Boolean);const result=await api('/keys',{method:'POST',body:JSON.stringify(data)});event.target.reset();showSecret('API key created',result.key);await loadAll();}catch(error){toast(error.message,true);}});
-$('#provider-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/providers',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Provider added');}catch(error){toast(error.message,true);}});
+$('#key-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const form=event.target;const data=formJSON(form);data.scopes=data.scopes.split(',').map(value=>value.trim()).filter(Boolean);data.tags=data.tags.split(',').map(value=>value.trim()).filter(Boolean);data.allowed_models=[...$('#key-models').selectedOptions].map(option=>option.value);data.monthly_spend_micros=data.monthly_spend.trim()?decimalToScaled(data.monthly_spend,6):0;delete data.monthly_spend;data.expires_at=data.expires_at?new Date(data.expires_at).toISOString():null;const result=await api('/keys',{method:'POST',body:JSON.stringify(data)});state.playgroundKey=result.key;form.reset();showSecret('API key created',result.key);await loadAll();goTo('quickstart');}catch(error){toast(error.message,true);}});
+$('#starter-key-form').addEventListener('submit',async(event)=>{event.preventDefault();const button=$('#starter-key-submit');try{const projectID=$('#starter-project').value;const name=$('#starter-key-name').value.trim();const model=selectedDeveloperModel();if(!projectID||!name||!model.public_id)throw new Error('An active project and model are required');button.disabled=true;const result=await api('/keys',{method:'POST',body:JSON.stringify({tenant_id:state.actor.tenant_id,project_id:projectID,name,scopes:['inference'],tags:['quickstart'],allowed_models:[model.public_id],monthly_spend_micros:0,expires_at:null})});state.playgroundKey=result.key;await loadAll();$('#playground-key').value=result.key;showSecret('Starter API key created',result.key);toast('Starter key is ready in the Playground');}catch(error){toast(error.message,true);}finally{button.disabled=false;}});
+function syncProviderWireAPI(){const form=$('#provider-form');const wire=form.elements.wire_api;const protocol=form.elements.protocol.value;wire.innerHTML=protocol==='anthropic'?'<option value="messages">Messages</option>':'<option value="chat_completions">Chat Completions</option><option value="responses">Responses</option>';}
+$('#provider-form [name=protocol]').addEventListener('change',syncProviderWireAPI);
+$('#provider-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/providers',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();syncProviderWireAPI();await loadAll();toast('Provider added');}catch(error){toast(error.message,true);}});
$('#model-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.input_price_micros_per_million=decimalToScaled(data.input_price,6);data.output_price_micros_per_million=decimalToScaled(data.output_price,6);data.cache_read_price_micros_per_million=decimalToScaled(data.cache_read_price,6);data.cache_write_price_micros_per_million=decimalToScaled(data.cache_write_price,6);delete data.input_price;delete data.output_price;delete data.cache_read_price;delete data.cache_write_price;for(const field of ['capabilities','input_modalities','output_modalities','regions','aliases','allowed_tenant_ids','allowed_key_ids'])data[field]=String(data[field]||'').split(',').map(value=>value.trim()).filter(Boolean);data.context_window=Number(data.context_window||0);data.max_output_tokens=Number(data.max_output_tokens||0);data.price_currency=state.overview.billing_currency||'usd';data.routes=$$('.route-row').map(row=>({provider_id:row.querySelector('.route-provider').value,upstream_model:row.querySelector('.route-upstream').value,priority:Number(row.querySelector('.route-priority').value),weight:Number(row.querySelector('.route-weight').value)}));await api('/models',{method:'POST',body:JSON.stringify(data)});event.target.reset();$('#route-editor').innerHTML='';renderRouteEditor();await loadAll();toast('Model created');}catch(error){toast(error.message,true);}});
$('#topup-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);const digits=currencyDigits(state.overview.billing_currency||'usd');const result=await api('/billing/checkout-sessions',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_minor:decimalToScaled(data.amount,digits)})});window.location.assign(result.url);}catch(error){toast(error.message,true);}});
+$('#billing-profile-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.country=String(data.country||'').toUpperCase();state.billingProfile=await api('/billing/profile',{method:'PUT',body:JSON.stringify(data)});renderBillingProfile();toast(state.billingProfile.stripe_sync_status==='synced'?'Invoice details saved and synced':'Invoice details saved');}catch(error){try{state.billingProfile=await api('/billing/profile');renderBillingProfile();}catch{}toast(error.message,true);}});
+$('#auto-topup-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const currency=state.autoTopUp.currency||state.overview.billing_currency||'usd';const result=await api('/billing/auto-topup',{method:'PUT',body:JSON.stringify({enabled:$('#auto-topup-enabled').checked,threshold_micros:decimalToScaled($('#auto-topup-threshold').value,6),topup_amount_minor:decimalToScaled($('#auto-topup-amount').value,currencyDigits(currency))})});state.autoTopUp=result;renderAutoTopUp();renderQuickstart();toast(result.enabled?'Automatic top-up enabled':'Automatic top-up settings saved');}catch(error){toast(error.message,true);}});
+$('#auto-topup-payment-setup').addEventListener('click',async()=>{try{const result=await api('/billing/auto-topup/setup-sessions',{method:'POST',body:'{}'});window.location.assign(result.url);}catch(error){toast(error.message,true);}});
+$('#developer-preferences-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/developer/preferences',{method:'PUT',body:JSON.stringify({default_model:data.default_model||'',fallback_model:data.fallback_model||''})});await loadAll();toast('API defaults saved');}catch(error){toast(error.message,true);}});
+$('#billing-preferences-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/developer/preferences/billing',{method:'PUT',body:JSON.stringify({low_balance_enabled:$('#low-balance-enabled').checked,low_balance_threshold_micros:decimalToScaled($('#low-balance-threshold').value,6)})});await loadAll();toast('Balance alert saved');}catch(error){toast(error.message,true);}});
$('#adjustment-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/billing/adjustments',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_micros:decimalToScaled(data.amount,6),description:data.description})});event.target.reset();await loadAll();toast('Balance adjusted');}catch(error){toast(error.message,true);}});
$('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Invitation sent');}catch(error){toast(error.message,true);}});
$('#password-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/auth/password',{method:'POST',body:JSON.stringify({current_password:data.current_password,new_password:data.new_password})});event.target.reset();state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);authError('Password changed. Sign in again.',true);}catch(error){toast(error.message,true);}});
@@ -223,6 +478,26 @@ $('#totp-confirm-form').addEventListener('submit',async(event)=>{event.preventDe
$('#passkey-form').addEventListener('submit',async(event)=>{event.preventDefault();try{if(!state.authConfig.passkeys_enabled)throw new Error('Passkeys are not configured');const data=formJSON(event.target);const begin=await api('/auth/mfa/passkey/options',{method:'POST',body:JSON.stringify({current_password:data.current_password})});const credential=await createPasskey(begin.options);await api('/auth/mfa/passkey',{method:'POST',body:JSON.stringify({challenge_token:begin.challenge_token,name:data.name,credential})});event.target.reset();await loadAll();toast('Passkey added');}catch(error){toast(error.message,true);}});
$('#revoke-other-sessions').addEventListener('click',async()=>{if(!confirm('Sign out every other device?'))return;try{await api('/auth/sessions/revoke-others',{method:'POST',body:'{}'});await loadAll();toast('Other devices signed out');}catch(error){toast(error.message,true);}});
$('#close-dialog').addEventListener('click',()=>$('#secret-dialog').close());$('#copy-secret').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#created-secret').textContent);toast('Credential copied');});
+$('#close-model-dialog').addEventListener('click',()=>$('#model-dialog').close());$('#model-dialog-close').addEventListener('click',()=>$('#model-dialog').close());$('#model-dialog-playground').addEventListener('click',()=>{if(state.detailModel)useModelInPlayground(state.detailModel.public_id);});$('#copy-model-id').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#model-dialog-id').textContent);toast('Model ID copied');});
+$('#close-usage-dialog').addEventListener('click',()=>$('#usage-dialog').close());$('#usage-dialog-close').addEventListener('click',()=>$('#usage-dialog').close());$('#copy-request-id').addEventListener('click',async()=>{await navigator.clipboard.writeText(state.detailUsage?.request_id||'');toast('Request ID copied');});$('#copy-request-diagnostic').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#usage-diagnostic').textContent);toast('Diagnostic copied');});
+$('#model-estimate-form').addEventListener('submit',event=>event.preventDefault());
+['estimate-input','estimate-output','estimate-cache-read','estimate-cache-write'].forEach(id=>$('#'+id).addEventListener('input',renderModelEstimate));
+$('#quickstart-model').addEventListener('change',()=>{syncQuickstartProtocols();renderQuickstartCode();renderDeveloperAccess();});
+$('#quickstart-protocol').addEventListener('change',()=>{syncProviderSelect('#quickstart-provider',selectedDeveloperModel(),$('#quickstart-protocol').value);renderQuickstartCode();});
+$('#quickstart-provider').addEventListener('change',renderQuickstartCode);
+$('#quickstart-language').addEventListener('change',renderQuickstartCode);
+$('#playground-model').addEventListener('change',syncPlaygroundProtocols);
+$('#playground-protocol').addEventListener('change',()=>syncProviderSelect('#playground-provider',selectedPlaygroundModel(),$('#playground-protocol').value));
+$('#playground-form').addEventListener('submit',runPlayground);
+$('#playground-stop').addEventListener('click',()=>state.playgroundController?.abort());
+$('#playground-diagnostic-action').addEventListener('click',event=>goTo(event.currentTarget.dataset.target));
+$('#catalog-search').addEventListener('input',renderCatalog);$('#catalog-protocol').addEventListener('change',renderCatalog);$('#catalog-input').addEventListener('change',renderCatalog);$('#catalog-owner').addEventListener('change',renderCatalog);$('#catalog-sort').addEventListener('change',renderCatalog);
+$('#copy-quickstart').addEventListener('click',async()=>{try{await navigator.clipboard.writeText($('#quickstart-code-block code').textContent);toast('Example copied');}catch(error){toast('Copy failed; select the example manually',true);}});
+$('#copy-endpoint-env').addEventListener('click',async()=>{try{await navigator.clipboard.writeText($('#endpoint-env').textContent);toast('Environment copied');}catch(error){toast('Copy failed; select the environment manually',true);}});
+async function loadUsageFilters(){const params=new URLSearchParams(new FormData($('#usage-filter-form')));for(const [key,value] of [...params.entries()])if(!String(value).trim())params.delete(key);const query=params.toString();try{[state.usage,state.usageDaily,state.usageAnalytics]=await Promise.all([api(`/usage${query?`?${query}`:''}`),api(`/usage/daily${query?`?${query}`:''}`),api(`/usage/analytics${query?`?${query}`:''}`)]);renderUsage();}catch(error){toast(error.message,true);}}
+$('#usage-filter-form').addEventListener('submit',async(event)=>{event.preventDefault();await loadUsageFilters();});
+$('#usage-filter-reset').addEventListener('click',async()=>{const form=$('#usage-filter-form');form.reset();const query=new URLSearchParams(defaultUsageQuery());$('#usage-from').value=query.get('from');$('#usage-to').value=query.get('to');await loadUsageFilters();});
async function pollTopUp(orderID){for(let attempt=0;attempt<20;attempt++){const order=await api(`/billing/orders/${encodeURIComponent(orderID)}`);if(order.status==='paid'){await loadAll();toast('Balance credited');return;}if(['failed','expired'].includes(order.status)){toast(`Top-up ${order.status}`,true);return;}await new Promise(resolve=>setTimeout(resolve,1500));}toast('Payment is still processing');}
-async function start(){try{state.authConfig=await api('/auth/config');$('#register-tab').classList.toggle('hidden',!state.authConfig.registration_enabled);if(!state.authConfig.registration_enabled&&$('#register-tab').classList.contains('active'))showAuthPane('login-pane');const params=new URLSearchParams(location.search);const action=params.get('action');const token=params.get('token');if(action==='reset-password'&&token){$('#reset-token').value=token;showAuthPane('reset-complete-pane');setConnected(false);return;}if(action==='accept-invite'&&token){$('#invite-token').value=token;showAuthPane('invite-pane');setConnected(false);return;}state.csrf=cookie('aigw_csrf');if(action==='verify-email'&&token){const result=await api('/auth/email/verify',{method:'POST',body:JSON.stringify({token})});history.replaceState({},'',location.pathname);await completeBrowserLogin(result);return;}const session=await api('/auth/session');if(session.authenticated){await loadAll(session);const orderID=params.get('order_id');if(params.get('topup')==='success'&&orderID){history.replaceState({},'',location.pathname);pollTopUp(orderID).catch(error=>toast(error.message,true));}else if(params.get('topup')==='cancel'){history.replaceState({},'',location.pathname);toast('Top-up cancelled');}}else setConnected(false);}catch(error){setConnected(false);authError(error.message);}}
+async function pollAutoTopUpSetup(){for(let attempt=0;attempt<20;attempt++){const settings=await api('/billing/auto-topup');if(settings.payment_method_configured){state.autoTopUp=settings;renderBilling();renderQuickstart();toast('Payment method saved; review the threshold and enable automatic top-up');return;}await new Promise(resolve=>setTimeout(resolve,1500));}toast('Payment method setup is still processing',true);}
+async function start(){try{state.authConfig=await api('/auth/config');$('#register-tab').classList.toggle('hidden',!state.authConfig.registration_enabled);if(!state.authConfig.registration_enabled&&$('#register-tab').classList.contains('active'))showAuthPane('login-pane');const params=new URLSearchParams(location.search);const action=params.get('action');const token=params.get('token');if(params.get('auth')==='register'&&state.authConfig.registration_enabled)showAuthPane('register-pane');if(action==='reset-password'&&token){$('#reset-token').value=token;showAuthPane('reset-complete-pane');setConnected(false);return;}if(action==='accept-invite'&&token){$('#invite-token').value=token;showAuthPane('invite-pane');setConnected(false);return;}state.csrf=cookie('aigw_csrf');if(action==='verify-email'&&token){const result=await api('/auth/email/verify',{method:'POST',body:JSON.stringify({token})});history.replaceState({},'',location.pathname);await completeBrowserLogin(result);return;}const session=await api('/auth/session');if(session.authenticated){await loadAll(session);const orderID=params.get('order_id');if(params.get('topup')==='success'&&orderID){history.replaceState({},'',location.pathname);pollTopUp(orderID).catch(error=>toast(error.message,true));}else if(params.get('topup')==='cancel'){history.replaceState({},'',location.pathname);toast('Top-up cancelled');}else if(params.get('autotopup')==='setup'){history.replaceState({},'',location.pathname);pollAutoTopUpSetup().catch(error=>toast(error.message,true));}else if(params.get('autotopup')==='cancel'){history.replaceState({},'',location.pathname);toast('Payment method setup cancelled');}}else setConnected(false);}catch(error){setConnected(false);authError(error.message);}}
start();
diff --git a/internal/adminui/assets/index.html b/internal/adminui/assets/index.html
index 04b4e87..8f31e1c 100644
--- a/internal/adminui/assets/index.html
+++ b/internal/adminui/assets/index.html
@@ -75,7 +75,9 @@
</header>
<main class="shell">
<nav class="tabs" aria-label="Admin sections">
- <button class="tab active" data-section="overview">Overview</button>
+ <button class="tab active" data-section="quickstart">Quickstart</button>
+ <button class="tab" data-section="overview">Overview</button>
+ <button class="tab" data-section="catalog">Model catalog</button>
<button class="tab" data-section="usage" data-permission="usage.read">Usage</button>
<button class="tab" data-section="billing" data-permission="billing.read" id="billing-tab">Billing</button>
<button class="tab" data-section="projects" data-permission="projects.read">Projects</button>
@@ -86,10 +88,77 @@
<button class="tab" data-section="audit" data-permission="audit.read">Audit</button>
<button class="tab" data-section="tenants" data-permission="tenants.read">Tenants</button>
<button class="tab" data-section="providers" data-permission="platform.read">Providers</button>
- <button class="tab" data-section="models" data-permission="platform.read">Models & routes</button>
+ <button class="tab" data-section="models" data-permission="platform.read">Routing</button>
</nav>
- <section id="overview" class="section active">
+ <section id="quickstart" class="section active">
+ <div class="section-heading"><div><span class="eyebrow">DEVELOPER WORKSPACE</span><h1>Start building</h1></div><div class="form-actions"><span class="currency-label" id="quickstart-balance"></span><button class="button secondary" type="button" id="quickstart-topup" data-goto="billing" data-permission="billing.topup">Add funds</button></div></div>
+ <div class="onboarding panel" id="onboarding"></div>
+ <div class="quick-access-grid">
+ <form class="panel starter-key-panel" id="starter-key-form" data-permission="keys.write">
+ <div class="section-heading"><div><span class="eyebrow">API ACCESS</span><h2>Create a starter key</h2></div><span class="badge" id="starter-model-label"></span></div>
+ <div class="starter-key-fields"><label>Project<select id="starter-project" required></select></label><label>Key name<input id="starter-key-name" maxlength="120" value="Quickstart key" required></label><button class="button primary" id="starter-key-submit" type="submit">Create & use key</button></div>
+ <p class="muted">The key is limited to the selected Quickstart model and placed in the Playground for this page only.</p>
+ </form>
+ <section class="panel endpoint-panel">
+ <div class="section-heading"><div><span class="eyebrow">CONNECTION</span><h2>API endpoints</h2></div><button class="button subtle" type="button" id="copy-endpoint-env">Copy environment</button></div>
+ <div class="endpoint-list" id="endpoint-list"></div>
+ <pre class="endpoint-env"><code id="endpoint-env"></code></pre>
+ </section>
+ </div>
+ <div class="quickstart-grid">
+ <div class="panel quickstart-code">
+ <div class="section-heading"><div><span class="eyebrow">FIRST REQUEST</span><h2>Copy a working example</h2></div><button class="button subtle" type="button" id="copy-quickstart">Copy</button></div>
+ <div class="form-grid compact-form"><label>Model<select id="quickstart-model"></select></label><label>Protocol<select id="quickstart-protocol"></select></label><label>Provider<select id="quickstart-provider"></select></label><label>Language<select id="quickstart-language"><option value="curl">cURL</option><option value="python">Python</option><option value="node">Node.js</option></select></label></div>
+ <pre id="quickstart-code-block"><code></code></pre>
+ <p class="muted">Use an active API key in <code>AIGW_API_KEY</code>. The example never stores your key in the browser.</p>
+ </div>
+ <div class="panel quickstart-next"><span class="eyebrow">WORKSPACE SIGNALS</span><h2>What to do next</h2><div id="quickstart-signals" class="signal-list"></div></div>
+ </div>
+ <form class="panel playground" id="playground-form">
+ <div class="section-heading"><div><span class="eyebrow">LIVE API</span><h2>API playground</h2></div><span class="currency-label" id="playground-endpoint"></span></div>
+ <div class="playground-fields">
+ <label class="playground-key">API key<input id="playground-key" type="password" autocomplete="off" spellcheck="false" required placeholder="sk-aigw-..."></label>
+ <label>Model<select id="playground-model" required></select></label>
+ <label>Protocol<select id="playground-protocol" required></select></label>
+ <label>Provider<select id="playground-provider"></select></label>
+ <label class="playground-prompt">Prompt<textarea id="playground-prompt" required rows="4">Reply with exactly: AIGW ready</textarea></label>
+ <div class="playground-actions"><label>Max output<input id="playground-max-output" type="number" min="1" max="4096" value="128" required></label><button class="button primary" id="playground-send" type="submit">Send request</button><button class="button secondary hidden" id="playground-stop" type="button">Stop</button></div>
+ </div>
+ <div class="playground-result hidden" id="playground-result" aria-live="polite">
+ <div class="playground-meta"><strong class="badge" id="playground-status"></strong><span id="playground-request-id"></span><span id="playground-duration"></span><span id="playground-tokens"></span></div>
+ <pre><code id="playground-response"></code></pre>
+ <div class="playground-diagnostic hidden" id="playground-diagnostic"><div><strong id="playground-diagnostic-title"></strong><p id="playground-diagnostic-message"></p></div><button class="button secondary hidden" id="playground-diagnostic-action" type="button"></button></div>
+ </div>
+ </form>
+ <div class="section-heading ledger-heading"><div><span class="eyebrow">DEFAULTS & ALERTS</span><h2>Workspace preferences</h2></div></div>
+ <div class="preferences-grid">
+ <form class="panel form-grid compact-form" id="developer-preferences-form" data-permission="preferences.read">
+ <h2>API defaults</h2>
+ <label>Default model<select id="preference-default-model" name="default_model"></select></label>
+ <label>Fallback model<select id="preference-fallback-model" name="fallback_model"></select></label>
+ <p class="muted form-note">These defaults drive workspace examples. Your API request can still select any model available to this tenant.</p>
+ <button class="button primary" type="submit" data-permission="developer.preferences.write">Save API defaults</button>
+ </form>
+ <form class="panel form-grid compact-form" id="billing-preferences-form" data-permission="billing.read">
+ <h2>Balance alert</h2>
+ <label class="toggle-row"><input id="low-balance-enabled" name="low_balance_enabled" type="checkbox"><span>Email billing members when available balance is low</span></label>
+ <label>Alert below<input id="low-balance-threshold" name="low_balance_threshold" inputmode="decimal" required></label>
+ <p class="muted form-note" id="balance-alert-status"></p>
+ <button class="button primary" type="submit" data-permission="billing.preferences.write">Save balance alert</button>
+ </form>
+ </div>
+ <div class="section-heading ledger-heading"><div><span class="eyebrow">RECENT ACTIVITY</span><h2>Latest requests</h2></div><button class="button subtle" type="button" data-goto="usage">View all usage</button></div>
+ <div class="panel table-wrap"><table><thead><tr><th>Time</th><th>Model</th><th>Status</th><th>Tokens</th><th>Charged</th><th>Latency</th></tr></thead><tbody id="quickstart-usage-body"></tbody></table></div>
+ </section>
+
+ <section id="catalog" class="section">
+ <div class="section-heading"><div><span class="eyebrow">DISCOVER</span><h1>Model catalog</h1></div><span class="currency-label" id="catalog-count"></span></div>
+ <div class="panel catalog-toolbar"><label>Search models<input id="catalog-search" type="search" placeholder="Search by model, developer, capability"></label><label>Protocol<select id="catalog-protocol"><option value="">All protocols</option><option value="chat_completions">OpenAI Chat Completions</option><option value="responses">OpenAI Responses</option><option value="messages">Anthropic Messages</option></select></label><label>Input<select id="catalog-input"><option value="">Any input</option><option value="text">Text</option><option value="image">Image</option><option value="audio">Audio</option><option value="video">Video</option></select></label><label>Developer<select id="catalog-owner"><option value="">All developers</option></select></label><label>Sort<select id="catalog-sort"><option value="newest">Newest</option><option value="name">Name</option><option value="input_price">Lowest input price</option><option value="output_price">Lowest output price</option><option value="context">Largest context</option></select></label></div>
+ <div id="catalog-grid" class="catalog-grid"></div>
+ </section>
+
+ <section id="overview" class="section">
<div class="section-heading"><div><span class="eyebrow">OPERATIONS</span><h1>Account overview</h1></div><button class="button secondary" id="reload" data-permission="platform.write">Reload snapshot</button></div>
<div class="metric-grid" id="metrics"></div>
<div class="section-heading ledger-heading"><div><span class="eyebrow">CURRENT PERIOD</span><h2>Project usage</h2></div></div>
@@ -98,9 +167,29 @@
<section id="usage" class="section">
<div class="section-heading"><div><span class="eyebrow">METERING</span><h1>Usage ledger</h1></div></div>
+ <form class="panel usage-filters" id="usage-filter-form">
+ <label>From<input id="usage-from" name="from" type="date"></label>
+ <label>To<input id="usage-to" name="to" type="date"></label>
+ <label>Project<select id="usage-project" name="project_id"><option value="">All projects</option></select></label>
+ <label>API key<select id="usage-key" name="key_id"><option value="">All API keys</option></select></label>
+ <label>Model<select id="usage-model" name="model"><option value="">All models</option></select></label>
+ <label>Provider<select id="usage-provider" name="provider"><option value="">All providers</option></select></label>
+ <label>Protocol<select id="usage-protocol" name="protocol"><option value="">All protocols</option><option value="openai">OpenAI Chat Completions</option><option value="openai_responses">OpenAI Responses</option><option value="anthropic">Anthropic Messages</option></select></label>
+ <label>Transport<select id="usage-stream" name="stream"><option value="">Streaming and non-streaming</option><option value="true">Streaming</option><option value="false">Non-streaming</option></select></label>
+ <label>Status<select id="usage-status" name="status"><option value="">All statuses</option><option value="success">Successful</option><option value="error">Errors</option></select></label>
+ <label>Error type<input id="usage-error-type" name="error_type" placeholder="provider_error"></label>
+ <label class="usage-request-filter">Request ID<input id="usage-request-id" name="request_id" placeholder="req_..."></label>
+ <div class="form-actions"><button class="button primary" type="submit">Apply filters</button><button class="button subtle" id="usage-filter-reset" type="button">Reset</button></div>
+ </form>
+ <div class="metric-grid usage-metrics" id="usage-metrics"></div>
+ <div class="panel usage-chart" id="usage-chart"></div>
+ <div class="analytics-grid">
+ <div class="panel table-wrap analytics-panel"><div class="section-heading ledger-heading"><div><span class="eyebrow">COST RANKING</span><h2>Model cost</h2></div><span class="currency-label">Current range vs previous</span></div><table class="analytics-table"><thead><tr><th>Model</th><th>Requests</th><th>Success</th><th>Tokens</th><th>Charged</th><th>Change</th><th>P95</th><th>Missing usage</th></tr></thead><tbody id="usage-model-analytics-body"></tbody></table></div>
+ <div class="panel table-wrap analytics-panel"><div class="section-heading ledger-heading"><div><span class="eyebrow">ROUTE HEALTH</span><h2>Provider performance</h2></div><span class="currency-label">Cache hit and latency</span></div><table class="analytics-table"><thead><tr><th>Provider</th><th>Requests</th><th>Success</th><th>Models</th><th>Cache hit</th><th>Charged</th><th>Change</th><th>P95</th></tr></thead><tbody id="usage-provider-analytics-body"></tbody></table></div>
+ </div>
<div class="panel table-wrap"><table><thead><tr><th>Period</th><th>Project</th><th>Requests</th><th>Success</th><th>Input</th><th>Output</th><th>Cost</th></tr></thead><tbody id="usage-summary-body"></tbody></table></div>
<div class="section-heading ledger-heading"><div><span class="eyebrow">REQUESTS</span><h2>Recent events</h2></div></div>
- <div class="panel table-wrap"><table><thead><tr><th>Time</th><th>Request</th><th>Model</th><th>Status</th><th>Tokens</th><th>Cost</th><th>Latency</th></tr></thead><tbody id="usage-events-body"></tbody></table></div>
+ <div class="panel table-wrap"><table><thead><tr><th>Time</th><th>Request</th><th>Project / key</th><th>Model / provider</th><th>Status</th><th>Tokens</th><th>Charged</th><th>Latency</th><th></th></tr></thead><tbody id="usage-events-body"></tbody></table></div>
</section>
<section id="tenants" class="section">
@@ -117,14 +206,24 @@
<section id="keys" class="section">
<div class="section-heading"><div><span class="eyebrow">ACCESS</span><h1>API keys</h1></div></div>
- <form class="panel form-grid" id="key-form" data-permission="keys.write"><label>Tenant<select name="tenant_id" id="key-tenant" required></select></label><label>Project<select name="project_id" id="key-project" required></select></label><label>Name<input name="name" required placeholder="CLI production key"></label><label>Scopes<input name="scopes" value="inference" placeholder="inference"></label><button class="button primary" type="submit">Create key</button></form>
+ <form class="panel form-grid key-form" id="key-form" data-permission="keys.write">
+ <label>Tenant<select name="tenant_id" id="key-tenant" required></select></label>
+ <label>Project<select name="project_id" id="key-project" required></select></label>
+ <label>Name<input name="name" required maxlength="120" placeholder="CLI production key"></label>
+ <label>Scopes<input name="scopes" value="inference" placeholder="inference"></label>
+ <label>Tags<input name="tags" placeholder="production, backend"></label>
+ <label>Monthly spend cap<input name="monthly_spend" inputmode="decimal" value="0" placeholder="0 = unlimited"></label>
+ <label>Expires at<input name="expires_at" type="datetime-local"></label>
+ <label class="key-model-picker">Allowed models<select name="allowed_models" id="key-models" multiple size="5" aria-describedby="key-model-help"></select><small id="key-model-help">No selection allows every model visible to this workspace.</small></label>
+ <button class="button primary" type="submit">Create key</button>
+ </form>
<div class="panel warning"><strong>Key visibility</strong><span>The secret is shown only once after creation.</span></div>
- <div class="panel table-wrap"><table><thead><tr><th>Name</th><th>Prefix</th><th>Project</th><th>Scopes</th><th>Status</th><th></th></tr></thead><tbody id="keys-body"></tbody></table></div>
+ <div class="panel table-wrap"><table class="keys-table"><thead><tr><th>Name</th><th>Prefix</th><th>Project</th><th>Restrictions</th><th>Month usage</th><th>Monthly cap</th><th>Activity</th><th></th></tr></thead><tbody id="keys-body"></tbody></table></div>
</section>
<section id="providers" class="section">
<div class="section-heading"><div><span class="eyebrow">UPSTREAMS</span><h1>Providers</h1></div></div>
- <form class="panel form-grid" id="provider-form" data-permission="platform.write"><input class="visually-hidden" name="username" autocomplete="username" value="aigw-provider" aria-hidden="true" tabindex="-1"><label>Name<input name="name" autocomplete="off" required placeholder="openai-primary"></label><label>Protocol<select name="protocol"><option value="openai">OpenAI</option><option value="anthropic">Anthropic</option></select></label><label>Base URL<input name="base_url" type="url" autocomplete="off" required placeholder="https://api.example.com/v1"></label><label>API key<input name="api_key" type="password" required autocomplete="new-password" placeholder="Stored encrypted"></label><button class="button primary" type="submit">Add provider</button></form>
+ <form class="panel form-grid" id="provider-form" data-permission="platform.write"><input class="visually-hidden" name="username" autocomplete="username" value="aigw-provider" aria-hidden="true" tabindex="-1"><label>Name<input name="name" autocomplete="off" required placeholder="OpenAI primary"></label><label>Public slug<input name="slug" autocomplete="off" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" maxlength="64" placeholder="openai-primary"></label><label>Protocol<select name="protocol"><option value="openai">OpenAI</option><option value="anthropic">Anthropic</option></select></label><label>Wire API<select name="wire_api"><option value="chat_completions">Chat Completions</option><option value="responses">Responses</option></select></label><label>Base URL<input name="base_url" type="url" autocomplete="off" required placeholder="https://api.example.com/v1"></label><label>API key<input name="api_key" type="password" required autocomplete="new-password" placeholder="Stored encrypted"></label><button class="button primary" type="submit">Add provider</button></form>
<div class="panel table-wrap"><table><thead><tr><th>Name</th><th>Protocol</th><th>Base URL</th><th>Routes</th><th>Status</th><th></th></tr></thead><tbody id="providers-body"></tbody></table></div>
</section>
@@ -140,6 +239,30 @@
<form class="panel form-grid compact-form" id="topup-form" data-permission="billing.topup"><label>Tenant<select name="tenant_id" id="topup-tenant" required></select></label><label>Amount<input name="amount" inputmode="decimal" min="0" required placeholder="25.00"></label><button class="button primary" type="submit">Open Stripe Checkout</button></form>
<form class="panel form-grid compact-form" id="adjustment-form" data-permission="billing.adjust"><label>Tenant<select name="tenant_id" id="adjustment-tenant" required></select></label><label>Signed amount<input name="amount" inputmode="decimal" required placeholder="10.00 or -5.00"></label><label>Reference<input name="description" maxlength="240" placeholder="Support credit"></label><button class="button secondary" type="submit">Post adjustment</button></form>
</div>
+ <div class="panel billing-profile-panel" id="billing-profile-panel" data-permission="billing.read">
+ <div class="section-heading"><div><span class="eyebrow">INVOICE DETAILS</span><h2>Billing profile</h2></div><span class="badge" id="billing-profile-status">Not configured</span></div>
+ <form class="form-grid" id="billing-profile-form">
+ <label>Legal or billing name<input name="legal_name" maxlength="150" autocomplete="organization" required></label>
+ <label>Billing email<input name="billing_email" type="email" maxlength="254" autocomplete="email" required></label>
+ <label class="billing-address-wide">Address line 1<input name="address_line1" maxlength="200" autocomplete="address-line1" required></label>
+ <label class="billing-address-wide">Address line 2<input name="address_line2" maxlength="200" autocomplete="address-line2"></label>
+ <label>City<input name="city" maxlength="100" autocomplete="address-level2" required></label>
+ <label>State or region<input name="region" maxlength="100" autocomplete="address-level1"></label>
+ <label>Postal code<input name="postal_code" maxlength="32" autocomplete="postal-code" required></label>
+ <label>Country code<input name="country" minlength="2" maxlength="2" pattern="[A-Za-z]{2}" autocomplete="country" autocapitalize="characters" placeholder="NZ" required></label>
+ <div class="form-actions billing-profile-actions"><small class="error-label" id="billing-profile-error"></small><button class="button primary" type="submit" data-permission="billing.topup">Save invoice details</button></div>
+ </form>
+ </div>
+ <div class="panel auto-topup-panel" id="auto-topup-panel" data-permission="billing.read">
+ <div class="section-heading"><div><span class="eyebrow">BALANCE PROTECTION</span><h2>Automatic top-up</h2></div><span class="badge" id="auto-topup-status">Not configured</span></div>
+ <form class="form-grid" id="auto-topup-form">
+ <label class="toggle-row"><input id="auto-topup-enabled" name="enabled" type="checkbox"><span>Automatically add funds when available balance reaches the threshold</span></label>
+ <label>Balance threshold<input id="auto-topup-threshold" name="threshold" inputmode="decimal" required></label>
+ <label>Top-up amount<input id="auto-topup-amount" name="amount" inputmode="decimal" required></label>
+ <div><span class="muted">Payment method</span><strong id="auto-topup-payment-method">Not saved</strong><small class="error-label" id="auto-topup-error"></small></div>
+ <div class="form-actions auto-topup-actions"><button class="button secondary" id="auto-topup-payment-setup" type="button">Save payment method</button><button class="button primary" type="submit">Save automatic top-up</button></div>
+ </form>
+ </div>
<div class="panel table-wrap"><table><thead><tr><th>Tenant</th><th>Balance</th><th>Reserved</th><th>Available</th><th>Updated</th></tr></thead><tbody id="billing-accounts-body"></tbody></table></div>
<div class="section-heading ledger-heading"><div><span class="eyebrow">AUDIT</span><h2>Recent ledger entries</h2></div></div>
<div class="panel table-wrap"><table><thead><tr><th>Time</th><th>Tenant</th><th>Kind</th><th>Amount</th><th>Balance after</th><th>Reference</th></tr></thead><tbody id="billing-ledger-body"></tbody></table></div>
@@ -180,6 +303,8 @@
</main>
</div>
<div id="toast" class="toast" role="status"></div>
+ <dialog id="model-dialog"><div class="dialog-content model-dialog-content"><div class="section-heading"><div><span class="eyebrow">MODEL DETAIL</span><h2 id="model-dialog-title">Model</h2></div><button class="icon-button" id="close-model-dialog" aria-label="Close">×</button></div><div class="model-dialog-id"><code id="model-dialog-id"></code><button class="text-button" id="copy-model-id" type="button">Copy ID</button></div><div class="model-detail-grid" id="model-detail-grid"></div><section class="model-provider-health" id="model-provider-health"><div class="section-heading"><div><h3>Provider health</h3><small>Measured from requests handled by this gateway instance.</small></div></div><div class="table-wrap"><table class="provider-health-table"><thead><tr><th>Provider</th><th>State</th><th>Recent availability</th><th>Header latency</th><th>Samples</th></tr></thead><tbody id="model-provider-health-body"></tbody></table></div></section><form class="model-estimator" id="model-estimate-form"><h3>Cost estimate</h3><div class="form-grid compact-form"><label>Input tokens<input id="estimate-input" type="number" min="0" step="1" value="1000" required></label><label>Output tokens<input id="estimate-output" type="number" min="0" step="1" value="500" required></label><label>Cache read tokens<input id="estimate-cache-read" type="number" min="0" step="1" value="0" required></label><label>Cache write tokens<input id="estimate-cache-write" type="number" min="0" step="1" value="0" required></label><output id="model-estimate" class="model-estimate" aria-live="polite"></output></div></form><div class="form-actions model-dialog-actions"><button class="button secondary" id="model-dialog-playground" type="button">Use in Playground</button><button class="button subtle" id="model-dialog-close" type="button">Close</button></div></div></dialog>
+ <dialog id="usage-dialog"><div class="dialog-content request-dialog-content"><div class="section-heading"><div><span class="eyebrow">API REQUEST</span><h2 id="usage-dialog-title">Request details</h2></div><button class="icon-button" id="close-usage-dialog" aria-label="Close">×</button></div><div class="model-dialog-id"><code id="usage-dialog-id"></code><button class="text-button" id="copy-request-id" type="button">Copy request ID</button></div><div class="request-detail-grid" id="usage-detail-grid"></div><pre class="request-diagnostic"><code id="usage-diagnostic"></code></pre><div class="form-actions model-dialog-actions"><button class="button secondary" id="copy-request-diagnostic" type="button">Copy diagnostic</button><button class="button subtle" id="usage-dialog-close" type="button">Close</button></div></div></dialog>
<dialog id="secret-dialog"><div class="dialog-content"><div class="section-heading"><div><span class="eyebrow">ONE-TIME SECRET</span><h2 id="secret-title">Credential created</h2></div><button class="icon-button" id="close-dialog" aria-label="Close">×</button></div><p>Copy this credential now. It will not be shown again.</p><code id="created-secret"></code><button class="button primary" id="copy-secret">Copy credential</button></div></dialog>
<script src="./app.js" defer></script>
</body>
diff --git a/internal/adminui/assets/models.css b/internal/adminui/assets/models.css
new file mode 100644
index 0000000..dc7c9c9
--- /dev/null
+++ b/internal/adminui/assets/models.css
@@ -0,0 +1,64 @@
+:root { --bg:#f4f6f7; --panel:#fff; --ink:#17212b; --muted:#657583; --line:#d9e1e5; --nav:#102a3a; --accent:#146c94; --accent-soft:#e6f2f6; --good:#187151; --warn:#7b5e14; --bad:#a23f45; font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
+* { box-sizing:border-box; }
+body { margin:0; background:var(--bg); color:var(--ink); font-size:14px; }
+button,input,select { font:inherit; }
+button { cursor:pointer; }
+.catalog-header { min-height:66px; padding:12px max(24px,calc((100% - 1240px)/2)); display:flex; align-items:center; justify-content:space-between; gap:20px; background:var(--nav); }
+.catalog-brand { display:grid; grid-template-columns:38px auto; grid-template-rows:20px 14px; column-gap:10px; color:#fff; text-decoration:none; }
+.catalog-brand > span { grid-row:1/-1; width:38px; height:38px; border:1px solid #8fd0df; display:grid; place-items:center; color:#b8eef7; font-weight:800; }
+.catalog-brand strong { font-size:15px; align-self:end; }
+.catalog-brand small { color:#8ba9b9; font-size:9px; }
+.catalog-header nav { display:flex; gap:8px; }
+.button { min-height:40px; padding:0 15px; border:1px solid transparent; display:inline-flex; align-items:center; justify-content:center; font-weight:700; text-decoration:none; }
+.button.primary { color:#fff; background:var(--accent); }
+.button.secondary { color:var(--accent); background:#fff; border-color:var(--line); }
+.catalog-header .button.secondary { color:#b8eef7; background:transparent; border-color:#537486; }
+main { width:min(1240px,calc(100% - 48px)); margin:30px auto 64px; }
+.catalog-intro { display:flex; justify-content:space-between; align-items:end; gap:24px; margin-bottom:22px; }
+.eyebrow { color:var(--accent); font-size:10px; font-weight:800; }
+h1 { margin:7px 0 0; font-size:32px; line-height:1.1; }
+.catalog-stats { display:flex; gap:22px; color:var(--muted); font-size:12px; }
+.catalog-stats strong { display:block; color:var(--ink); font-size:20px; }
+.catalog-filters { display:grid; grid-template-columns:2fr repeat(4,minmax(0,1fr)); gap:12px; padding:18px; margin-bottom:18px; background:var(--panel); border:1px solid var(--line); }
+label { display:flex; flex-direction:column; gap:7px; color:var(--muted); font-size:12px; font-weight:650; }
+input,select { width:100%; min-height:40px; padding:9px 11px; border:1px solid var(--line); color:var(--ink); background:#fff; outline:none; }
+input:focus,select:focus { border-color:#69a9bf; box-shadow:0 0 0 3px var(--accent-soft); }
+.catalog-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; }
+.model-card { min-width:0; display:flex; flex-direction:column; gap:12px; padding:18px; background:var(--panel); border:1px solid var(--line); box-shadow:0 8px 24px rgba(29,47,61,.05); }
+.model-card header { display:flex; justify-content:space-between; align-items:start; gap:12px; }
+.model-card header small { color:var(--muted); }
+.model-card h2 { margin:4px 0 0; font-size:17px; overflow-wrap:anywhere; }
+.model-card > code { color:#486071; font-size:12px; overflow-wrap:anywhere; }
+.model-card > p { min-height:63px; margin:0; color:#526673; line-height:1.5; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
+.health { flex:0 0 auto; padding:4px 7px; border:1px solid var(--line); font-size:11px; white-space:nowrap; }
+.health.available { color:var(--good); background:#eaf7f0; border-color:#c7e9d9; }
+.health.degraded { color:var(--warn); background:#fff8dc; border-color:#e9d990; }
+.health.unavailable { color:var(--bad); background:#fff0f0; border-color:#f0cccc; }
+.model-tags,.detail-tags { display:flex; flex-wrap:wrap; gap:5px; }
+.model-tags span,.detail-tags span { padding:4px 7px; color:#4c6572; background:#eef3f5; font-size:11px; }
+.model-card dl { display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; margin:0; padding-top:12px; border-top:1px solid var(--line); }
+.model-card dl div { min-width:0; }
+dt { color:var(--muted); font-size:10px; }
+dd { margin:5px 0 0; font-size:12px; font-weight:700; overflow-wrap:anywhere; }
+.model-card > .button { margin-top:auto; align-self:flex-start; }
+.catalog-status { padding:28px; color:var(--muted); text-align:center; background:#fff; border:1px solid var(--line); }
+.catalog-status[role="alert"] { color:var(--bad); background:#fff6f6; }
+.hidden { display:none !important; }
+dialog { width:min(720px,calc(100% - 32px)); max-height:calc(100vh - 32px); padding:0; border:0; box-shadow:0 18px 70px rgba(0,0,0,.25); }
+dialog::backdrop { background:rgba(16,42,58,.5); }
+.model-dialog { padding:24px; overflow:auto; }
+.model-dialog > header { display:flex; justify-content:space-between; align-items:start; gap:16px; padding-bottom:16px; border-bottom:1px solid var(--line); }
+.model-dialog h2 { margin:5px 0 5px; font-size:24px; }
+.model-dialog code { color:#486071; overflow-wrap:anywhere; }
+.icon-button { width:36px; height:36px; border:1px solid var(--line); background:#fff; color:var(--muted); font-size:23px; line-height:1; }
+.model-dialog > p { margin:18px 0; color:#526673; line-height:1.55; }
+.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 22px; margin:18px 0; }
+.detail-grid > div { display:flex; justify-content:space-between; gap:14px; padding:11px 0; border-bottom:1px solid var(--line); }
+.detail-grid dd { text-align:right; }
+.price-estimator { padding-top:18px; border-top:1px solid var(--line); }
+.price-estimator h3 { margin:5px 0 14px; font-size:16px; }
+.estimate-inputs { display:grid; grid-template-columns:1fr 1fr 1fr; align-items:end; gap:12px; }
+.estimate-inputs output { min-height:40px; display:flex; align-items:center; justify-content:center; color:var(--good); background:#f0fbf5; border:1px solid #c7e9d9; font-weight:750; }
+.model-dialog footer { display:flex; justify-content:flex-end; gap:8px; margin-top:22px; }
+@media (max-width:900px) { .catalog-filters { grid-template-columns:repeat(2,minmax(0,1fr)); } .search-field { grid-column:1/-1; } .catalog-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
+@media (max-width:620px) { .catalog-header { padding:12px; } .catalog-brand small { display:none; } .catalog-header .button { padding:0 11px; } main { width:calc(100% - 24px); margin-top:20px; } .catalog-intro { align-items:start; flex-direction:column; } .catalog-stats { width:100%; justify-content:space-between; } .catalog-filters,.catalog-grid,.detail-grid,.estimate-inputs { grid-template-columns:1fr; } .search-field { grid-column:auto; } .model-card > p { min-height:0; } .model-dialog { padding:20px; } .model-dialog footer { flex-direction:column; } }
diff --git a/internal/adminui/assets/models.html b/internal/adminui/assets/models.html
new file mode 100644
index 0000000..0af0f50
--- /dev/null
+++ b/internal/adminui/assets/models.html
@@ -0,0 +1,50 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="description" content="Browse AIGW models, API protocols, context limits, and current token prices.">
+ <title>Models | AIGW</title>
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23102a3a'/%3E%3Ctext x='16' y='22' text-anchor='middle' font-family='Arial' font-weight='700' font-size='18' fill='%23b8eef7'%3EA%3C/text%3E%3C/svg%3E">
+ <link rel="stylesheet" href="./models.css">
+ <script src="./models.js" defer></script>
+</head>
+<body>
+ <header class="catalog-header">
+ <a class="catalog-brand" href="./models" aria-label="AIGW model catalog"><span>A</span><strong>AIGW</strong><small>MODEL CATALOG</small></a>
+ <nav aria-label="Account access"><a class="button secondary" href="./">Sign in</a><a class="button primary" id="create-account" href="./?auth=register">Create account</a></nav>
+ </header>
+ <main>
+ <section class="catalog-intro">
+ <div><span class="eyebrow">UNIFIED API</span><h1>Models</h1></div>
+ <div class="catalog-stats" aria-live="polite"><span><strong id="model-count">0</strong> models</span><span><strong id="provider-count">0</strong> routes</span><span><strong id="protocol-count">0</strong> APIs</span></div>
+ </section>
+
+ <form class="catalog-filters" id="catalog-filters">
+ <label class="search-field">Search<input id="search" type="search" placeholder="Model, developer, or capability" autocomplete="off"></label>
+ <label>Protocol<select id="protocol"><option value="">All protocols</option></select></label>
+ <label>Input<select id="input"><option value="">Any input</option><option value="text">Text</option><option value="image">Image</option><option value="audio">Audio</option><option value="video">Video</option></select></label>
+ <label>Developer<select id="owner"><option value="">All developers</option></select></label>
+ <label>Sort<select id="sort"><option value="newest">Newest</option><option value="name">Name</option><option value="input_price">Lowest input price</option><option value="output_price">Lowest output price</option><option value="context">Largest context</option></select></label>
+ </form>
+
+ <div class="catalog-status hidden" id="catalog-error" role="alert"></div>
+ <div class="catalog-grid" id="catalog-grid" aria-live="polite"></div>
+ <div class="catalog-status hidden" id="catalog-empty">No models match these filters.</div>
+ </main>
+
+ <dialog id="model-dialog">
+ <article class="model-dialog">
+ <header><div><span class="eyebrow" id="detail-owner"></span><h2 id="detail-name"></h2><code id="detail-id"></code></div><button class="icon-button" id="close-dialog" type="button" aria-label="Close">&times;</button></header>
+ <p id="detail-description"></p>
+ <div class="detail-tags" id="detail-tags"></div>
+ <dl class="detail-grid" id="detail-grid"></dl>
+ <section class="price-estimator">
+ <div><span class="eyebrow">COST ESTIMATE</span><h3>Token estimate</h3></div>
+ <div class="estimate-inputs"><label>Input tokens<input id="estimate-input" type="number" min="0" step="100" value="1000"></label><label>Output tokens<input id="estimate-output" type="number" min="0" step="100" value="500"></label><output id="estimate-total"></output></div>
+ </section>
+ <footer><a class="button secondary" href="./">Sign in</a><a class="button primary" id="detail-register" href="./?auth=register">Start with this model</a></footer>
+ </article>
+ </dialog>
+</body>
+</html>
diff --git a/internal/adminui/assets/models.js b/internal/adminui/assets/models.js
new file mode 100644
index 0000000..4057ac5
--- /dev/null
+++ b/internal/adminui/assets/models.js
@@ -0,0 +1,126 @@
+'use strict';
+
+const state = { models: [], selected: null, registrationEnabled: false };
+const $ = selector => document.querySelector(selector);
+const esc = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[char]));
+const integer = value => new Intl.NumberFormat().format(Number(value || 0));
+const protocolName = value => ({chat_completions:'Chat Completions',responses:'Responses',messages:'Anthropic Messages'}[value] || value);
+const price = (micros, currency='usd') => new Intl.NumberFormat(undefined, {style:'currency', currency:String(currency).toUpperCase(), minimumFractionDigits:2, maximumFractionDigits:6}).format(Number(micros || 0) / 1_000_000);
+const date = value => value ? new Intl.DateTimeFormat(undefined, {year:'numeric',month:'short',day:'numeric'}).format(new Date(value)) : 'Not published';
+
+function healthLabel(model) {
+ if (model.health_status === 'unavailable') return ['Unavailable', 'unavailable'];
+ if (model.health_status === 'degraded') return [`${model.available_provider_count}/${model.provider_count} routes`, 'degraded'];
+ return ['Available', 'available'];
+}
+
+function renderStats(models) {
+ const protocols = new Set(models.flatMap(model => model.supported_wire_apis || []));
+ $('#model-count').textContent = integer(models.length);
+ $('#provider-count').textContent = integer(models.reduce((sum, model) => sum + Number(model.provider_count || 0), 0));
+ $('#protocol-count').textContent = integer(protocols.size);
+}
+
+function renderFilters() {
+ const owners = [...new Set(state.models.map(model => model.owned_by).filter(Boolean))].sort();
+ const protocols = [...new Set(state.models.flatMap(model => model.supported_wire_apis || []))].sort();
+ $('#owner').innerHTML = '<option value="">All developers</option>' + owners.map(owner => `<option value="${esc(owner)}">${esc(owner)}</option>`).join('');
+ $('#protocol').innerHTML = '<option value="">All protocols</option>' + protocols.map(item => `<option value="${esc(item)}">${esc(protocolName(item))}</option>`).join('');
+}
+
+function filteredModels() {
+ const query = $('#search').value.trim().toLowerCase();
+ const protocol = $('#protocol').value;
+ const input = $('#input').value;
+ const owner = $('#owner').value;
+ const result = state.models.filter(model => {
+ const haystack = [model.public_id, model.display_name, model.description, model.owned_by, ...(model.capabilities || []), ...(model.aliases || [])].join(' ').toLowerCase();
+ return (!query || haystack.includes(query)) && (!protocol || (model.supported_wire_apis || []).includes(protocol)) && (!input || (model.input_modalities || []).includes(input)) && (!owner || model.owned_by === owner);
+ });
+ const sort = $('#sort').value;
+ result.sort((a,b) => {
+ if (sort === 'name') return String(a.display_name || a.public_id).localeCompare(String(b.display_name || b.public_id));
+ if (sort === 'input_price') return Number(a.input_price_micros_per_million) - Number(b.input_price_micros_per_million);
+ if (sort === 'output_price') return Number(a.output_price_micros_per_million) - Number(b.output_price_micros_per_million);
+ if (sort === 'context') return Number(b.context_window) - Number(a.context_window);
+ return new Date(b.released_at || 0) - new Date(a.released_at || 0) || String(a.public_id).localeCompare(String(b.public_id));
+ });
+ return result;
+}
+
+function renderCatalog() {
+ const models = filteredModels();
+ $('#catalog-empty').classList.toggle('hidden', models.length !== 0);
+ $('#catalog-grid').innerHTML = models.map(model => {
+ const [health, healthClass] = healthLabel(model);
+ return `<article class="model-card">
+ <header><div><small>${esc(model.owned_by || 'Independent')}</small><h2>${esc(model.display_name || model.public_id)}</h2></div><span class="health ${healthClass}">${esc(health)}</span></header>
+ <code>${esc(model.public_id)}</code>
+ <p>${esc(model.description || 'No description published.')}</p>
+ <div class="model-tags">${(model.supported_wire_apis || []).map(item => `<span>${esc(protocolName(item))}</span>`).join('')}${(model.input_modalities || []).map(item => `<span>${esc(item)}</span>`).join('')}</div>
+ <dl><div><dt>Input</dt><dd>${price(model.input_price_micros_per_million, model.price_currency)} / 1M</dd></div><div><dt>Output</dt><dd>${price(model.output_price_micros_per_million, model.price_currency)} / 1M</dd></div><div><dt>Context</dt><dd>${integer(model.context_window)}</dd></div></dl>
+ <button class="button secondary" type="button" data-model="${esc(model.public_id)}">View model</button>
+ </article>`;
+ }).join('');
+}
+
+function renderEstimate() {
+ if (!state.selected) return;
+ const input = Math.max(0, Number($('#estimate-input').value || 0));
+ const output = Math.max(0, Number($('#estimate-output').value || 0));
+ const totalMicros = (Number(state.selected.input_price_micros_per_million || 0) * input + Number(state.selected.output_price_micros_per_million || 0) * output) / 1_000_000;
+ $('#estimate-total').textContent = `${price(totalMicros, state.selected.price_currency)} estimated`;
+}
+
+function openModel(publicID) {
+ const model = state.models.find(item => item.public_id === publicID);
+ if (!model) return;
+ state.selected = model;
+ const [health, healthClass] = healthLabel(model);
+ $('#detail-owner').textContent = model.owned_by || 'Independent';
+ $('#detail-name').textContent = model.display_name || model.public_id;
+ $('#detail-id').textContent = model.public_id;
+ $('#detail-description').textContent = model.description || 'No description published.';
+ $('#detail-tags').innerHTML = [...(model.supported_wire_apis || []).map(protocolName), ...(model.capabilities || []), ...(model.input_modalities || []).map(item => `${item} input`), ...(model.output_modalities || []).map(item => `${item} output`)].map(item => `<span>${esc(item)}</span>`).join('');
+ const rows = [
+ ['Status', `<span class="health ${healthClass}">${esc(health)}</span>`],
+ ['Input price', `${price(model.input_price_micros_per_million, model.price_currency)} / 1M tokens`],
+ ['Output price', `${price(model.output_price_micros_per_million, model.price_currency)} / 1M tokens`],
+ ['Cached input', `${price(model.cache_read_price_micros_per_million, model.price_currency)} / 1M tokens`],
+ ['Context window', `${integer(model.context_window)} tokens`],
+ ['Max output', `${integer(model.max_output_tokens)} tokens`],
+ ['Regions', esc((model.regions || []).join(', ') || 'Global')],
+ ['Released', esc(date(model.released_at))]
+ ];
+ $('#detail-grid').innerHTML = rows.map(([label,value]) => `<div><dt>${esc(label)}</dt><dd>${value}</dd></div>`).join('');
+ const register = $('#detail-register');
+ register.classList.toggle('hidden', !state.registrationEnabled);
+ register.href = `./?auth=register&model=${encodeURIComponent(model.public_id)}`;
+ renderEstimate();
+ $('#model-dialog').showModal();
+}
+
+async function start() {
+ try {
+ const response = await fetch('./api/public/models', {headers:{Accept:'application/json'}});
+ if (!response.ok) throw new Error(`Catalog request failed (${response.status})`);
+ const payload = await response.json();
+ state.models = Array.isArray(payload.data) ? payload.data : [];
+ state.registrationEnabled = Boolean(payload.registration_enabled);
+ $('#create-account').classList.toggle('hidden', !state.registrationEnabled);
+ renderStats(state.models);
+ renderFilters();
+ renderCatalog();
+ } catch (error) {
+ $('#catalog-error').textContent = error.message || 'The model catalog is temporarily unavailable.';
+ $('#catalog-error').classList.remove('hidden');
+ }
+}
+
+$('#catalog-filters').addEventListener('input', renderCatalog);
+$('#catalog-grid').addEventListener('click', event => { const button = event.target.closest('[data-model]'); if (button) openModel(button.dataset.model); });
+$('#close-dialog').addEventListener('click', () => $('#model-dialog').close());
+$('#model-dialog').addEventListener('click', event => { if (event.target === $('#model-dialog')) $('#model-dialog').close(); });
+$('#estimate-input').addEventListener('input', renderEstimate);
+$('#estimate-output').addEventListener('input', renderEstimate);
+start();
diff --git a/internal/adminui/assets/style.css b/internal/adminui/assets/style.css
index e712da3..b71dc20 100644
--- a/internal/adminui/assets/style.css
+++ b/internal/adminui/assets/style.css
@@ -1,18 +1,140 @@
:root { --bg:#f3f5f7; --panel:#fff; --ink:#18212b; --muted:#71808e; --line:#dce3e8; --accent:#146c94; --accent-soft:#e5f2f7; --danger:#b4494d; --shadow:0 8px 24px rgba(29,47,61,.06); font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
-* { box-sizing:border-box; } body { margin:0; color:var(--ink); background:var(--bg); font-size:14px; } button,input,select { font:inherit; } button { cursor:pointer; }
+* { box-sizing:border-box; } body { margin:0; color:var(--ink); background:var(--bg); font-size:14px; } button,input,select,textarea { font:inherit; } button { cursor:pointer; }
.auth-screen { min-height:100vh; display:grid; grid-template-columns:minmax(260px,1fr) minmax(360px,560px); background:#102a3a; } .auth-brand { color:#fff; display:flex; align-items:flex-start; gap:12px; padding:38px; } .auth-brand strong { display:block; font-size:18px; } .auth-brand small { display:block; color:#8ba9b9; font-size:10px; margin-top:3px; } .auth-panel { background:#fff; padding:clamp(30px,7vh,72px) clamp(28px,5vw,64px); overflow:auto; } .auth-tabs { display:flex; overflow:auto; border-bottom:1px solid var(--line); margin-bottom:34px; } .auth-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--muted); padding:11px 12px; white-space:nowrap; } .auth-tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; } .auth-pane { display:none; gap:18px; } .auth-pane.active { display:grid; } .auth-pane h1 { margin-bottom:8px; } .auth-pane .button { margin-top:4px; } .auth-error { color:var(--danger); min-height:20px; margin:18px 0 0; font-size:12px; } .auth-error.success { color:#187151; } .form-actions { display:flex; gap:8px; } .form-actions .button { flex:1; }
.topbar { height:72px; background:#102a3a; color:#fff; padding:0 32px; display:flex; align-items:center; justify-content:space-between; gap:24px; } .brand { display:flex; align-items:center; gap:11px; letter-spacing:0; } .brand-mark { width:32px; height:32px; display:grid; place-items:center; border:1px solid #8fd0df; color:#b8eef7; font-weight:800; } .brand strong { display:block; font-size:15px; } .brand small { color:#8ba9b9; font-size:9px; letter-spacing:0; } .session { display:flex; align-items:center; gap:12px; } .session div { display:flex; flex-direction:column; align-items:flex-end; gap:2px; } .session button { min-height:36px; border:1px solid #8fd0df; background:transparent; color:#b8eef7; padding:0 13px; font-weight:700; } .session button:hover { background:#18384b; } .state { color:#86d5ad; font-size:11px; text-transform:capitalize; } .actor-label { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#fff; font-size:12px; }
.shell { width:min(1240px,calc(100% - 48px)); margin:28px auto 60px; } .tabs { display:flex; flex-wrap:wrap; gap:4px; border-bottom:1px solid var(--line); margin-bottom:26px; } .tab { white-space:nowrap; border:0; background:transparent; color:var(--muted); padding:12px 15px; border-bottom:2px solid transparent; } .tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; }
.section { display:none; } .section.active { display:block; } .section-heading { display:flex; justify-content:space-between; align-items:flex-end; gap:20px; margin-bottom:19px; } .eyebrow { color:var(--accent); font-size:10px; letter-spacing:0; font-weight:800; } h1 { font-size:28px; line-height:1.1; margin:7px 0 0; letter-spacing:0; } h2 { margin:4px 0 0; font-size:20px; }
.metric-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:12px; } .metric { background:var(--panel); border:1px solid var(--line); padding:18px; box-shadow:var(--shadow); } .metric span,.metric small { display:block; color:var(--muted); } .metric strong { display:block; font-size:28px; margin:12px 0 3px; font-weight:750; } .metric small { font-size:11px; }
.panel { background:var(--panel); border:1px solid var(--line); box-shadow:var(--shadow); padding:20px; margin-bottom:16px; } .note,.warning { display:flex; align-items:flex-start; gap:12px; } .note-icon { flex:0 0 22px; height:22px; border:1px solid var(--accent); color:var(--accent); display:grid; place-items:center; font-weight:700; } .note p { margin:5px 0 0; color:var(--muted); } .warning { color:#6d5523; background:#fff9e9; border-color:#ead9a9; box-shadow:none; } .warning span { margin-left:8px; color:#887650; }
-.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); align-items:end; gap:13px; } label { display:flex; flex-direction:column; gap:7px; color:var(--muted); font-size:12px; font-weight:650; } input,select { width:100%; border:1px solid var(--line); background:#fff; color:var(--ink); padding:10px 11px; min-height:40px; outline:none; } input:focus,select:focus { border-color:#69a9bf; box-shadow:0 0 0 3px var(--accent-soft); } .button { border:1px solid transparent; min-height:40px; padding:0 15px; font-weight:700; } .button.primary { color:#fff; background:var(--accent); } .button.primary:hover { background:#0d5879; } .button.secondary { color:var(--accent); background:var(--accent-soft); border-color:#c5e1ea; } .button.subtle { color:var(--accent); background:#fff; border-color:var(--line); grid-column:1; }
-.table-wrap { overflow:auto; padding:0; } table { width:100%; border-collapse:collapse; min-width:700px; } th,td { padding:14px 18px; text-align:left; border-bottom:1px solid var(--line); vertical-align:middle; } th { color:var(--muted); font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0; background:#fbfcfd; } tbody tr:last-child td { border-bottom:0; } td { font-size:13px; } code { font-family:"SFMono-Regular",Consolas,monospace; font-size:12px; color:#486071; } .badge,.tag { display:inline-flex; align-items:center; padding:4px 7px; font-size:11px; line-height:1; } .badge { border:1px solid #d9e0e4; color:var(--muted); } .badge.active { color:#187151; background:#e9f7f0; border-color:#c7e9d9; } .badge.revoked,.badge.suspended { color:var(--danger); background:#fff0f0; border-color:#f0cccc; } .tag { color:#4c6572; background:#eef3f5; margin:2px 3px 2px 0; } .text-button { border:0; background:transparent; color:var(--accent); padding:5px 0; } .text-button.danger { color:var(--danger); } .empty { color:var(--muted); text-align:center; padding:32px; } .truncate { max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); align-items:end; gap:13px; } label { display:flex; flex-direction:column; gap:7px; color:var(--muted); font-size:12px; font-weight:650; } input,select,textarea { width:100%; border:1px solid var(--line); background:#fff; color:var(--ink); padding:10px 11px; min-height:40px; outline:none; } textarea { resize:vertical; line-height:1.5; } input:focus,select:focus,textarea:focus { border-color:#69a9bf; box-shadow:0 0 0 3px var(--accent-soft); } .button { border:1px solid transparent; min-height:40px; padding:0 15px; font-weight:700; } .button.primary { color:#fff; background:var(--accent); } .button.primary:hover { background:#0d5879; } .button.secondary { color:var(--accent); background:var(--accent-soft); border-color:#c5e1ea; } .button.subtle { color:var(--accent); background:#fff; border-color:var(--line); grid-column:1; }
+.table-wrap { overflow:auto; padding:0; } table { width:100%; border-collapse:collapse; min-width:700px; } th,td { padding:14px 18px; text-align:left; border-bottom:1px solid var(--line); vertical-align:middle; } th { color:var(--muted); font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0; background:#fbfcfd; } tbody tr:last-child td { border-bottom:0; } td { font-size:13px; } code { font-family:"SFMono-Regular",Consolas,monospace; font-size:12px; color:#486071; } .badge,.tag { display:inline-flex; align-items:center; padding:4px 7px; font-size:11px; line-height:1; } .badge { border:1px solid #d9e0e4; color:var(--muted); } .badge.active { color:#187151; background:#e9f7f0; border-color:#c7e9d9; } .badge.revoked,.badge.suspended,.badge.expired,.badge.unavailable { color:var(--danger); background:#fff0f0; border-color:#f0cccc; } .badge.degraded { color:#765b16; background:#fff8dc; border-color:#e9d990; } .tag { color:#4c6572; background:#eef3f5; margin:2px 3px 2px 0; } .text-button { border:0; background:transparent; color:var(--accent); padding:5px 0; } .text-button.danger { color:var(--danger); } .empty { color:var(--muted); text-align:center; padding:32px; } .truncate { max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.route-editor { grid-column:1/-1; display:flex; flex-direction:column; gap:8px; } .route-row { display:grid; grid-template-columns:1.4fr 1.4fr .6fr .6fr 34px; gap:8px; } .icon-button { border:1px solid var(--line); background:#fff; color:var(--muted); width:34px; height:34px; font-size:19px; } .route-list { display:flex; flex-direction:column; gap:3px; color:#486071; font-size:12px; } .route-list em { color:var(--muted); font-style:normal; margin-left:4px; }
.hidden { display:none !important; } .visually-hidden { position:absolute !important; width:1px !important; height:1px !important; padding:0 !important; margin:-1px !important; overflow:hidden !important; clip:rect(0,0,0,0) !important; white-space:nowrap !important; border:0 !important; } .billing-actions { display:grid; grid-template-columns:1fr 1fr; gap:16px; } .compact-form { grid-template-columns:1fr 1fr; } .compact-form .button { grid-column:1/-1; } .currency-label { color:var(--muted); font-size:12px; text-transform:uppercase; } .ledger-heading { margin-top:28px; } .money-positive { color:#187151; } .money-negative { color:var(--danger); }
.account-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; } .account-grid .panel { align-content:start; } #totp-qr { width:180px; height:180px; border:1px solid var(--line); } #totp-secret { overflow-wrap:anywhere; } .table-wrap > .section-heading { padding:18px; margin:0; align-items:center; border-bottom:1px solid var(--line); }
.price-line { display:block; color:var(--muted); font-size:10px; margin-top:5px; white-space:nowrap; }
+.onboarding { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; padding:12px; }
+.onboarding-step { display:flex; flex-direction:column; gap:7px; min-height:112px; padding:14px; border:1px solid var(--line); background:#fbfcfd; text-align:left; color:var(--ink); }
+.onboarding-step strong { font-size:13px; }
+.onboarding-step small { color:var(--muted); line-height:1.45; flex:1; }
+.onboarding-step.done { border-color:#b9e1cf; background:#f0fbf5; }
+.onboarding-step.done .step-state { color:#187151; }
+.step-state { color:var(--accent); font-size:11px; font-weight:750; }
+.quick-access-grid { display:grid; grid-template-columns:minmax(320px,.85fr) minmax(0,1.4fr); gap:16px; }
+.starter-key-panel,.endpoint-panel { align-content:start; }
+.starter-key-fields { display:grid; grid-template-columns:1fr 1fr auto; gap:12px; align-items:end; }
+.starter-key-panel .muted { margin-bottom:0; }
+.endpoint-list { display:grid; gap:0; }
+.endpoint-row { display:grid; grid-template-columns:150px minmax(0,1fr) auto; align-items:center; gap:12px; padding:9px 0; border-bottom:1px solid var(--line); }
+.endpoint-row span { color:var(--muted); font-size:12px; }
+.endpoint-row code { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.endpoint-env { margin:14px 0 0; padding:13px; background:#102a3a; overflow:auto; white-space:pre-wrap; word-break:break-word; }
+.endpoint-env code { color:#e9f7fb; line-height:1.55; }
+.quickstart-grid { display:grid; grid-template-columns:minmax(0,1.5fr) minmax(260px,1fr); gap:16px; }
+.quickstart-code pre { margin:17px 0 0; min-height:220px; background:#102a3a; color:#e9f7fb; padding:17px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
+.quickstart-code pre code { color:inherit; font-size:12px; line-height:1.6; }
+.quickstart-next { align-content:start; }
+.playground { margin-top:0; }
+.playground .section-heading { align-items:center; }
+.playground-fields { display:grid; grid-template-columns:1.4fr 1fr 1fr 1fr .72fr; align-items:end; gap:13px; }
+.playground-key { grid-column:span 2; }
+.playground-prompt { grid-column:span 4; }
+.playground-prompt textarea { min-height:112px; }
+.playground-actions { display:grid; gap:13px; }
+.playground-result { margin-top:20px; padding-top:18px; border-top:1px solid var(--line); }
+.playground-meta { display:flex; flex-wrap:wrap; align-items:center; gap:8px 16px; color:var(--muted); font-size:11px; }
+.playground-result pre { margin:12px 0 0; min-height:160px; max-height:440px; background:#102a3a; color:#e9f7fb; padding:17px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
+.playground-result pre code { color:inherit; font-size:12px; line-height:1.6; }
+.playground-diagnostic { display:flex; justify-content:space-between; align-items:center; gap:16px; margin-top:12px; padding:13px 15px; border:1px solid #e5c4c4; background:#fff6f6; color:#713b3b; }
+.playground-diagnostic strong { font-size:12px; }
+.playground-diagnostic p { margin:5px 0 0; color:#7b5a5a; font-size:12px; line-height:1.45; }
+.preferences-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
+.preferences-grid .panel { align-content:start; }
+.preferences-grid h2,.preferences-grid .form-note { grid-column:1/-1; }
+.toggle-row { grid-column:1/-1; flex-direction:row; align-items:center; min-height:40px; }
+.toggle-row input { width:18px; min-height:18px; flex:0 0 18px; }
+.form-note { margin:0; line-height:1.5; }
+.signal-list { display:grid; gap:10px; margin-top:16px; }
+.signal { display:flex; justify-content:space-between; align-items:center; gap:12px; border-bottom:1px solid var(--line); padding-bottom:10px; }
+.signal:last-child { border-bottom:0; }
+.signal span { color:var(--muted); font-size:12px; }
+.catalog-toolbar { display:grid; grid-template-columns:2fr repeat(4,minmax(0,1fr)); gap:13px; }
+.catalog-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; }
+.catalog-card { display:flex; flex-direction:column; gap:12px; background:var(--panel); border:1px solid var(--line); padding:18px; box-shadow:var(--shadow); min-width:0; }
+.catalog-card h2 { font-size:17px; overflow-wrap:anywhere; }
+.catalog-card .model-id { color:var(--muted); font-family:"SFMono-Regular",Consolas,monospace; font-size:12px; overflow-wrap:anywhere; }
+.catalog-card p { color:#526673; line-height:1.5; margin:0; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
+.provider-summary { display:flex; align-items:center; gap:8px; min-height:24px; }
+.provider-summary small { color:var(--muted); line-height:1.35; }
+.catalog-meta { display:flex; flex-wrap:wrap; gap:5px; }
+.catalog-price { color:#187151; font-weight:750; font-size:12px; }
+.catalog-card .button { margin-top:auto; align-self:flex-start; }
+.catalog-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:auto; }
+.catalog-actions .button { margin-top:0; }
+.usage-filters { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); align-items:end; gap:12px; }
+.usage-request-filter { grid-column:span 2; }
+.usage-filters .form-actions { grid-column:span 2; }
+.usage-metrics { margin-bottom:16px; }
+.usage-chart { height:220px; overflow:auto; }
+.chart-bars { min-width:680px; height:176px; display:flex; align-items:stretch; gap:7px; }
+.chart-day { flex:1 0 22px; min-width:22px; display:grid; grid-template-rows:1fr 28px; gap:7px; text-align:center; }
+.chart-bar { height:140px; display:flex; align-items:flex-end; background:#f3f6f7; border-bottom:1px solid var(--line); }
+.chart-bar span { display:block; width:100%; min-height:4px; background:var(--accent); }
+.chart-height-1 { height:5%; } .chart-height-2 { height:10%; } .chart-height-3 { height:15%; } .chart-height-4 { height:20%; }
+.chart-height-5 { height:25%; } .chart-height-6 { height:30%; } .chart-height-7 { height:35%; } .chart-height-8 { height:40%; }
+.chart-height-9 { height:45%; } .chart-height-10 { height:50%; } .chart-height-11 { height:55%; } .chart-height-12 { height:60%; }
+.chart-height-13 { height:65%; } .chart-height-14 { height:70%; } .chart-height-15 { height:75%; } .chart-height-16 { height:80%; }
+.chart-height-17 { height:85%; } .chart-height-18 { height:90%; } .chart-height-19 { height:95%; } .chart-height-20 { height:100%; }
+.chart-day small { color:var(--muted); font-size:9px; white-space:nowrap; overflow:hidden; }
+.analytics-grid { display:grid; grid-template-columns:1fr; gap:16px; margin:16px 0; }
+.analytics-panel { padding-top:0; }
+.analytics-panel .section-heading { min-width:760px; margin:0; padding:18px 18px 14px; }
+.analytics-table { min-width:960px; }
+.positive { color:#187151; }
+.auto-topup-panel .section-heading { align-items:center; }
+.billing-profile-panel { margin-top:16px; }
+.billing-profile-panel .section-heading { align-items:center; }
+.billing-profile-panel .form-grid { grid-template-columns:repeat(4,minmax(0,1fr)); }
+.billing-profile-panel .billing-address-wide { grid-column:span 2; }
+.billing-profile-actions { grid-column:1/-1; justify-content:space-between; align-items:center; }
+.billing-profile-actions .error-label { margin:0; }
+.billing-profile-panel input[name="country"] { text-transform:uppercase; }
+.auto-topup-panel .form-grid { grid-template-columns:1.2fr 1fr 1fr; }
+.auto-topup-panel .toggle-row { grid-column:1/-1; }
+.auto-topup-actions { align-items:end; }
+.auto-topup-actions .button { flex:1; }
+#auto-topup-payment-method { display:block; margin-top:8px; font-size:13px; overflow-wrap:anywhere; }
+.key-form .key-model-picker { grid-column:span 2; }
+.key-model-picker select { min-height:126px; }
+.key-model-picker small { color:var(--muted); font-weight:400; line-height:1.4; }
+.key-form .button.primary { align-self:end; }
+.keys-table { min-width:1220px; }
+.key-tags { display:flex; flex-wrap:wrap; margin-top:5px; }
.muted,.error-label { display:block; color:var(--muted); font-size:11px; margin-top:4px; } .error-label { color:var(--danger); } .limits-table input { min-width:118px; padding:8px 9px; } .limits-table .button { min-height:36px; } input:disabled,select:disabled { background:#f5f7f8; color:#697985; cursor:not-allowed; }
.toast { position:fixed; bottom:24px; right:24px; background:#102a3a; color:#fff; padding:12px 16px; opacity:0; transform:translateY(8px); pointer-events:none; transition:.2s; } .toast.visible { opacity:1; transform:none; } .toast.error { background:#8f3d42; } dialog { border:0; padding:0; width:min(460px,calc(100% - 32px)); box-shadow:0 18px 70px rgba(0,0,0,.22); } dialog::backdrop { background:rgba(16,42,58,.45); } .dialog-content { padding:24px; } .dialog-content p { color:var(--muted); } .dialog-content code { display:block; background:#f3f5f7; padding:15px; overflow:auto; color:var(--ink); margin:18px 0; white-space:pre-wrap; }
-@media (max-width:900px) { .metric-grid { grid-template-columns:repeat(3,1fr); } .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .form-grid .button.primary { grid-column:1/-1; } .billing-actions,.account-grid { grid-template-columns:1fr; } }
-@media (max-width:620px) { .auth-screen { grid-template-columns:1fr; background:#fff; } .auth-brand { background:#102a3a; padding:22px; } .auth-panel { padding:28px 22px 50px; } .auth-tabs { overflow:auto; } .topbar { height:auto; padding:16px; align-items:flex-start; } .session { margin-left:auto; } .session div { align-items:flex-end; max-width:150px; } .session .actor-label { max-width:150px; } .shell { width:calc(100% - 24px); margin-top:18px; } .tabs { margin-bottom:20px; flex-wrap:nowrap; overflow:auto; } .metric-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .metric { padding:14px; } .metric strong { font-size:22px; overflow-wrap:anywhere; } .form-grid { grid-template-columns:1fr; } .route-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr) 34px; } .route-provider,.route-upstream { grid-column:1/-1; } h1 { font-size:24px; } .section-heading { align-items:flex-start; } }
+#model-dialog { width:min(680px,calc(100vw - 32px)); }
+#usage-dialog { width:min(760px,calc(100vw - 32px)); }
+.model-dialog-content { width:100%; max-height:calc(100vh - 32px); overflow:auto; }
+.model-dialog-id { display:flex; justify-content:space-between; align-items:center; gap:12px; padding:10px 0 16px; border-bottom:1px solid var(--line); }
+.model-dialog-id code { margin:0; padding:0; background:none; overflow-wrap:anywhere; }
+.model-detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 20px; margin:16px 0; }
+.model-detail-row { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; padding:10px 0; border-bottom:1px solid var(--line); min-width:0; }
+.model-detail-row span { color:var(--muted); font-size:12px; flex:0 0 auto; }
+.model-detail-row strong { text-align:right; overflow-wrap:anywhere; font-size:12px; }
+.model-provider-health { border-top:1px solid var(--line); padding-top:16px; margin-bottom:18px; }
+.model-provider-health .section-heading { margin-bottom:10px; }
+.model-provider-health h3 { margin:0; font-size:15px; }
+.model-provider-health small { display:block; margin-top:4px; color:var(--muted); }
+.provider-health-table { min-width:620px; border:1px solid var(--line); }
+.provider-health-table th,.provider-health-table td { padding:10px 12px; }
+.provider-retry { max-width:155px; line-height:1.3; }
+.model-estimator { border-top:1px solid var(--line); padding-top:16px; }
+.model-estimator h3 { margin:0 0 12px; font-size:15px; }
+.model-estimator .form-grid { grid-template-columns:repeat(4,minmax(0,1fr)); }
+.model-estimate { grid-column:1/-1; align-self:end; color:#187151; font-weight:750; min-height:40px; display:flex; align-items:center; }
+.model-dialog-actions { margin-top:18px; justify-content:flex-end; }
+.request-dialog-content { width:100%; max-height:calc(100vh - 32px); overflow:auto; }
+.request-detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 20px; margin:16px 0; }
+.request-diagnostic { margin:16px 0 0; max-height:260px; background:#102a3a; padding:15px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
+.request-diagnostic code { color:#e9f7fb; margin:0; padding:0; background:transparent; }
+@media (max-width:900px) { .metric-grid { grid-template-columns:repeat(3,1fr); } .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .form-grid .button.primary { grid-column:1/-1; } .billing-actions,.account-grid,.quickstart-grid,.quick-access-grid,.preferences-grid { grid-template-columns:1fr; } .catalog-toolbar { grid-template-columns:repeat(2,minmax(0,1fr)); } .catalog-toolbar label:first-child { grid-column:1/-1; } .catalog-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .onboarding { grid-template-columns:repeat(2,minmax(0,1fr)); } .usage-filters { grid-template-columns:repeat(3,minmax(0,1fr)); } .auto-topup-panel .form-grid,.billing-profile-panel .form-grid { grid-template-columns:1fr 1fr; } .playground-fields { grid-template-columns:1fr 1fr; } .playground-key,.playground-prompt { grid-column:1/-1; } }
+@media (max-width:620px) { .auth-screen { grid-template-columns:1fr; background:#fff; } .auth-brand { background:#102a3a; padding:22px; } .auth-panel { padding:28px 22px 50px; } .auth-tabs { overflow:auto; } .topbar { height:auto; padding:16px; align-items:flex-start; } .session { margin-left:auto; } .session div { align-items:flex-end; max-width:150px; } .session .actor-label { max-width:150px; } .shell { width:calc(100% - 24px); margin-top:18px; } .tabs { margin-bottom:20px; flex-wrap:nowrap; overflow:auto; } .metric-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .metric { padding:14px; } .metric strong { font-size:22px; overflow-wrap:anywhere; } .form-grid,.catalog-toolbar,.usage-filters,.auto-topup-panel .form-grid,.billing-profile-panel .form-grid,.playground-fields,.starter-key-fields { grid-template-columns:1fr; } .catalog-toolbar label:first-child,.usage-request-filter,.usage-filters .form-actions,.key-form .key-model-picker,.billing-profile-panel .billing-address-wide,.playground-key,.playground-prompt { grid-column:auto; } .endpoint-row { grid-template-columns:1fr auto; } .endpoint-row code { grid-column:1/-1; grid-row:2; white-space:normal; overflow-wrap:anywhere; } .route-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr) 34px; } .route-provider,.route-upstream { grid-column:1/-1; } h1 { font-size:24px; } .section-heading { align-items:flex-start; } .onboarding { grid-template-columns:1fr; } .catalog-grid { grid-template-columns:1fr; } .quickstart-code pre { min-height:250px; } .auto-topup-actions,.billing-profile-actions { flex-direction:column; align-items:stretch; } .playground-meta { align-items:flex-start; flex-direction:column; } .playground-diagnostic { align-items:flex-start; flex-direction:column; } .model-detail-grid,.request-detail-grid,.model-estimator .form-grid { grid-template-columns:1fr; } .model-dialog-actions { flex-direction:column; align-items:stretch; } }