@jacobbd/relay-ai 0.4.8 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,8 @@ import {
3
3
  getProviderModelPage,
4
4
  PROVIDER_MODEL_PAGE_SIZE,
5
5
  } from './provider-model-browser.js';
6
+ import { copyDeviceCode, oauthConnectionLabel } from './oauth-device.js';
7
+ import { providerInitial, providerLogoHtml } from './provider-logo.js';
6
8
 
7
9
  // ─── State ───────────────────────────────────────────────────────────────────
8
10
 
@@ -56,53 +58,6 @@ const state = {
56
58
  },
57
59
  };
58
60
 
59
- // ─── Provider logos via Simple Icons CDN ─────────────────────────────────────
60
- // https://simpleicons.org — monochrome SVG brand marks, white on dark bg.
61
- // Format: https://cdn.simpleicons.org/{slug}/ffffff
62
-
63
- // Inline SVGs for providers whose official domains block hotlinking.
64
- // White fill, renders on gradient background.
65
- const PROVIDER_INLINE_SVGS = {
66
- openai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.896zm16.597 3.855l-5.843-3.368L15.115 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.403-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>`,
67
- kilo: `<svg viewBox="0 0 24 24" fill="white" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg" style="width:22px;height:22px"><title>Kilo Code</title><path d="M0 0v24h24V0H0zm22.222 22.222H1.778V1.778h20.444v20.444zm-7.555-4.964h2.222v1.778h-2.794L12.89 17.83v-2.794h1.778v2.222zm4 0h-1.778v-2.222h-2.222v-1.778h2.793l1.207 1.207v2.793zm-7.556-2.591H9.333v-1.778h1.778v1.778zm-5.778-1.778h1.778v4h4v1.778H6.54L5.333 17.46V12.89zm13.334-3.556v1.778h-5.778V9.333h1.987V7.111h-1.987V5.333h2.558l1.206 1.207v2.793h2.014zm-11.556-2h2.222l1.778 1.778v2H9.333v-2H7.111v2H5.333V5.333h1.778v2zm4 0H9.333v-2h1.778v2z"/></svg>`,
68
- xai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>`,
69
- 'xai-oauth': `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>`,
70
- };
71
-
72
- // Verified working logo URLs. Slugs tested against cdn.simpleicons.org — 404s removed.
73
- // Missing: openai, groq, cohere, togetherai, fireworks, cerebras → inline SVG or gradient letter fallback.
74
- const PROVIDER_LOGO_URLS = {
75
- // OpenCode — official favicon (proper square icon)
76
- zen: 'https://opencode.ai/favicon.ico',
77
- go: 'https://opencode.ai/favicon.ico',
78
- // Verified Simple Icons slugs (200 responses confirmed)
79
- anthropic: 'https://cdn.simpleicons.org/anthropic/ffffff',
80
- google: 'https://cdn.simpleicons.org/google/ffffff',
81
- nvidia: 'https://cdn.simpleicons.org/nvidia/ffffff',
82
- deepseek: 'https://cdn.simpleicons.org/deepseek/ffffff',
83
- mistral: 'https://cdn.simpleicons.org/mistralai/ffffff',
84
- ollama: 'https://cdn.simpleicons.org/ollama/ffffff',
85
- openrouter: 'https://cdn.simpleicons.org/openrouter/ffffff',
86
- perplexity: 'https://cdn.simpleicons.org/perplexity/ffffff',
87
- huggingface: 'https://cdn.simpleicons.org/huggingface/ffffff',
88
- cloudflare: 'https://cdn.simpleicons.org/cloudflare/ffffff',
89
- azure: 'https://cdn.simpleicons.org/microsoftazure/ffffff',
90
- vertex: 'https://cdn.simpleicons.org/googlecloud/ffffff',
91
- bedrock: 'https://cdn.simpleicons.org/amazonaws/ffffff',
92
- // xai / xai-oauth → inline SVG in PROVIDER_INLINE_SVGS
93
- // openai, groq, cohere, togetherai, fireworks, cerebras → no verified slug, use gradient letter
94
- };
95
-
96
- function getProviderLogoContent(providerId) {
97
- const base = providerId.replace(/-oauth$/, '').replace(/-api$/, '');
98
- // Inline SVG takes priority (for providers that block hotlinking)
99
- const svg = PROVIDER_INLINE_SVGS[providerId] ?? PROVIDER_INLINE_SVGS[base];
100
- if (svg) return { type: 'svg', content: svg };
101
- const url = PROVIDER_LOGO_URLS[providerId] ?? PROVIDER_LOGO_URLS[base];
102
- if (url) return { type: 'img', content: url };
103
- return null;
104
- }
105
-
106
61
  // ─── Neon model colors (unique per model ID) ──────────────────────────────────
107
62
 
108
63
  const NEONS = [
@@ -213,10 +168,6 @@ function providerPalette(id) {
213
168
  return PALETTES[Math.abs(hash) % PALETTES.length];
214
169
  }
215
170
 
216
- function providerInitial(name) {
217
- return (name || '?').replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || '?';
218
- }
219
-
220
171
  // ─── API ─────────────────────────────────────────────────────────────────────
221
172
 
222
173
  async function api(method, path, body) {
@@ -437,8 +388,7 @@ function renderProviders() {
437
388
  function buildProviderCard(provider) {
438
389
  const [c1, c2] = providerPalette(provider.id);
439
390
  const displayName = getProviderName(provider.id);
440
- const initial = providerInitial(displayName);
441
- const logo = getProviderLogoContent(provider.id);
391
+ const connectionLabel = oauthConnectionLabel(provider);
442
392
  const card = document.createElement('div');
443
393
  card.className = 'provider-card';
444
394
  card.dataset.id = provider.id;
@@ -449,21 +399,15 @@ function buildProviderCard(provider) {
449
399
  header.setAttribute('tabindex', '0');
450
400
  header.setAttribute('aria-label', `View models from ${displayName}`);
451
401
 
452
- const logoHtml = logo?.type === 'svg'
453
- ? logo.content
454
- : logo?.type === 'img'
455
- ? `<img src="${logo.content}" class="provider-logo-img" alt="${escapeHtml(displayName)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><span class="provider-logo-fallback" style="display:none">${initial}</span>`
456
- : initial;
457
-
458
402
  header.innerHTML = `
459
- <div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${logoHtml}</div>
403
+ <div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${providerLogoHtml(provider.id, displayName)}</div>
460
404
  <div class="provider-info">
461
405
  <div class="provider-name">${escapeHtml(displayName)}</div>
462
406
  <div class="provider-models-count">${provider.modelCount ?? provider.models?.length ?? 0} models</div>
463
407
  </div>
464
408
  <div class="provider-status">
465
409
  <span class="status-chip ${provider.hasKey ? 'has-key' : provider.freeAccess ? 'free-access' : 'no-key'}">
466
- ${provider.hasKey ? 'Key stored' : provider.freeAccess ? 'Free models' : 'Not configured'}
410
+ ${escapeHtml(connectionLabel)}
467
411
  </span>
468
412
  <button class="provider-config-toggle" type="button" aria-label="Manage ${escapeHtml(displayName)} provider" aria-expanded="false" title="Manage provider">
469
413
  <svg class="provider-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
@@ -554,7 +498,7 @@ function renderProviderModelBrowser() {
554
498
  container.innerHTML = `
555
499
  <a class="provider-browser-back" href="#providers">← Providers &amp; Keys</a>
556
500
  <div class="provider-browser-hero">
557
- <div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${providerInitial(displayName)}</div>
501
+ <div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${providerLogoHtml(provider.id, displayName)}</div>
558
502
  <div>
559
503
  <div class="section-eyebrow">Provider catalog</div>
560
504
  <h1 class="section-heading">${escapeHtml(displayName)} <span class="heading-accent">models</span></h1>
@@ -629,13 +573,6 @@ function renderProviderModelBrowser() {
629
573
 
630
574
  function buildTemplateCard(template) {
631
575
  const [c1, c2] = providerPalette(template.id);
632
- const initial = providerInitial(template.name);
633
- const logo = getProviderLogoContent(template.id);
634
- const logoHtml = logo?.type === 'svg'
635
- ? logo.content
636
- : logo?.type === 'img'
637
- ? `<img src="${logo.content}" class="provider-logo-img" alt="${template.name}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><span class="provider-logo-fallback" style="display:none">${initial}</span>`
638
- : initial;
639
576
  const card = document.createElement('div');
640
577
  card.className = 'provider-card';
641
578
  card.dataset.id = template.id;
@@ -651,7 +588,7 @@ function buildTemplateCard(template) {
651
588
  : '';
652
589
 
653
590
  header.innerHTML = `
654
- <div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${logoHtml}</div>
591
+ <div class="provider-icon" style="background:linear-gradient(135deg,${c1},${c2})">${providerLogoHtml(template.id, template.name)}</div>
655
592
  <div class="provider-info">
656
593
  <div class="provider-name">${template.name}</div>
657
594
  <div class="provider-models-count">Not configured ${signupLink}</div>
@@ -797,88 +734,180 @@ function buildCustomEndpointBodyContent(template, card) {
797
734
  return content;
798
735
  }
799
736
 
737
+ function setOAuthFeedback(feedback, message, tone = '') {
738
+ feedback.replaceChildren();
739
+ feedback.className = `key-feedback${tone ? ` ${tone}` : ''}`;
740
+ feedback.textContent = message;
741
+ }
742
+
743
+ function createDeviceAuthorizationPanel(url, userCode) {
744
+ const panel = document.createElement('section');
745
+ panel.className = 'oauth-device-panel';
746
+ panel.setAttribute('aria-label', 'Device authorization');
747
+
748
+ const eyebrow = document.createElement('div');
749
+ eyebrow.className = 'oauth-device-eyebrow';
750
+ eyebrow.textContent = 'One-time device code';
751
+
752
+ const codeRow = document.createElement('div');
753
+ codeRow.className = 'oauth-device-code-row';
754
+
755
+ const code = document.createElement('code');
756
+ code.className = 'oauth-device-code';
757
+ code.textContent = userCode;
758
+ code.setAttribute('aria-label', `Device code ${userCode}`);
759
+
760
+ const copyButton = document.createElement('button');
761
+ copyButton.type = 'button';
762
+ copyButton.className = 'btn btn-ghost oauth-device-copy';
763
+ copyButton.textContent = 'Copy code';
764
+ copyButton.addEventListener('click', async () => {
765
+ try {
766
+ await copyDeviceCode(userCode);
767
+ copyButton.textContent = 'Copied ✓';
768
+ setTimeout(() => { copyButton.textContent = 'Copy code'; }, 1800);
769
+ } catch {
770
+ copyButton.textContent = 'Copy failed';
771
+ setTimeout(() => { copyButton.textContent = 'Copy code'; }, 1800);
772
+ }
773
+ });
774
+
775
+ codeRow.append(code, copyButton);
776
+
777
+ const instructions = document.createElement('ol');
778
+ instructions.className = 'oauth-device-steps';
779
+ for (const step of [
780
+ 'Copy the code above.',
781
+ 'Open the secure sign-in page.',
782
+ 'Paste the code there and approve access.',
783
+ ]) {
784
+ const item = document.createElement('li');
785
+ item.textContent = step;
786
+ instructions.appendChild(item);
787
+ }
788
+
789
+ const actions = document.createElement('div');
790
+ actions.className = 'oauth-device-actions';
791
+
792
+ const openButton = document.createElement('button');
793
+ openButton.type = 'button';
794
+ openButton.className = 'btn btn-primary';
795
+ openButton.textContent = 'Open sign-in page ↗';
796
+
797
+ const waiting = document.createElement('div');
798
+ waiting.className = 'oauth-device-waiting';
799
+ waiting.setAttribute('role', 'status');
800
+ waiting.textContent = 'Waiting for you to finish sign-in…';
801
+
802
+ openButton.addEventListener('click', () => {
803
+ window.open(url, '_blank', 'noopener,noreferrer');
804
+ waiting.textContent = 'Sign-in page opened. Paste the code, then return here…';
805
+ });
806
+
807
+ actions.append(openButton, waiting);
808
+ panel.append(eyebrow, codeRow, instructions, actions);
809
+ return panel;
810
+ }
811
+
812
+ async function beginDeviceOAuthFlow({ providerId, signInButton, refreshButton, feedback, onDone }) {
813
+ signInButton.disabled = true;
814
+ if (refreshButton) refreshButton.disabled = true;
815
+ setOAuthFeedback(feedback, 'Preparing a secure one-time code…', 'muted');
816
+
817
+ let startResult;
818
+ try {
819
+ startResult = await api('POST', '/api/providers/oauth/start', { providerId });
820
+ } catch {
821
+ setOAuthFeedback(feedback, 'Could not start sign-in. Check the connection and try again.', 'error');
822
+ signInButton.disabled = false;
823
+ if (refreshButton) refreshButton.disabled = false;
824
+ return;
825
+ }
826
+ if (startResult.error) {
827
+ setOAuthFeedback(feedback, startResult.error, 'error');
828
+ signInButton.disabled = false;
829
+ if (refreshButton) refreshButton.disabled = false;
830
+ return;
831
+ }
832
+
833
+ const { sessionId, url, userCode } = startResult;
834
+ if (!sessionId || !url || !userCode) {
835
+ setOAuthFeedback(feedback, 'The provider did not return a usable device code. Please try again.', 'error');
836
+ signInButton.disabled = false;
837
+ if (refreshButton) refreshButton.disabled = false;
838
+ return;
839
+ }
840
+
841
+ feedback.replaceChildren(createDeviceAuthorizationPanel(url, userCode));
842
+ feedback.className = 'key-feedback oauth-device-host';
843
+
844
+ let pollInFlight = false;
845
+ const pollInterval = setInterval(async () => {
846
+ if (pollInFlight) return;
847
+ pollInFlight = true;
848
+ try {
849
+ const statusResult = await api('GET', `/api/providers/oauth/status?sessionId=${encodeURIComponent(sessionId)}`);
850
+ if (statusResult.status === 'done') {
851
+ clearInterval(pollInterval);
852
+ setOAuthFeedback(feedback, '✓ Signed in successfully', 'success');
853
+ try {
854
+ await onDone();
855
+ } catch {
856
+ setOAuthFeedback(feedback, 'Signed in, but the provider list could not reload. Refresh the page to continue.', 'error');
857
+ }
858
+ } else if (statusResult.status === 'error' || statusResult.error) {
859
+ clearInterval(pollInterval);
860
+ const message = statusResult.status === 'error'
861
+ ? (statusResult.error ?? 'Authorization failed')
862
+ : 'Session expired — please try again';
863
+ setOAuthFeedback(feedback, message, 'error');
864
+ signInButton.disabled = false;
865
+ if (refreshButton) refreshButton.disabled = false;
866
+ }
867
+ } catch {
868
+ clearInterval(pollInterval);
869
+ setOAuthFeedback(feedback, 'Lost contact with the sign-in session. Please try again.', 'error');
870
+ signInButton.disabled = false;
871
+ if (refreshButton) refreshButton.disabled = false;
872
+ } finally {
873
+ pollInFlight = false;
874
+ }
875
+ }, 2000);
876
+ }
877
+
800
878
  function buildOAuthTemplateBodyContent(template) {
801
879
  const content = document.createElement('div');
802
880
  content.className = 'provider-body-content';
803
881
 
804
882
  const note = document.createElement('div');
805
- note.style.cssText = 'font-size:13px;color:var(--text-secondary,#aaa);margin-bottom:10px';
806
- note.textContent = 'Sign in via device code — no API key required.';
883
+ note.className = 'oauth-device-note';
884
+ note.textContent = 'Sign in with a one-time device code — no API key required.';
807
885
 
808
886
  const actionRow = document.createElement('div');
809
887
  actionRow.className = 'key-row';
810
888
 
811
889
  const signInBtn = document.createElement('button');
812
890
  signInBtn.className = 'btn btn-primary';
813
- signInBtn.textContent = 'Sign in';
891
+ signInBtn.textContent = 'Get sign-in code';
814
892
 
815
893
  const feedback = document.createElement('div');
816
894
  feedback.className = 'key-feedback';
817
895
 
818
896
  actionRow.appendChild(signInBtn);
819
- content.appendChild(note);
820
- content.appendChild(actionRow);
821
- content.appendChild(feedback);
822
-
823
- let pollInterval = null;
824
- function stopPolling() { if (pollInterval) { clearInterval(pollInterval); pollInterval = null; } }
825
-
826
- signInBtn.addEventListener('click', async () => {
827
- stopPolling();
828
- signInBtn.disabled = true;
829
- feedback.textContent = 'Starting authorization…';
830
- feedback.className = 'key-feedback muted';
831
-
832
- const startResult = await api('POST', '/api/providers/oauth/start', { providerId: template.id });
833
- if (startResult.error) {
834
- feedback.textContent = startResult.error;
835
- feedback.className = 'key-feedback error';
836
- signInBtn.disabled = false;
837
- return;
838
- }
839
-
840
- const { sessionId } = startResult;
841
- if (startResult.pkce) {
842
- // PKCE / browser-redirect flow — open auth URL, poll for completion.
843
- const { authUrl } = startResult;
844
- window.open(authUrl, '_blank');
845
- feedback.innerHTML = `Browser opened for authorization.<br>
846
- <span style="opacity:0.6;font-size:12px">Complete sign-in in the browser window, then return here…</span>`;
847
- feedback.className = 'key-feedback muted';
848
- } else {
849
- // Device code flow — show URL and code.
850
- const { url, userCode } = startResult;
851
- window.open(url, '_blank');
852
- feedback.innerHTML = `Go to <a href="${url}" target="_blank" style="color:inherit">${url}</a><br>
853
- Enter code: <strong style="letter-spacing:0.1em;font-size:15px">${userCode}</strong><br>
854
- <span style="opacity:0.6;font-size:12px">Waiting for authorization…</span>`;
855
- feedback.className = 'key-feedback muted';
856
- }
857
-
858
- pollInterval = setInterval(async () => {
859
- const statusResult = await api('GET', `/api/providers/oauth/status?sessionId=${encodeURIComponent(sessionId)}`);
860
- if (statusResult.status === 'done') {
861
- stopPolling();
862
- feedback.textContent = '✓ Signed in — provider added';
863
- feedback.className = 'key-feedback success';
864
- showToast(`${template.name} added`);
865
- state.modelsLoaded = false;
866
- await loadTemplates();
867
- await initModels();
868
- renderProviders();
869
- } else if (statusResult.status === 'error') {
870
- stopPolling();
871
- feedback.textContent = statusResult.error ?? 'Authorization failed';
872
- feedback.className = 'key-feedback error';
873
- signInBtn.disabled = false;
874
- } else if (statusResult.error) {
875
- stopPolling();
876
- feedback.textContent = 'Session expired — please try again';
877
- feedback.className = 'key-feedback error';
878
- signInBtn.disabled = false;
879
- }
880
- }, 3000);
881
- });
897
+ content.append(note, actionRow, feedback);
898
+
899
+ signInBtn.addEventListener('click', () => beginDeviceOAuthFlow({
900
+ providerId: template.id,
901
+ signInButton: signInBtn,
902
+ feedback,
903
+ onDone: async () => {
904
+ showToast(`${template.name} added`);
905
+ state.modelsLoaded = false;
906
+ await loadTemplates();
907
+ await initModels();
908
+ renderProviders();
909
+ },
910
+ }));
882
911
 
883
912
  return content;
884
913
  }
@@ -1110,10 +1139,10 @@ function buildOAuthProviderBodyContent(provider) {
1110
1139
  content.className = 'provider-body-content';
1111
1140
 
1112
1141
  const status = document.createElement('div');
1113
- status.style.cssText = 'font-size:13px;color:var(--text-secondary,#aaa);margin-bottom:10px';
1142
+ status.className = 'oauth-device-note';
1114
1143
  status.textContent = provider.hasKey
1115
- ? 'Signed in via OAuth.'
1116
- : 'Not signed in. Click below to start the device authorization flow.';
1144
+ ? `Signed in via OAuth${provider.subscription?.label ? ` · ${provider.subscription.label}` : ''}.`
1145
+ : 'Not signed in. Generate a one-time code to connect this provider.';
1117
1146
 
1118
1147
  const actionRow = document.createElement('div');
1119
1148
  actionRow.className = 'key-row';
@@ -1122,7 +1151,7 @@ function buildOAuthProviderBodyContent(provider) {
1122
1151
 
1123
1152
  const signInBtn = document.createElement('button');
1124
1153
  signInBtn.className = 'btn btn-primary';
1125
- signInBtn.textContent = provider.hasKey ? 'Re-authenticate' : 'Sign in';
1154
+ signInBtn.textContent = provider.hasKey ? 'Re-authenticate' : 'Get sign-in code';
1126
1155
 
1127
1156
  const refreshBtn = document.createElement('button');
1128
1157
  refreshBtn.className = 'btn btn-ghost';
@@ -1139,11 +1168,6 @@ function buildOAuthProviderBodyContent(provider) {
1139
1168
  content.appendChild(feedback);
1140
1169
  content.appendChild(buildDeleteProviderRow(provider));
1141
1170
 
1142
- let pollInterval = null;
1143
- function stopPolling() {
1144
- if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
1145
- }
1146
-
1147
1171
  refreshBtn.addEventListener('click', async () => {
1148
1172
  refreshBtn.disabled = true;
1149
1173
  feedback.textContent = 'Fetching latest models…';
@@ -1162,61 +1186,19 @@ function buildOAuthProviderBodyContent(provider) {
1162
1186
  setTimeout(() => { feedback.textContent = ''; feedback.className = 'key-feedback'; }, 4000);
1163
1187
  });
1164
1188
 
1165
- signInBtn.addEventListener('click', async () => {
1166
- stopPolling();
1167
- signInBtn.disabled = true;
1168
- refreshBtn.disabled = true;
1169
- feedback.textContent = 'Starting authorization…';
1170
- feedback.className = 'key-feedback muted';
1171
-
1172
- const startResult = await api('POST', '/api/providers/oauth/start', { providerId: provider.id });
1173
- if (startResult.error) {
1174
- feedback.textContent = startResult.error;
1175
- feedback.className = 'key-feedback error';
1176
- signInBtn.disabled = false;
1177
- refreshBtn.disabled = false;
1178
- return;
1179
- }
1180
-
1181
- const { sessionId, url, userCode } = startResult;
1182
- window.open(url, '_blank');
1183
- feedback.innerHTML = `Go to <a href="${url}" target="_blank" style="color:inherit">${url}</a><br>
1184
- Enter code: <strong style="letter-spacing:0.1em;font-size:15px">${userCode}</strong><br>
1185
- <span style="opacity:0.6;font-size:12px">Waiting for authorization…</span>`;
1186
- feedback.className = 'key-feedback muted';
1187
-
1188
- pollInterval = setInterval(async () => {
1189
- const statusResult = await api('GET', `/api/providers/oauth/status?sessionId=${encodeURIComponent(sessionId)}`);
1190
- if (statusResult.status === 'done') {
1191
- stopPolling();
1192
- feedback.textContent = '✓ Signed in successfully';
1193
- feedback.className = 'key-feedback success';
1194
- provider.hasKey = true;
1195
- status.textContent = 'Signed in via OAuth.';
1196
- signInBtn.textContent = 'Re-authenticate';
1197
- signInBtn.disabled = false;
1198
- refreshBtn.style.display = '';
1199
- refreshBtn.disabled = false;
1200
- const chip = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .status-chip`);
1201
- if (chip) { chip.className = 'status-chip has-key'; chip.textContent = 'Key stored'; }
1202
- showToast(`Signed in to ${provider.name ?? provider.id}`);
1203
- state.modelsLoaded = false;
1204
- initModels().then(() => { renderProviders(); renderFavList(); });
1205
- } else if (statusResult.status === 'error') {
1206
- stopPolling();
1207
- feedback.textContent = statusResult.error ?? 'Authorization failed';
1208
- feedback.className = 'key-feedback error';
1209
- signInBtn.disabled = false;
1210
- refreshBtn.disabled = false;
1211
- } else if (statusResult.error) {
1212
- stopPolling();
1213
- feedback.textContent = 'Session expired — please try again';
1214
- feedback.className = 'key-feedback error';
1215
- signInBtn.disabled = false;
1216
- refreshBtn.disabled = false;
1217
- }
1218
- }, 3000);
1219
- });
1189
+ signInBtn.addEventListener('click', () => beginDeviceOAuthFlow({
1190
+ providerId: provider.id,
1191
+ signInButton: signInBtn,
1192
+ refreshButton: refreshBtn,
1193
+ feedback,
1194
+ onDone: async () => {
1195
+ showToast(`Signed in to ${provider.name ?? provider.id}`);
1196
+ state.modelsLoaded = false;
1197
+ await initModels();
1198
+ renderProviders();
1199
+ renderFavList();
1200
+ },
1201
+ }));
1220
1202
 
1221
1203
  return content;
1222
1204
  }
@@ -0,0 +1,30 @@
1
+ /** Return the short credential label used on a provider card. */
2
+ export function oauthConnectionLabel(provider) {
3
+ if (!provider?.hasKey) return provider?.freeAccess ? 'Free models' : 'Not configured';
4
+ if (provider.authType !== 'oauth') return 'Key stored';
5
+ const planLabel = provider.subscription?.label;
6
+ return typeof planLabel === 'string' && planLabel.trim()
7
+ ? `Connected · ${planLabel.trim()}`
8
+ : 'Connected';
9
+ }
10
+
11
+ /** Copy a device code, with a small legacy fallback for older browser contexts. */
12
+ export async function copyDeviceCode(code, clipboard = globalThis.navigator?.clipboard) {
13
+ if (clipboard?.writeText) {
14
+ await clipboard.writeText(code);
15
+ return;
16
+ }
17
+ if (!globalThis.document?.body || typeof globalThis.document.execCommand !== 'function') {
18
+ throw new Error('Clipboard access is unavailable');
19
+ }
20
+ const input = globalThis.document.createElement('textarea');
21
+ input.value = code;
22
+ input.setAttribute('readonly', '');
23
+ input.style.position = 'fixed';
24
+ input.style.opacity = '0';
25
+ globalThis.document.body.appendChild(input);
26
+ input.select();
27
+ const copied = globalThis.document.execCommand('copy');
28
+ input.remove();
29
+ if (!copied) throw new Error('Copy failed');
30
+ }
@@ -0,0 +1,59 @@
1
+ // Provider brand marks shared by every UI surface that represents a provider.
2
+ // Simple Icons URLs were verified when added; inline SVGs cover brands whose
3
+ // official assets cannot be hotlinked reliably.
4
+
5
+ const PROVIDER_INLINE_SVGS = {
6
+ openai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.896zm16.597 3.855l-5.843-3.368L15.115 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.403-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08-4.778 2.758a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z"/></svg>`,
7
+ kilo: `<svg viewBox="0 0 24 24" fill="white" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg" style="width:22px;height:22px"><title>Kilo Code</title><path d="M0 0v24h24V0H0zm22.222 22.222H1.778V1.778h20.444v20.444zm-7.555-4.964h2.222v1.778h-2.794L12.89 17.83v-2.794h1.778v2.222zm4 0h-1.778v-2.222h-2.222v-1.778h2.793l1.207 1.207v2.793zm-7.556-2.591H9.333v-1.778h1.778v1.778zm-5.778-1.778h1.778v4h4v1.778H6.54L5.333 17.46V12.89zm13.334-3.556v1.778h-5.778V9.333h1.987V7.111h-1.987V5.333h2.558l1.206 1.207v2.793h2.014zm-11.556-2h2.222l1.778 1.778v2H9.333v-2H7.111v2H5.333V5.333h1.778v2zm4 0H9.333v-2h1.778v2z"/></svg>`,
8
+ xai: `<svg viewBox="0 0 24 24" fill="white" xmlns="http://www.w3.org/2000/svg" style="width:20px;height:20px"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>`,
9
+ };
10
+
11
+ const PROVIDER_LOGO_URLS = {
12
+ zen: 'https://opencode.ai/favicon.ico',
13
+ go: 'https://opencode.ai/favicon.ico',
14
+ anthropic: 'https://cdn.simpleicons.org/anthropic/ffffff',
15
+ google: 'https://cdn.simpleicons.org/google/ffffff',
16
+ nvidia: 'https://cdn.simpleicons.org/nvidia/ffffff',
17
+ deepseek: 'https://cdn.simpleicons.org/deepseek/ffffff',
18
+ mistral: 'https://cdn.simpleicons.org/mistralai/ffffff',
19
+ ollama: 'https://cdn.simpleicons.org/ollama/ffffff',
20
+ openrouter: 'https://cdn.simpleicons.org/openrouter/ffffff',
21
+ perplexity: 'https://cdn.simpleicons.org/perplexity/ffffff',
22
+ huggingface: 'https://cdn.simpleicons.org/huggingface/ffffff',
23
+ cloudflare: 'https://cdn.simpleicons.org/cloudflare/ffffff',
24
+ azure: 'https://cdn.simpleicons.org/microsoftazure/ffffff',
25
+ vertex: 'https://cdn.simpleicons.org/googlecloud/ffffff',
26
+ bedrock: 'https://cdn.simpleicons.org/amazonaws/ffffff',
27
+ };
28
+
29
+ function escapeHtml(value) {
30
+ return String(value ?? '')
31
+ .replace(/&/g, '&amp;')
32
+ .replace(/</g, '&lt;')
33
+ .replace(/>/g, '&gt;')
34
+ .replace(/"/g, '&quot;')
35
+ .replace(/'/g, '&#39;');
36
+ }
37
+
38
+ function getProviderLogoContent(providerId) {
39
+ const base = providerId.replace(/-oauth$/, '').replace(/-api$/, '');
40
+ const svg = PROVIDER_INLINE_SVGS[providerId] ?? PROVIDER_INLINE_SVGS[base];
41
+ if (svg) return { type: 'svg', content: svg };
42
+ const url = PROVIDER_LOGO_URLS[providerId] ?? PROVIDER_LOGO_URLS[base];
43
+ if (url) return { type: 'img', content: url };
44
+ return null;
45
+ }
46
+
47
+ export function providerInitial(name) {
48
+ return (name || '?').replace(/[^a-zA-Z0-9]/g, '').charAt(0).toUpperCase() || '?';
49
+ }
50
+
51
+ export function providerLogoHtml(providerId, displayName) {
52
+ const initial = providerInitial(displayName);
53
+ const logo = getProviderLogoContent(providerId);
54
+ if (logo?.type === 'svg') return logo.content;
55
+ if (logo?.type === 'img') {
56
+ return `<img src="${logo.content}" class="provider-logo-img" alt="${escapeHtml(displayName)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><span class="provider-logo-fallback" style="display:none">${initial}</span>`;
57
+ }
58
+ return initial;
59
+ }