@jacobbd/relay-ai 0.4.7 → 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
  }
@@ -2332,10 +2314,14 @@ function renderServerProviderPicker() {
2332
2314
  const label = p ? p.name : id;
2333
2315
  return `<span class="server-provider-chip">${escapeHtml(label)}<button type="button" onclick="toggleServerProvider('${id}')" aria-label="Remove ${escapeHtml(label)}">&times;</button></span>`;
2334
2316
  }).join('');
2317
+ const allFreeNote = s.form.freeModelsOnly && s.form.exposedProviders.length === 0
2318
+ ? '<div class="server-field-hint">No providers selected — exposing free models from every available provider.</div>'
2319
+ : '';
2335
2320
 
2336
2321
  return `
2337
2322
  <div class="server-providers-picker">
2338
2323
  ${chips ? `<div class="server-provider-chips">${chips}</div>` : ''}
2324
+ ${allFreeNote}
2339
2325
  <div class="search-field">
2340
2326
  <svg class="search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
2341
2327
  <input type="search" class="search-input" placeholder="Search providers to expose…" value="${escapeHtml(s.form.providerSearch)}" oninput="setServerProviderSearch(this.value)">
@@ -2377,13 +2363,27 @@ function renderServerNetworkOptions() {
2377
2363
  function renderServerSetup(s) {
2378
2364
  const f = s.form;
2379
2365
  const specific = f.expose === 'specific';
2380
- const providersOk = f.expose === 'favorites' || (specific && f.exposedProviders.length > 0);
2366
+ const hasProviders = f.exposedProviders.length > 0;
2367
+ // Free-only with no providers selected = expose free models from every provider
2368
+ // (backend: exposedProviders null + freeModelsOnly). Otherwise require a selection.
2369
+ const providersOk = f.expose === 'favorites'
2370
+ || (specific && hasProviders)
2371
+ || (specific && f.freeModelsOnly);
2381
2372
  const passwordOk = f.listenMode !== 'network' || f.passwordMode === 'saved' || f.password.trim().length > 0;
2382
2373
  const canStart = providersOk && passwordOk && !s.starting;
2383
2374
 
2384
2375
  let hint = '';
2385
- if (!providersOk) hint = 'Select at least one provider to expose.';
2386
- else if (!passwordOk) hint = 'Enter a server password for network mode.';
2376
+ if (!providersOk) {
2377
+ hint = f.freeModelsOnly
2378
+ ? 'Select at least one provider, or leave none selected to expose free models from all providers.'
2379
+ : 'Select at least one provider to expose.';
2380
+ } else if (!passwordOk) {
2381
+ hint = 'Enter a server password for network mode.';
2382
+ }
2383
+
2384
+ const freeModelsHint = specific
2385
+ ? 'With providers selected, only their free models are exposed. With none selected, free models from every available provider are exposed.'
2386
+ : 'Includes verified $0 models and free developer access.';
2387
2387
 
2388
2388
  return `
2389
2389
  <div class="server-setup">
@@ -2405,6 +2405,7 @@ function renderServerSetup(s) {
2405
2405
  <input type="checkbox" ${f.freeModelsOnly ? 'checked' : ''} onchange="setServerFreeModelsOnly(this.checked)">
2406
2406
  <span>Free models only <span class="server-field-hint">(includes verified $0 models and free developer access)</span></span>
2407
2407
  </label>
2408
+ <div class="server-field-hint">${escapeHtml(freeModelsHint)}</div>
2408
2409
  </div>
2409
2410
 
2410
2411
  <div class="server-field">
@@ -2475,8 +2476,8 @@ function renderServerRunning(status) {
2475
2476
  <tr>
2476
2477
  <td>${escapeHtml(m.providerLabel)}</td>
2477
2478
  <td>${escapeHtml(m.name)}</td>
2478
- <td><code>${escapeHtml(m.anthropicId)}</code></td>
2479
- <td><code>${escapeHtml(m.openaiId)}</code></td>
2479
+ <td>${serverIdCell(m.anthropicId)}</td>
2480
+ <td>${serverIdCell(m.openaiId)}</td>
2480
2481
  </tr>
2481
2482
  `).join('');
2482
2483
 
@@ -2531,6 +2532,8 @@ function setServerMask(checked) {
2531
2532
 
2532
2533
  function setServerFreeModelsOnly(checked) {
2533
2534
  state.server.form.freeModelsOnly = checked;
2535
+ state.server.error = null;
2536
+ renderServerPanel();
2534
2537
  }
2535
2538
 
2536
2539
  function setServerListenMode(mode) {
@@ -2561,7 +2564,10 @@ async function submitServerStart() {
2561
2564
  const body = {
2562
2565
  favoritesOnly: f.expose === 'favorites',
2563
2566
  freeModelsOnly: f.freeModelsOnly,
2564
- exposedProviders: f.expose === 'specific' ? f.exposedProviders : null,
2567
+ // Empty specific selection + free-only null (all providers, free filter applied server-side).
2568
+ exposedProviders: f.expose === 'specific' && f.exposedProviders.length > 0
2569
+ ? f.exposedProviders
2570
+ : null,
2565
2571
  maskGatewayIds: f.maskGatewayIds,
2566
2572
  listenMode: f.listenMode,
2567
2573
  passwordMode: f.passwordMode,
@@ -2599,10 +2605,18 @@ async function stopServerGateway() {
2599
2605
  }
2600
2606
  }
2601
2607
 
2602
- async function copyServerValue(id) {
2603
- const el = document.getElementById(id);
2604
- if (!el) return;
2605
- const text = el.dataset.value ?? el.textContent;
2608
+ function serverIdCell(id) {
2609
+ const value = String(id ?? '');
2610
+ if (!value) return '<span class="server-id-empty">—</span>';
2611
+ return `
2612
+ <div class="server-id-cell">
2613
+ <code title="${escapeHtml(value)}">${escapeHtml(value)}</code>
2614
+ <button type="button" class="btn btn-ghost btn-sm server-id-copy" onclick='copyPlainText(${JSON.stringify(value)})'>Copy</button>
2615
+ </div>
2616
+ `;
2617
+ }
2618
+
2619
+ async function copyPlainText(text) {
2606
2620
  try {
2607
2621
  await navigator.clipboard.writeText(text);
2608
2622
  showToast('Copied to clipboard');
@@ -2611,6 +2625,12 @@ async function copyServerValue(id) {
2611
2625
  }
2612
2626
  }
2613
2627
 
2628
+ async function copyServerValue(id) {
2629
+ const el = document.getElementById(id);
2630
+ if (!el) return;
2631
+ await copyPlainText(el.dataset.value ?? el.textContent);
2632
+ }
2633
+
2614
2634
  function toggleServerApiKeyVisibility() {
2615
2635
  const el = document.getElementById('server-api-key-value');
2616
2636
  if (!el) return;
@@ -2630,4 +2650,5 @@ window.setServerSavePassword = setServerSavePassword;
2630
2650
  window.submitServerStart = submitServerStart;
2631
2651
  window.stopServerGateway = stopServerGateway;
2632
2652
  window.copyServerValue = copyServerValue;
2653
+ window.copyPlainText = copyPlainText;
2633
2654
  window.toggleServerApiKeyVisibility = toggleServerApiKeyVisibility;
@@ -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
+ }