main
js 1,080 lines 38.5 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi, fetchApi } from "/js/api.js";
3 import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
4 import {
5 toastFrontendError,
6 toastFrontendInfo,
7 toastFrontendSuccess,
8 } from "/components/notifications/notification-store.js";
9
10 const MODEL_CONFIG_API = "/plugins/_model_config";
11 const STATUS_API = "/plugins/_oauth/status";
12 const START_LOGIN_API = "/plugins/_oauth/start_login";
13 const POLL_LOGIN_API = "/plugins/_oauth/poll_device_login";
14 const MANUAL_CALLBACK_API = "/plugins/_oauth/manual_callback";
15 const MODELS_API = "/plugins/_oauth/models";
16 const DISCONNECT_API = "/plugins/_oauth/disconnect";
17 const MAX_POLL_MS = 120000;
18 const CODEX_PROVIDER = "codex_oauth";
19 const PROVIDER_MARKS = {
20 github: "terminal",
21 google: "cloud",
22 openai: "key",
23 xai: "neurology",
24 };
25 const MODEL_SLOTS = [
26 {
27 key: "chat_model",
28 title: "Main model",
29 icon: "forum",
30 },
31 {
32 key: "utility_model",
33 title: "Utility model",
34 icon: "manufacturing",
35 },
36 ];
37
38 function ensureConfig(config) {
39 if (!config || typeof config !== "object") return null;
40 config.codex = config.codex && typeof config.codex === "object" ? config.codex : {};
41 const codex = config.codex;
42 codex.enabled = codex.enabled !== false;
43 codex.auth_file_path = String(codex.auth_file_path || "");
44 codex.issuer = String(codex.issuer || "https://auth.openai.com");
45 codex.token_url = String(codex.token_url || "https://auth.openai.com/oauth/token");
46 codex.client_id = String(codex.client_id || "app_EMoamEEZ73f0CkXaXp7hrann");
47 codex.upstream_base_url = String(codex.upstream_base_url || "https://chatgpt.com/backend-api/codex");
48 codex.proxy_base_path = String(codex.proxy_base_path || "/oauth/codex");
49 codex.callback_path = String(codex.callback_path || "/auth/callback");
50 codex.require_proxy_token = Boolean(codex.require_proxy_token);
51 codex.proxy_token = String(codex.proxy_token || "");
52 codex.codex_version = String(codex.codex_version || "");
53 codex.models = Array.isArray(codex.models) ? codex.models : [];
54 codex.reasoning_effort = String(codex.reasoning_effort || "high");
55 codex.reasoning_summary = String(codex.reasoning_summary || "auto");
56 codex.text_verbosity = String(codex.text_verbosity || "medium");
57
58 config.gemini_api = config.gemini_api && typeof config.gemini_api === "object" ? config.gemini_api : {};
59 const geminiApi = config.gemini_api;
60 geminiApi.enabled = geminiApi.enabled !== false;
61 geminiApi.client_id = String(geminiApi.client_id || "");
62 geminiApi.client_secret = String(geminiApi.client_secret || "");
63 geminiApi.quota_project_id = String(geminiApi.quota_project_id || "");
64 geminiApi.scopes = Array.isArray(geminiApi.scopes) ? geminiApi.scopes : [];
65 geminiApi.api_base_url = String(geminiApi.api_base_url || "https://generativelanguage.googleapis.com/v1beta/openai");
66 geminiApi.proxy_base_path = String(geminiApi.proxy_base_path || "/oauth/gemini-api");
67 geminiApi.callback_path = String(geminiApi.callback_path || "/oauth/gemini-api/callback");
68 return config;
69 }
70
71 function clone(value) {
72 return JSON.parse(JSON.stringify(value || {}));
73 }
74
75 function ensureModelSlot(config, key) {
76 if (!config[key] || typeof config[key] !== "object") config[key] = {};
77 config[key] = {
78 provider: "",
79 name: "",
80 api_base: "",
81 ctx_length: key === "utility_model" ? 128000 : 200000,
82 ctx_history: key === "chat_model" ? 0.7 : undefined,
83 ctx_input: key === "utility_model" ? 0.7 : undefined,
84 vision: key === "chat_model" ? true : undefined,
85 max_embeds: key === "chat_model" ? 10 : undefined,
86 rl_requests: 0,
87 rl_input: 0,
88 rl_output: 0,
89 kwargs: {},
90 ...config[key],
91 };
92 if (!config[key].kwargs || typeof config[key].kwargs !== "object") config[key].kwargs = {};
93 }
94
95 function messageOf(error) {
96 return error instanceof Error ? error.message : String(error);
97 }
98
99 function providerUiDefaults() {
100 return {};
101 }
102
103 export const store = createStore("oauthConfig", {
104 config: null,
105 status: null,
106 loadingStatus: false,
107 connecting: false,
108 disconnecting: false,
109 loadingModels: false,
110 providerModels: {},
111 providerModelMetadata: {},
112 providerUi: providerUiDefaults(),
113 connectingProvider: "",
114 disconnectingProvider: "",
115 loadingModelsProvider: "",
116 selectedProviderId: "",
117 activeModelProvider: CODEX_PROVIDER,
118 models: [],
119 modelSlots: MODEL_SLOTS,
120 modelConfig: null,
121 modelConfigLoading: false,
122 modelConfigSaving: false,
123 modelConfigDirty: false,
124 modelSlotCurrentProviders: {},
125 modelSlotDirty: {
126 chat_model: false,
127 utility_model: false,
128 },
129 modelDropdown: {
130 chat_model: { open: false },
131 utility_model: { open: false },
132 },
133 devices: {},
134 pollTimers: {},
135 pollStartedAt: {},
136 callbackPollTimers: {},
137 callbackPollStartedAt: {},
138 device: null,
139 pollTimer: null,
140
141 async init(config, context = null) {
142 this.bindConfig(config);
143 this.installSettingsHooks(context);
144 await Promise.all([this.loadStatus(), this.loadModelConfig()]);
145 this.applySoleConnectedProviderDefaults();
146 },
147
148 cleanup() {
149 this.stopPolling();
150 this.stopCallbackPolling();
151 this.config = null;
152 this.status = null;
153 this.connecting = false;
154 this.disconnecting = false;
155 this.loadingModels = false;
156 this.providerModels = {};
157 this.providerModelMetadata = {};
158 this.providerUi = providerUiDefaults();
159 this.connectingProvider = "";
160 this.disconnectingProvider = "";
161 this.loadingModelsProvider = "";
162 this.selectedProviderId = "";
163 this.activeModelProvider = CODEX_PROVIDER;
164 this.models = [];
165 this.modelConfig = null;
166 this.modelConfigLoading = false;
167 this.modelConfigSaving = false;
168 this.modelConfigDirty = false;
169 this.modelSlotCurrentProviders = {};
170 this.modelSlotDirty = { chat_model: false, utility_model: false };
171 this.modelDropdown = {
172 chat_model: { open: false },
173 utility_model: { open: false },
174 };
175 this.devices = {};
176 this.pollStartedAt = {};
177 this.callbackPollTimers = {};
178 this.callbackPollStartedAt = {};
179 this.device = null;
180 },
181
182 bindConfig(config) {
183 const safeConfig = ensureConfig(config);
184 if (!safeConfig) return;
185 if (this.config === safeConfig) return;
186 this.config = safeConfig;
187 },
188
189 codex() {
190 return this.config?.codex || {};
191 },
192
193 geminiApi() {
194 return this.config?.gemini_api || {};
195 },
196
197 providerMap() {
198 const mapped = this.status?.provider_map;
199 if (mapped && typeof mapped === "object") return mapped;
200 const providers = Array.isArray(this.status?.providers) ? this.status.providers : [];
201 return providers.reduce((result, provider) => {
202 if (provider?.provider_id) result[provider.provider_id] = provider;
203 return result;
204 }, {});
205 },
206
207 providerStatus(providerId) {
208 return this.providerMap()[providerId] || {};
209 },
210
211 providerUiFor(providerId) {
212 const key = String(providerId || "");
213 if (!key) return {};
214 if (!this.providerUi[key]) {
215 this.providerUi = {
216 ...this.providerUi,
217 [key]: {
218 enterprise_domain: "",
219 manualCallback: "",
220 client_id: "",
221 client_secret: "",
222 quota_project_id: "",
223 },
224 };
225 }
226 return this.providerUi[key];
227 },
228
229 providerCards() {
230 const map = this.providerMap();
231 const providers = Array.isArray(this.status?.providers)
232 ? this.status.providers
233 : Object.values(map);
234 return providers
235 .filter((provider) => provider?.provider_id)
236 .map((status) => {
237 const providerId = status.provider_id;
238 return {
239 ...status,
240 provider_id: providerId,
241 connected: Boolean(status.connected),
242 mark: status.mark || PROVIDER_MARKS[status.icon] || "key",
243 use_label: status.use_label || `Use ${status.short_name || status.display_name || providerId}`,
244 device: this.devices[providerId] || null,
245 connecting: this.connectingProvider === providerId,
246 disconnecting: this.disconnectingProvider === providerId,
247 loadingModels: this.loadingModelsProvider === providerId,
248 };
249 });
250 },
251
252 connectedProviderCards() {
253 return this.providerCards().filter((card) => card.connected);
254 },
255
256 availableProviderCards() {
257 return this.providerCards().filter((card) => !card.connected);
258 },
259
260 selectedProvider() {
261 const selected = this.providerCards().find((card) => card.provider_id === this.selectedProviderId);
262 return selected || this.providerCards()[0] || null;
263 },
264
265 selectProvider(providerId) {
266 if (!this.isOauthProvider(providerId)) return;
267 this.selectedProviderId = providerId;
268 },
269
270 connectedSummaryLabel() {
271 const connected = this.connectedProviderCards().length;
272 const total = this.providerCards().length;
273 if (!connected) return "Connect account-backed providers here. More than one can be connected at the same time.";
274 if (connected === 1) return `1 of ${total} account providers connected.`;
275 return `${connected} of ${total} account providers connected.`;
276 },
277
278 providerIds() {
279 return this.providerCards().map((card) => card.provider_id);
280 },
281
282 isOauthProvider(providerId) {
283 const key = String(providerId || "");
284 return Boolean(key && this.providerMap()[key]);
285 },
286
287 providerConnected(providerId) {
288 return Boolean(this.providerStatus(providerId)?.connected);
289 },
290
291 providerLabel(providerId) {
292 const status = this.providerStatus(providerId);
293 return status.display_name || providerId;
294 },
295
296 providerUseLabel(providerId) {
297 const status = this.providerStatus(providerId);
298 return status.use_label || `Use ${status.short_name || status.display_name || providerId}`;
299 },
300
301 providerStatusLabel(providerId) {
302 if (this.loadingStatus) return "Checking";
303 const status = this.providerStatus(providerId);
304 if (!status.connected) return "Not connected";
305 return status.account_label || status.email || "Connected";
306 },
307
308 providerDevice(providerId) {
309 return this.devices[String(providerId || "")] || null;
310 },
311
312 providerShowSetupFields(providerId) {
313 if (this.providerConnected(providerId)) return false;
314 if (this.providerDevice(providerId)) return false;
315 return this.selectedProviderId === providerId;
316 },
317
318 providerShowNote(providerId) {
319 if (this.providerConnected(providerId)) return false;
320 const status = this.providerStatus(providerId);
321 return this.selectedProviderId === providerId && Boolean(status.warning || status.note);
322 },
323
324 providerDetailOpen(providerId) {
325 if (!this.isOauthProvider(providerId) || this.providerConnected(providerId)) return false;
326 if (this.providerDevice(providerId)) return true;
327 const status = this.providerStatus(providerId);
328 return this.selectedProviderId === providerId && Boolean(
329 status.supports_enterprise_domain
330 || status.supports_oauth_client_config
331 || status.warning
332 || status.note
333 );
334 },
335
336 providerReadinessLabel(providerId) {
337 const status = this.providerStatus(providerId);
338 if (this.loadingStatus) return "Checking";
339 if (status.connected) return this.providerStatusLabel(providerId);
340 if (!this.providerSetupReady(providerId)) return "Needs OAuth client details";
341 if (status.warning) return "Available with restrictions";
342 return "Ready to connect";
343 },
344
345 providerSetupReady(providerId) {
346 const status = this.providerStatus(providerId);
347 if (!status.supports_oauth_client_config) return true;
348 const ui = this.providerUiFor(providerId);
349 const clientId = ui.client_id || status.client_id || this.geminiApi().client_id || "";
350 const hasSecret = Boolean(
351 ui.client_secret
352 || this.geminiApi().client_secret
353 || status.client_secret_configured
354 );
355 return Boolean(String(clientId).trim() && hasSecret);
356 },
357
358 providerPrimaryLabel(providerId) {
359 if (this.connectingProvider === providerId) return "Waiting";
360 return this.providerSetupReady(providerId) ? "Connect" : "Configure";
361 },
362
363 providerPrimaryDisabled(providerId) {
364 if (!this.isOauthProvider(providerId)) return true;
365 if (this.connectingProvider || this.disconnectingProvider) return true;
366 return false;
367 },
368
369 handleProviderPrimary(providerId) {
370 this.selectProvider(providerId);
371 if (!this.providerSetupReady(providerId)) return;
372 void this.connectProvider(providerId);
373 },
374
375 providerEndpointUrl(providerId) {
376 const status = this.providerStatus(providerId);
377 const proxyBase = String(status.proxy_base_path || "").replace(/\/$/, "");
378 const base = status.v1_base_path || (proxyBase ? `${proxyBase}/v1` : "");
379 return base ? `${window.location.origin}${base}` : "";
380 },
381
382 providerCallbackUrl(providerId) {
383 const status = this.providerStatus(providerId);
384 const path = status.callback_path || "";
385 return path ? `${window.location.origin}${path}` : "";
386 },
387
388 usage(providerId = CODEX_PROVIDER) {
389 return this.providerStatus(providerId)?.usage || null;
390 },
391
392 usageWindows(providerId = CODEX_PROVIDER) {
393 const status = this.providerStatus(providerId);
394 if (Array.isArray(status.usage_windows) && status.usage_windows.length) {
395 return status.usage_windows.filter((window) => Number.isFinite(this.remainingPercent(window)));
396 }
397 const usage = this.usage(providerId);
398 if (!usage?.available) return [];
399 return [
400 { key: "primary", title: "Session", ...(usage.primary || {}) },
401 { key: "secondary", title: "Week", ...(usage.secondary || {}) },
402 ].filter((window) => Number.isFinite(this.remainingPercent(window)));
403 },
404
405 connectedUsageWindows() {
406 const windows = [];
407 for (const card of this.connectedProviderCards()) {
408 for (const window of this.usageWindows(card.provider_id)) {
409 windows.push({
410 ...window,
411 key: `${card.provider_id}-${window.key}`,
412 title: `${card.short_name || card.display_name || "Account"} ${window.title}`,
413 });
414 }
415 }
416 return windows;
417 },
418
419 usagePlanCatalog() {
420 const catalog = this.status?.usage_plan_catalog;
421 return catalog && typeof catalog === "object" ? catalog : {};
422 },
423
424 usagePlanEntries() {
425 const catalog = this.usagePlanCatalog();
426 const providerIds = this.providerIds();
427 const ids = [
428 ...providerIds,
429 ...Object.keys(catalog).filter((providerId) => !providerIds.includes(providerId)),
430 ];
431 return ids
432 .map((providerId) => catalog[providerId])
433 .filter((entry) => entry && Array.isArray(entry.plans) && entry.plans.length);
434 },
435
436 usagePlanStatus() {
437 return "Provider available";
438 },
439
440 usagePlanNotes(entry) {
441 return Array.isArray(entry?.notes) ? entry.notes.slice(0, 2) : [];
442 },
443
444 usageWidth(window) {
445 const value = Math.max(0, Math.min(100, this.remainingPercent(window)));
446 return `${value}%`;
447 },
448
449 remainingPercent(window) {
450 const remaining = Number(window?.remaining_percent);
451 if (Number.isFinite(remaining)) return remaining;
452 const used = Number(window?.used_percent);
453 if (Number.isFinite(used)) return 100 - used;
454 return Number.NaN;
455 },
456
457 formatRemainingPercent(window) {
458 const number = this.remainingPercent(window);
459 if (!Number.isFinite(number)) return "0%";
460 return `${Math.round(number * 10) / 10}% left`;
461 },
462
463 formatWindowLabel(window) {
464 return window?.label || "";
465 },
466
467 formatReset(window) {
468 const seconds = Number(window?.reset_at || 0);
469 if (!Number.isFinite(seconds) || seconds <= 0) return "";
470 const remainingMs = Math.max(0, seconds * 1000 - Date.now());
471 const minutes = Math.round(remainingMs / 60000);
472 if (minutes < 60) return `${minutes}m`;
473 const hours = Math.round(minutes / 60);
474 if (hours < 48) return `${hours}h`;
475 return `${Math.round(hours / 24)}d`;
476 },
477
478 installSettingsHooks(context) {
479 if (!context || context.__oauthConfigHooksInstalled) return;
480
481 const originalSave = context.save.bind(context);
482 context.save = async () => {
483 context.error = null;
484 try {
485 await this.saveModelConfigIfDirty();
486 } catch (error) {
487 context.error = messageOf(error) || "Failed to save model selection.";
488 return;
489 }
490 await originalSave();
491 };
492
493 context.__oauthConfigHooksInstalled = true;
494 },
495
496 async loadModelConfig() {
497 if (this.modelConfigLoading) return;
498 this.modelConfigLoading = true;
499 try {
500 await modelConfigStore.ensureLoaded();
501 const response = await fetchApi(`${MODEL_CONFIG_API}/model_config_get`, {
502 method: "POST",
503 headers: { "Content-Type": "application/json" },
504 body: JSON.stringify({}),
505 });
506 const data = await response.json().catch(() => ({}));
507 const modelConfig = data.config && typeof data.config === "object" ? data.config : {};
508 ensureModelSlot(modelConfig, "chat_model");
509 ensureModelSlot(modelConfig, "utility_model");
510 this.modelConfig = modelConfig;
511 this.modelSlotCurrentProviders = Object.fromEntries(
512 MODEL_SLOTS.map((slot) => [slot.key, modelConfig[slot.key].provider || ""]),
513 );
514 this.modelConfigDirty = false;
515 this.modelSlotDirty = { chat_model: false, utility_model: false };
516 } catch (error) {
517 this.modelConfig = null;
518 void toastFrontendError(messageOf(error), "OAuth Connections");
519 } finally {
520 this.modelConfigLoading = false;
521 }
522 },
523
524 modelSlot(key) {
525 if (!this.modelConfig) return {};
526 ensureModelSlot(this.modelConfig, key);
527 return this.modelConfig[key];
528 },
529
530 slotUsesOauth(key) {
531 return this.isOauthProvider(this.modelSlot(key).provider);
532 },
533
534 slotUsesProvider(key, providerId) {
535 return this.modelSlot(key).provider === providerId;
536 },
537
538 providerName(provider) {
539 if (!provider) return "Not configured";
540 const found = (modelConfigStore.chatProviders || []).find((item) => item.value === provider);
541 return found?.label || this.providerLabel(provider);
542 },
543
544 slotProviderChoices() {
545 return this.connectedProviderCards();
546 },
547
548 modelProviderOptionLabel(provider) {
549 return provider?.display_name || provider?.short_name || provider?.provider_id || "Connected account";
550 },
551
552 applySoleConnectedProviderDefaults() {
553 const providers = this.connectedProviderCards();
554 if (providers.length !== 1 || !this.modelConfig) return;
555 const providerId = providers[0].provider_id;
556 for (const slot of MODEL_SLOTS) {
557 const model = this.modelSlot(slot.key);
558 if (model.provider && model.provider !== providerId) continue;
559 if (this.providerConnected(model.provider)) continue;
560 model.provider = providerId;
561 model.name = "";
562 model.api_base = "";
563 model.kwargs = {};
564 }
565 this.activeModelProvider = providerId;
566 this.models = this.activeProviderModels();
567 },
568
569 slotStatusLabel(key) {
570 const slot = this.modelSlot(key);
571 const currentProvider = this.modelSlotCurrentProviders[key] ?? slot.provider;
572 if (this.isOauthProvider(currentProvider)) return "";
573 return `Currently ${this.providerName(currentProvider)}`;
574 },
575
576 slotCanUseModels(key) {
577 const providerId = this.modelSlot(key).provider;
578 return this.isOauthProvider(providerId) && this.providerConnected(providerId);
579 },
580
581 activeProviderModels() {
582 if (!this.providerConnected(this.activeModelProvider)) return [];
583 return this.providerModels[this.activeModelProvider] || [];
584 },
585
586 modelMetadata(providerId, model) {
587 return this.providerModelMetadata[providerId]?.[model] || {};
588 },
589
590 modelDescription(providerId, model) {
591 return this.modelMetadata(providerId, model).description || "";
592 },
593
594 activeModelsDescription() {
595 if (!this.providerConnected(this.activeModelProvider)) return "";
596 return `Available models from ${this.providerLabel(this.activeModelProvider)}`;
597 },
598
599 markModelDirty(key) {
600 this.modelConfigDirty = true;
601 this.modelSlotDirty = { ...this.modelSlotDirty, [key]: true };
602 },
603
604 useProviderForSlot(key, providerId) {
605 if (!this.providerConnected(providerId)) return;
606 const slot = this.modelSlot(key);
607 const previousProvider = slot.provider;
608 slot.provider = providerId;
609 slot.api_base = "";
610 if (previousProvider && previousProvider !== providerId) {
611 slot.name = "";
612 }
613 if (!slot.kwargs || typeof slot.kwargs !== "object") slot.kwargs = {};
614 this.activeModelProvider = providerId;
615 this.models = this.activeProviderModels();
616 this.markModelDirty(key);
617 if (this.models.length) {
618 this.openModelDropdown(key);
619 } else if (this.providerConnected(providerId)) {
620 void this.loadModels({ providerId, openDropdown: key, silent: true });
621 }
622 },
623
624 copyMainToUtility() {
625 if (!this.modelConfig) return;
626 const main = this.modelSlot("chat_model");
627 const utility = this.modelSlot("utility_model");
628 utility.provider = main.provider || "";
629 utility.name = main.name || "";
630 utility.api_base = main.api_base || "";
631 utility.kwargs = clone(main.kwargs || {});
632 this.activeModelProvider = utility.provider || this.activeModelProvider;
633 this.models = this.activeProviderModels();
634 this.markModelDirty("utility_model");
635 },
636
637 openModelDropdown(key, trigger = null) {
638 if (!this.slotCanUseModels(key)) return;
639 trigger?.scrollIntoView({ block: "center" });
640 const providerId = this.modelSlot(key).provider;
641 this.activeModelProvider = providerId;
642 this.models = this.activeProviderModels();
643 this.modelDropdown[key] = { ...this.modelDropdown[key], open: true };
644 if (!this.models.length && !this.loadingModelsProvider && this.providerConnected(providerId)) {
645 void this.loadModels({ providerId, openDropdown: key, silent: true });
646 }
647 },
648
649 closeModelDropdown(key) {
650 this.modelDropdown[key] = { ...this.modelDropdown[key], open: false };
651 },
652
653 filteredModels(key) {
654 const slot = this.modelSlot(key);
655 const query = String(slot.name || "").trim().toLowerCase();
656 const models = this.providerModels[slot.provider] || [];
657 const filtered = query
658 ? models.filter((model) => String(model).toLowerCase().includes(query))
659 : models;
660 return filtered.slice(0, 80);
661 },
662
663 selectModel(key, model) {
664 const slot = this.modelSlot(key);
665 const providerId = slot.provider;
666 if (!this.providerConnected(providerId)) return;
667 slot.name = model;
668 this.activeModelProvider = providerId;
669 this.models = this.activeProviderModels();
670 this.markModelDirty(key);
671 this.closeModelDropdown(key);
672 },
673
674 validateModelConfig() {
675 if (!this.modelConfigDirty) return;
676 for (const slot of MODEL_SLOTS) {
677 if (!this.modelSlotDirty[slot.key]) continue;
678 const model = this.modelSlot(slot.key);
679 if (this.isOauthProvider(model.provider) && !String(model.name || "").trim()) {
680 throw new Error(`Choose a ${slot.title} before saving.`);
681 }
682 }
683 },
684
685 async saveModelConfigIfDirty() {
686 if (!this.modelConfigDirty || !this.modelConfig) return;
687 this.validateModelConfig();
688 this.modelConfigSaving = true;
689 try {
690 const response = await fetchApi(`${MODEL_CONFIG_API}/model_config_set`, {
691 method: "POST",
692 headers: { "Content-Type": "application/json" },
693 body: JSON.stringify({
694 project_name: "",
695 agent_profile: "",
696 config: this.modelConfig,
697 }),
698 });
699 const data = await response.json().catch(() => ({}));
700 if (!data?.ok) throw new Error(data?.error || "Could not save model selection.");
701 this.modelConfigDirty = false;
702 this.modelSlotDirty = { chat_model: false, utility_model: false };
703 await modelConfigStore.refreshModelsSummary?.();
704 } finally {
705 this.modelConfigSaving = false;
706 }
707 },
708
709 notifyModelSetupChanged(providerId) {
710 if (typeof document === "undefined") return;
711 document.dispatchEvent(new CustomEvent("model-setup-changed", {
712 detail: { source: "_oauth", providerId },
713 }));
714 },
715
716 async handleProviderConnected(providerId, { statusLoaded = false } = {}) {
717 if (!statusLoaded) await this.loadStatus();
718 this.notifyModelSetupChanged(providerId);
719 },
720
721 async loadStatus() {
722 if (this.loadingStatus) return;
723 this.loadingStatus = true;
724 try {
725 const response = await callJsonApi(STATUS_API, {});
726 this.status = response;
727 for (const card of this.providerCards()) {
728 const ui = this.providerUiFor(card.provider_id);
729 if (card.enterprise_domain && !ui.enterprise_domain) {
730 ui.enterprise_domain = card.enterprise_domain;
731 }
732 if (card.client_id && !ui.client_id) {
733 ui.client_id = card.client_id;
734 }
735 if (card.quota_project_id && !ui.quota_project_id) {
736 ui.quota_project_id = card.quota_project_id;
737 }
738 }
739 if (!this.providerConnected(this.activeModelProvider)) {
740 this.activeModelProvider = this.connectedProviderCards()[0]?.provider_id
741 || this.providerCards()[0]?.provider_id
742 || CODEX_PROVIDER;
743 this.models = this.activeProviderModels();
744 }
745 if (!this.isOauthProvider(this.selectedProviderId)) {
746 this.selectedProviderId = this.connectedProviderCards()[0]?.provider_id
747 || this.providerCards()[0]?.provider_id
748 || "";
749 }
750 this.applySoleConnectedProviderDefaults();
751 } catch (error) {
752 void toastFrontendError(messageOf(error), "OAuth Connections");
753 } finally {
754 this.loadingStatus = false;
755 }
756 },
757
758 async connectProvider(providerId) {
759 if (!this.isOauthProvider(providerId) || this.connectingProvider) return;
760 this.connectingProvider = providerId;
761 this.connecting = true;
762 try {
763 const payload = { provider_id: providerId };
764 const status = this.providerStatus(providerId);
765 const ui = this.providerUiFor(providerId);
766 if (status.supports_enterprise_domain) {
767 payload.enterprise_domain = ui.enterprise_domain || "";
768 }
769 if (status.supports_oauth_client_config) {
770 payload.client_id = ui.client_id || this.geminiApi().client_id || "";
771 payload.client_secret = ui.client_secret || this.geminiApi().client_secret || "";
772 }
773 if (status.supports_quota_project) {
774 payload.quota_project_id = ui.quota_project_id || this.geminiApi().quota_project_id || "";
775 }
776 const response = await callJsonApi(START_LOGIN_API, payload);
777 if (!response?.ok) {
778 throw new Error(response?.error || `Could not start ${this.providerLabel(providerId)} sign-in.`);
779 }
780
781 this.devices = { ...this.devices, [providerId]: response };
782 if (providerId === CODEX_PROVIDER) this.device = response;
783
784 if (response.flow === "device_code" && response.verification_url && response.attempt_id) {
785 window.open(response.verification_url, "_blank", "noopener,noreferrer");
786 void toastFrontendInfo("Enter the code shown here in the opened browser tab.", "OAuth Connections");
787 this.startPolling(providerId);
788 return;
789 }
790
791 if (response.flow === "browser_pkce" && response.auth_url) {
792 window.open(response.auth_url, "_blank", "noopener,noreferrer");
793 void toastFrontendInfo("Finish sign-in in the opened browser tab.", "OAuth Connections");
794 this.startCallbackPolling(providerId);
795 return;
796 }
797
798 throw new Error(response?.error || `Could not start ${this.providerLabel(providerId)} sign-in.`);
799 } catch (error) {
800 this.clearProviderDevice(providerId);
801 this.connectingProvider = "";
802 this.connecting = false;
803 void toastFrontendError(messageOf(error), "OAuth Connections");
804 }
805 },
806
807 startPolling(providerId = CODEX_PROVIDER) {
808 this.stopPolling(providerId);
809 this.pollStartedAt = { ...this.pollStartedAt, [providerId]: Date.now() };
810 const clearTimer = () => {
811 if (this.pollTimers[providerId]) window.clearTimeout(this.pollTimers[providerId]);
812 const timers = { ...this.pollTimers };
813 delete timers[providerId];
814 this.pollTimers = timers;
815 if (providerId === CODEX_PROVIDER) this.pollTimer = null;
816 };
817 const schedule = (delayMs) => {
818 clearTimer();
819 this.pollTimers = { ...this.pollTimers, [providerId]: window.setTimeout(tick, delayMs) };
820 if (providerId === CODEX_PROVIDER) this.pollTimer = this.pollTimers[providerId];
821 };
822 const tick = async () => {
823 clearTimer();
824 const device = this.devices[providerId];
825 if (!device?.attempt_id) return;
826 let nextDelay = Math.max(1500, Number(device.interval || 5) * 1000);
827 try {
828 const response = await callJsonApi(POLL_LOGIN_API, {
829 provider_id: providerId,
830 attempt_id: device.attempt_id,
831 });
832 if (!response?.ok) {
833 if (response?.expired) {
834 this.clearProviderDevice(providerId);
835 this.stopPolling(providerId);
836 }
837 throw new Error(response?.error || `Could not finish ${this.providerLabel(providerId)} sign-in.`);
838 }
839 if (response.completed) {
840 await this.handleProviderConnected(providerId);
841 this.clearProviderDevice(providerId);
842 this.stopPolling(providerId);
843 if (this.connectingProvider === providerId) this.connectingProvider = "";
844 this.connecting = Boolean(this.connectingProvider);
845 void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections");
846 return;
847 }
848 if (response.interval || response.expires_at) {
849 const updatedDevice = {
850 ...device,
851 interval: response.interval || device.interval,
852 expires_at: response.expires_at || device.expires_at,
853 };
854 this.devices = { ...this.devices, [providerId]: updatedDevice };
855 if (providerId === CODEX_PROVIDER) this.device = updatedDevice;
856 nextDelay = Math.max(1500, Number(updatedDevice.interval || 5) * 1000);
857 }
858 } catch (error) {
859 if (this.connectingProvider === providerId) this.connectingProvider = "";
860 this.connecting = Boolean(this.connectingProvider);
861 this.stopPolling(providerId);
862 void toastFrontendError(messageOf(error), "OAuth Connections");
863 return;
864 }
865 const expiresAt = Number(this.devices[providerId]?.expires_at || 0);
866 const timedOut = expiresAt > 0
867 ? Date.now() / 1000 > expiresAt
868 : Date.now() - Number(this.pollStartedAt[providerId] || 0) > MAX_POLL_MS;
869 if (timedOut) {
870 if (this.connectingProvider === providerId) this.connectingProvider = "";
871 this.connecting = Boolean(this.connectingProvider);
872 this.clearProviderDevice(providerId);
873 this.stopPolling(providerId);
874 return;
875 }
876 schedule(nextDelay);
877 };
878 const initialDelay = Math.max(1500, Number(this.devices[providerId]?.interval || 5) * 1000);
879 schedule(initialDelay);
880 },
881
882 pollProvider(providerId = CODEX_PROVIDER) {
883 this.startPolling(providerId);
884 },
885
886 startCallbackPolling(providerId) {
887 this.stopCallbackPolling(providerId);
888 this.callbackPollStartedAt = { ...this.callbackPollStartedAt, [providerId]: Date.now() };
889 const tick = async () => {
890 await this.loadStatus();
891 if (this.providerConnected(providerId)) {
892 await this.handleProviderConnected(providerId, { statusLoaded: true });
893 this.clearProviderDevice(providerId);
894 this.stopCallbackPolling(providerId);
895 if (this.connectingProvider === providerId) this.connectingProvider = "";
896 this.connecting = Boolean(this.connectingProvider);
897 void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections");
898 return;
899 }
900 if (Date.now() - Number(this.callbackPollStartedAt[providerId] || 0) > MAX_POLL_MS) {
901 this.stopCallbackPolling(providerId);
902 if (this.connectingProvider === providerId) this.connectingProvider = "";
903 this.connecting = Boolean(this.connectingProvider);
904 }
905 };
906 this.callbackPollTimers = {
907 ...this.callbackPollTimers,
908 [providerId]: window.setInterval(tick, 2500),
909 };
910 void tick();
911 },
912
913 stopPolling(providerId = "") {
914 if (providerId) {
915 if (this.pollTimers[providerId]) window.clearTimeout(this.pollTimers[providerId]);
916 const timers = { ...this.pollTimers };
917 delete timers[providerId];
918 this.pollTimers = timers;
919 const startedAt = { ...this.pollStartedAt };
920 delete startedAt[providerId];
921 this.pollStartedAt = startedAt;
922 if (providerId === CODEX_PROVIDER) this.pollTimer = null;
923 return;
924 }
925
926 for (const timer of Object.values(this.pollTimers || {})) {
927 if (timer) window.clearTimeout(timer);
928 }
929 this.pollTimers = {};
930 this.pollStartedAt = {};
931 this.pollTimer = null;
932 },
933
934 stopCallbackPolling(providerId = "") {
935 if (providerId) {
936 if (this.callbackPollTimers[providerId]) window.clearInterval(this.callbackPollTimers[providerId]);
937 const timers = { ...this.callbackPollTimers };
938 delete timers[providerId];
939 this.callbackPollTimers = timers;
940 const startedAt = { ...this.callbackPollStartedAt };
941 delete startedAt[providerId];
942 this.callbackPollStartedAt = startedAt;
943 return;
944 }
945
946 for (const timer of Object.values(this.callbackPollTimers || {})) {
947 if (timer) window.clearInterval(timer);
948 }
949 this.callbackPollTimers = {};
950 this.callbackPollStartedAt = {};
951 },
952
953 clearProviderDevice(providerId = "") {
954 if (!providerId) {
955 this.devices = {};
956 this.device = null;
957 return;
958 }
959 const devices = { ...this.devices };
960 delete devices[providerId];
961 this.devices = devices;
962 if (providerId === CODEX_PROVIDER) this.device = null;
963 },
964
965 async submitManualCallback(providerId) {
966 if (!this.isOauthProvider(providerId)) return;
967 const callback = String(this.providerUiFor(providerId).manualCallback || "").trim();
968 if (!callback) {
969 void toastFrontendError("Paste callback URL, query string, or code.", "OAuth Connections");
970 return;
971 }
972 this.connectingProvider = providerId;
973 this.connecting = true;
974 try {
975 const response = await callJsonApi(MANUAL_CALLBACK_API, {
976 provider_id: providerId,
977 callback,
978 });
979 if (!response?.ok) {
980 throw new Error(response?.error || `Could not finish ${this.providerLabel(providerId)} sign-in.`);
981 }
982 this.providerUiFor(providerId).manualCallback = "";
983 this.clearProviderDevice(providerId);
984 this.stopCallbackPolling(providerId);
985 await this.handleProviderConnected(providerId);
986 void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections");
987 } catch (error) {
988 void toastFrontendError(messageOf(error), "OAuth Connections");
989 } finally {
990 if (this.connectingProvider === providerId) this.connectingProvider = "";
991 this.connecting = Boolean(this.connectingProvider);
992 }
993 },
994
995 async loadModels({ providerId = "", openDropdown = "", silent = false } = {}) {
996 const selectedProvider = providerId || this.activeModelProvider || CODEX_PROVIDER;
997 if (!this.isOauthProvider(selectedProvider) || this.loadingModelsProvider) return;
998 this.loadingModelsProvider = selectedProvider;
999 this.loadingModels = true;
1000 this.activeModelProvider = selectedProvider;
1001 try {
1002 const response = await callJsonApi(MODELS_API, { provider_id: selectedProvider });
1003 if (!response?.ok) {
1004 throw new Error(response?.error || `Could not load ${this.providerLabel(selectedProvider)} models.`);
1005 }
1006 const models = Array.isArray(response.models) ? response.models : [];
1007 const metadata = Array.isArray(response.model_metadata) ? response.model_metadata : [];
1008 const metadataMap = metadata.reduce((result, item) => {
1009 const key = String(item?.slug || item?.id || "");
1010 if (key) result[key] = item;
1011 return result;
1012 }, {});
1013 this.providerModels = { ...this.providerModels, [selectedProvider]: models };
1014 this.providerModelMetadata = { ...this.providerModelMetadata, [selectedProvider]: metadataMap };
1015 this.models = models;
1016 if (openDropdown) this.openModelDropdown(openDropdown);
1017 if (!silent) void toastFrontendSuccess(`${this.providerLabel(selectedProvider)} models loaded.`, "OAuth Connections");
1018 } catch (error) {
1019 this.providerModels = { ...this.providerModels, [selectedProvider]: [] };
1020 this.providerModelMetadata = { ...this.providerModelMetadata, [selectedProvider]: {} };
1021 this.models = [];
1022 if (!silent) void toastFrontendError(messageOf(error), "OAuth Connections");
1023 } finally {
1024 this.loadingModelsProvider = "";
1025 this.loadingModels = false;
1026 }
1027 },
1028
1029 async disconnectProvider(providerId) {
1030 if (!this.isOauthProvider(providerId) || this.disconnectingProvider || !this.providerConnected(providerId)) return;
1031 const confirmed = window.confirm(`Disconnect ${this.providerLabel(providerId)} and remove stored OAuth tokens?`);
1032 if (!confirmed) return;
1033
1034 this.disconnectingProvider = providerId;
1035 this.disconnecting = true;
1036 try {
1037 const response = await callJsonApi(DISCONNECT_API, { provider_id: providerId });
1038 if (!response?.ok) throw new Error(response?.error || "Could not disconnect the account.");
1039 if (response.provider) {
1040 const providerMap = { ...this.providerMap(), [providerId]: response.provider };
1041 this.status = { ...(this.status || {}), provider_map: providerMap, providers: Object.values(providerMap) };
1042 if (providerId === CODEX_PROVIDER) this.status.codex = response.provider;
1043 }
1044 const providerModels = { ...this.providerModels };
1045 delete providerModels[providerId];
1046 this.providerModels = providerModels;
1047 const providerModelMetadata = { ...this.providerModelMetadata };
1048 delete providerModelMetadata[providerId];
1049 this.providerModelMetadata = providerModelMetadata;
1050 if (this.activeModelProvider === providerId) this.models = [];
1051 this.clearProviderDevice(providerId);
1052 if (this.connectingProvider === providerId) this.connectingProvider = "";
1053 this.connecting = Boolean(this.connectingProvider);
1054 this.stopPolling(providerId);
1055 this.stopCallbackPolling(providerId);
1056 void toastFrontendSuccess(`${this.providerLabel(providerId)} disconnected.`, "OAuth Connections");
1057 await this.loadStatus();
1058 } catch (error) {
1059 void toastFrontendError(messageOf(error), "OAuth Connections");
1060 } finally {
1061 if (this.disconnectingProvider === providerId) this.disconnectingProvider = "";
1062 this.disconnecting = Boolean(this.disconnectingProvider);
1063 }
1064 },
1065
1066 cancelConnect(providerId = "") {
1067 if (providerId) {
1068 this.stopPolling(providerId);
1069 this.stopCallbackPolling(providerId);
1070 this.clearProviderDevice(providerId);
1071 if (this.connectingProvider === providerId) this.connectingProvider = "";
1072 } else {
1073 this.stopPolling();
1074 this.stopCallbackPolling();
1075 this.clearProviderDevice();
1076 this.connectingProvider = "";
1077 }
1078 this.connecting = Boolean(this.connectingProvider);
1079 },
1080 });