From 6fa701a7e2ae9a1622665e48759ce66b8b8c047e Mon Sep 17 00:00:00 2001 From: elkimek <36666630+elkimek@users.noreply.github.com> Date: Sat, 30 May 2026 14:29:56 +0200 Subject: [PATCH 1/2] Extract API provider storage --- js/api-provider-storage.js | 236 +++++++++++++++++++++++++ js/api.js | 345 ++++++++++++------------------------- service-worker.js | 1 + tests/test-audit.js | 8 +- tests/test-custom-api.js | 25 +-- tests/test-openrouter.js | 19 +- tests/test-venice-e2ee.js | 11 +- version.js | 2 +- 8 files changed, 380 insertions(+), 267 deletions(-) create mode 100644 js/api-provider-storage.js diff --git a/js/api-provider-storage.js b/js/api-provider-storage.js new file mode 100644 index 00000000..6a9bff7d --- /dev/null +++ b/js/api-provider-storage.js @@ -0,0 +1,236 @@ +// api-provider-storage.js — persisted AI provider settings, keys, and model caches. + +import { getCachedKey, updateKeyCache, encryptedSetItem } from './crypto.js'; + +function notifyAISelectionChanged() { + window.updateChatHeaderModel?.(); + window.refreshWebSearchToggle?.(); +} + +export function getAIProvider() { return localStorage.getItem('labcharts-ai-provider') || 'openrouter'; } +export function setAIProvider(provider) { + localStorage.setItem('labcharts-ai-provider', provider); + markAISettingsLocal(); + notifyAISelectionChanged(); +} +export function isAIPaused() { return localStorage.getItem('labcharts-ai-paused') === 'true'; } +export function setAIPaused(v) { localStorage.setItem('labcharts-ai-paused', v ? 'true' : 'false'); } + +const AI_SETTINGS_LOCAL_LOCK_UNTIL_KEY = 'labcharts-ai-settings-local-lock-until'; + +export function markAISettingsLocal() { + try { + sessionStorage.setItem(AI_SETTINGS_LOCAL_LOCK_UNTIL_KEY, String(Date.now() + 5 * 60 * 1000)); + } catch {} + try { window.dispatchEvent(new CustomEvent('labcharts-ai-settings-local-changed')); } catch {} +} + +export function hasAIProvider() { + if (isAIPaused()) return false; + const provider = getAIProvider(); + if (provider === 'venice') return hasVeniceKey(); + if (provider === 'openrouter') return hasOpenRouterKey(); + if (provider === 'routstr') return hasRoutstrKey(); + if (provider === 'ppq') return hasPpqKey(); + if (provider === 'custom') return hasCustomApiKey() && !!getCustomApiUrl(); + return true; // Ollama — optimistic, errors caught at call time +} + +export function getOllamaMainModel() { return localStorage.getItem('labcharts-ollama-model') || window.getOllamaConfig().model || 'llama3.2'; } +export function setOllamaMainModel(model) { + localStorage.setItem('labcharts-ollama-model', model); + markAISettingsLocal(); + notifyAISelectionChanged(); +} +export function getOllamaPIIUrl() { return localStorage.getItem('labcharts-ollama-pii-url') || window.getOllamaConfig().url; } +export function setOllamaPIIUrl(url) { + localStorage.setItem('labcharts-ollama-pii-url', url); + markAISettingsLocal(); +} +export function getOllamaPIIModel() { return localStorage.getItem('labcharts-ollama-pii-model') || getOllamaMainModel(); } +export function setOllamaPIIModel(model) { + localStorage.setItem('labcharts-ollama-pii-model', model); + markAISettingsLocal(); +} + +export function getVeniceKey() { return getCachedKey('labcharts-venice-key') || ''; } +export async function saveVeniceKey(key) { await encryptedSetItem('labcharts-venice-key', key); updateKeyCache('labcharts-venice-key', key); markAISettingsLocal(); } +export function hasVeniceKey() { return !!getVeniceKey(); } +export function getVeniceModel() { return localStorage.getItem('labcharts-venice-model') || 'llama-3.3-70b'; } +export function setVeniceModel(model) { + localStorage.setItem('labcharts-venice-model', model); + markAISettingsLocal(); + notifyAISelectionChanged(); +} + +export function readStoredArray(key) { + try { + const parsed = JSON.parse(localStorage.getItem(key) || '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function modelListHasId(models, id) { + return models.some(function(m) { return m && m.id === id; }); +} + +function veniceE2EEModelsCacheKnown() { + return localStorage.getItem('labcharts-venice-e2ee-models') !== null; +} + +export function modelSupportsVeniceE2EE(model) { + const supports = model?.model_spec?.capabilities?.supportsE2EE; + if (supports === true) return true; + if (supports === false) return false; + return typeof model?.id === 'string' && model.id.startsWith('e2ee-'); +} + +function preferredVeniceModelId(models, savedId, preferLlama = false) { + if (!models.length) return ''; + if (savedId && modelListHasId(models, savedId)) return savedId; + if (preferLlama) { + const llama = models.find(function(m) { return m.id && m.id.includes('llama-3.3-70b'); }); + if (llama) return llama.id; + } + return models[0].id; +} + +export function syncVeniceModelSelection(regularModels, e2eeModels) { + const current = getVeniceModel(); + const e2eeOn = getVeniceE2EE(); + if (e2eeOn) { + if (e2eeModels.length) { + if (!modelListHasId(e2eeModels, current)) { + const next = preferredVeniceModelId(e2eeModels, localStorage.getItem('labcharts-venice-model-e2ee')); + if (next) { + setVeniceModel(next); + localStorage.setItem('labcharts-venice-model-e2ee', next); + } + } + return; + } + return; + } + if (regularModels.length && !modelListHasId(regularModels, getVeniceModel())) { + const next = preferredVeniceModelId(regularModels, localStorage.getItem('labcharts-venice-model-regular'), true); + if (next) setVeniceModel(next); + } +} + +export function veniceModelsCacheStale() { + const fetchedAt = Number(localStorage.getItem('labcharts-venice-models-fetched-at') || 0); + return !fetchedAt || Date.now() - fetchedAt > 60 * 60 * 1000; +} + +export function getVeniceModelDisplay() { + const id = getVeniceModel(); + const cached = [ + ...readStoredArray('labcharts-venice-models'), + ...readStoredArray('labcharts-venice-e2ee-models') + ]; + const m = cached.find(function(x) { return x.id === id; }); + return m ? (m.name || m.id) : id; +} + +export function getVeniceE2EE() { return localStorage.getItem('labcharts-venice-e2ee') === 'on'; } +export function setVeniceE2EE(on) { + localStorage.setItem('labcharts-venice-e2ee', on ? 'on' : 'off'); + markAISettingsLocal(); +} + +export function isE2EEModel(modelId) { + if (typeof modelId !== 'string') return false; + const e2eeModels = readStoredArray('labcharts-venice-e2ee-models'); + if (veniceE2EEModelsCacheKnown()) return modelListHasId(e2eeModels, modelId); + return modelId.startsWith('e2ee-'); +} + +export function isVeniceE2EEActive() { + return isE2EEModel(getVeniceModel()); +} + +export function getOpenRouterKey() { return getCachedKey('labcharts-openrouter-key') || ''; } +export async function saveOpenRouterKey(key) { await encryptedSetItem('labcharts-openrouter-key', key); updateKeyCache('labcharts-openrouter-key', key); markAISettingsLocal(); } +export function hasOpenRouterKey() { return !!getOpenRouterKey(); } +export function getOpenRouterModel() { + let m = localStorage.getItem('labcharts-openrouter-model'); + // Fix legacy hyphenated IDs (OpenRouter uses dots: anthropic/claude-sonnet-4.6) + if (m === 'anthropic/claude-sonnet-4-6') { m = 'anthropic/claude-sonnet-4.6'; localStorage.setItem('labcharts-openrouter-model', m); } + return m || 'anthropic/claude-sonnet-4.6'; +} +export function setOpenRouterModel(model) { + localStorage.setItem('labcharts-openrouter-model', model); + markAISettingsLocal(); + notifyAISelectionChanged(); +} +export function getOpenRouterModelDisplay() { + const id = getOpenRouterModel(); + const cached = readStoredArray('labcharts-openrouter-models'); + const m = cached.find(function(x) { return x.id === id; }); + return m ? (m.name || m.id) : id; +} +export function getOpenRouterPricing(modelId) { + let cached = {}; try { cached = JSON.parse(localStorage.getItem('labcharts-openrouter-pricing') || '{}'); } catch(e) {} + return cached[modelId] || null; +} + +export function getRoutstrKey() { return getCachedKey('labcharts-routstr-key') || ''; } +export async function saveRoutstrKey(key) { await encryptedSetItem('labcharts-routstr-key', key); updateKeyCache('labcharts-routstr-key', key); markAISettingsLocal(); } +export function hasRoutstrKey() { return !!getRoutstrKey(); } +export function getRoutstrModel() { return localStorage.getItem('labcharts-routstr-model') || 'claude-sonnet-4.6'; } +export function setRoutstrModel(model) { + localStorage.setItem('labcharts-routstr-model', model); + markAISettingsLocal(); + notifyAISelectionChanged(); +} +export function getRoutstrModelDisplay() { + const id = getRoutstrModel(); + const cached = readStoredArray('labcharts-routstr-models'); + const m = cached.find(function(x) { return x.id === id; }); + return m ? (m.name || m.id) : id; +} + +export function getPpqKey() { return getCachedKey('labcharts-ppq-key') || ''; } +export async function savePpqKey(key) { await encryptedSetItem('labcharts-ppq-key', key); updateKeyCache('labcharts-ppq-key', key); markAISettingsLocal(); } +export function hasPpqKey() { return !!getPpqKey(); } +export function getPpqModel() { return localStorage.getItem('labcharts-ppq-model') || 'claude-sonnet-4.6'; } +export function setPpqModel(model) { + localStorage.setItem('labcharts-ppq-model', model); + markAISettingsLocal(); + notifyAISelectionChanged(); +} +export function getPpqModelDisplay() { + const id = getPpqModel(); + const cached = readStoredArray('labcharts-ppq-models'); + const m = cached.find(function(x) { return x.id === id; }); + return m ? (m.name || m.id) : id; +} +export function getPpqCreditId() { return localStorage.getItem('labcharts-ppq-credit-id') || ''; } +export function savePpqCreditId(id) { + localStorage.setItem('labcharts-ppq-credit-id', id); + markAISettingsLocal(); +} + +export function getCustomApiUrl() { return localStorage.getItem('labcharts-custom-url') || ''; } +export function setCustomApiUrl(url) { + localStorage.setItem('labcharts-custom-url', url); + markAISettingsLocal(); +} +export function getCustomApiKey() { return getCachedKey('labcharts-custom-key') || ''; } +export async function saveCustomApiKey(key) { await encryptedSetItem('labcharts-custom-key', key); updateKeyCache('labcharts-custom-key', key); markAISettingsLocal(); } +export function hasCustomApiKey() { return !!getCustomApiKey(); } +export function getCustomApiModel() { return localStorage.getItem('labcharts-custom-model') || ''; } +export function setCustomApiModel(model) { + localStorage.setItem('labcharts-custom-model', model); + markAISettingsLocal(); + notifyAISelectionChanged(); +} +export function getCustomApiModelDisplay() { + const id = getCustomApiModel(); + if (!id) return '(no model selected)'; + const cached = readStoredArray('labcharts-custom-models'); + const m = cached.find(function(x) { return x.id === id; }); + return m ? (m.name || m.id) : id; +} diff --git a/js/api.js b/js/api.js index 1c7692e4..f5f73f62 100644 --- a/js/api.js +++ b/js/api.js @@ -2,18 +2,125 @@ import { getModelPricing } from './schema.js'; import { isDebugMode } from './utils.js'; -import { getCachedKey, updateKeyCache, encryptedSetItem } from './crypto.js'; import { FETCH_REQUEST_TIMEOUT_MS, createProxyFetch, fetchWithRetry, readWithStallTimeout, } from './api-transport.js'; +import { + getAIProvider, + setAIProvider, + markAISettingsLocal, + hasAIProvider, + getOllamaMainModel, + setOllamaMainModel, + getOllamaPIIUrl, + setOllamaPIIUrl, + getOllamaPIIModel, + setOllamaPIIModel, + getVeniceKey, + saveVeniceKey, + hasVeniceKey, + getVeniceModel, + setVeniceModel, + getVeniceModelDisplay, + getVeniceE2EE, + setVeniceE2EE, + modelSupportsVeniceE2EE, + readStoredArray, + syncVeniceModelSelection, + veniceModelsCacheStale, + isE2EEModel, + isVeniceE2EEActive, + getOpenRouterKey, + saveOpenRouterKey, + hasOpenRouterKey, + getOpenRouterModel, + setOpenRouterModel, + getOpenRouterModelDisplay, + getOpenRouterPricing, + getRoutstrKey, + saveRoutstrKey, + hasRoutstrKey, + getRoutstrModel, + setRoutstrModel, + getRoutstrModelDisplay, + getPpqKey, + savePpqKey, + hasPpqKey, + getPpqModel, + setPpqModel, + getPpqModelDisplay, + getPpqCreditId, + savePpqCreditId, + getCustomApiUrl, + setCustomApiUrl, + getCustomApiKey, + saveCustomApiKey, + hasCustomApiKey, + getCustomApiModel, + setCustomApiModel, + getCustomApiModelDisplay, +} from './api-provider-storage.js'; export { AI_IMPORT_REQUEST_TIMEOUT_MS, FETCH_REQUEST_TIMEOUT_MS, STREAM_STALL_TIMEOUT_MS, } from './api-transport.js'; +export { + getAIProvider, + setAIProvider, + isAIPaused, + setAIPaused, + markAISettingsLocal, + hasAIProvider, + getOllamaMainModel, + setOllamaMainModel, + getOllamaPIIUrl, + setOllamaPIIUrl, + getOllamaPIIModel, + setOllamaPIIModel, + getVeniceKey, + saveVeniceKey, + hasVeniceKey, + getVeniceModel, + setVeniceModel, + getVeniceModelDisplay, + getVeniceE2EE, + setVeniceE2EE, + isE2EEModel, + isVeniceE2EEActive, + getOpenRouterKey, + saveOpenRouterKey, + hasOpenRouterKey, + getOpenRouterModel, + setOpenRouterModel, + getOpenRouterModelDisplay, + getOpenRouterPricing, + getRoutstrKey, + saveRoutstrKey, + hasRoutstrKey, + getRoutstrModel, + setRoutstrModel, + getRoutstrModelDisplay, + getPpqKey, + savePpqKey, + hasPpqKey, + getPpqModel, + setPpqModel, + getPpqModelDisplay, + getPpqCreditId, + savePpqCreditId, + getCustomApiUrl, + setCustomApiUrl, + getCustomApiKey, + saveCustomApiKey, + hasCustomApiKey, + getCustomApiModel, + setCustomApiModel, + getCustomApiModelDisplay, +} from './api-provider-storage.js'; function isTokenLimitFinish(reason) { const r = String(reason || '').toLowerCase(); @@ -24,9 +131,6 @@ function isTokenLimitFinish(reason) { || r.includes('max token'); } -// ═══════════════════════════════════════════════ -// AI PROVIDER MANAGEMENT -// ═══════════════════════════════════════════════ export function deduplicateModels(models, familyFn) { const seen = {}; return models.filter(function(m) { @@ -36,67 +140,15 @@ export function deduplicateModels(models, familyFn) { return true; }); } -function notifyAISelectionChanged() { - window.updateChatHeaderModel?.(); - window.refreshWebSearchToggle?.(); -} - -export function getAIProvider() { return localStorage.getItem('labcharts-ai-provider') || 'openrouter'; } -export function setAIProvider(provider) { - localStorage.setItem('labcharts-ai-provider', provider); - markAISettingsLocal(); - notifyAISelectionChanged(); -} -export function isAIPaused() { return localStorage.getItem('labcharts-ai-paused') === 'true'; } -export function setAIPaused(v) { localStorage.setItem('labcharts-ai-paused', v ? 'true' : 'false'); } const OPENROUTER_OAUTH_PREVIOUS_PROVIDER_KEY = 'or_previous_ai_provider'; const OPENROUTER_OAUTH_LOCAL_SETTINGS_LOCK_UNTIL_KEY = 'or_oauth_local_settings_lock_until'; -const AI_SETTINGS_LOCAL_LOCK_UNTIL_KEY = 'labcharts-ai-settings-local-lock-until'; const OPENROUTER_OAUTH_PROVIDERS = new Set(['openrouter', 'venice', 'routstr', 'ppq', 'custom', 'ollama']); function _isValidAIProvider(provider) { return typeof provider === 'string' && OPENROUTER_OAUTH_PROVIDERS.has(provider); } -export function markAISettingsLocal() { - try { - sessionStorage.setItem(AI_SETTINGS_LOCAL_LOCK_UNTIL_KEY, String(Date.now() + 5 * 60 * 1000)); - } catch {} - try { window.dispatchEvent(new CustomEvent('labcharts-ai-settings-local-changed')); } catch {} -} - -export function hasAIProvider() { - if (isAIPaused()) return false; - const provider = getAIProvider(); - if (provider === 'venice') return hasVeniceKey(); - if (provider === 'openrouter') return hasOpenRouterKey(); - if (provider === 'routstr') return hasRoutstrKey(); - if (provider === 'ppq') return hasPpqKey(); - if (provider === 'custom') return hasCustomApiKey() && !!getCustomApiUrl(); - return true; // Ollama — optimistic, errors caught at call time -} - -export function getOllamaMainModel() { return localStorage.getItem('labcharts-ollama-model') || window.getOllamaConfig().model || 'llama3.2'; } -export function setOllamaMainModel(model) { - localStorage.setItem('labcharts-ollama-model', model); - markAISettingsLocal(); - notifyAISelectionChanged(); -} -export function getOllamaPIIUrl() { return localStorage.getItem('labcharts-ollama-pii-url') || window.getOllamaConfig().url; } -export function setOllamaPIIUrl(url) { - localStorage.setItem('labcharts-ollama-pii-url', url); - markAISettingsLocal(); -} -export function getOllamaPIIModel() { return localStorage.getItem('labcharts-ollama-pii-model') || getOllamaMainModel(); } -export function setOllamaPIIModel(model) { - localStorage.setItem('labcharts-ollama-pii-model', model); - markAISettingsLocal(); -} - -export function getVeniceKey() { return getCachedKey('labcharts-venice-key') || ''; } -export async function saveVeniceKey(key) { await encryptedSetItem('labcharts-venice-key', key); updateKeyCache('labcharts-venice-key', key); markAISettingsLocal(); } -export function hasVeniceKey() { return !!getVeniceKey(); } export async function getVeniceBalance() { const key = getVeniceKey(); if (!key) return null; @@ -115,93 +167,7 @@ export async function getVeniceBalance() { return null; } catch { return null; } } -export function getVeniceModel() { return localStorage.getItem('labcharts-venice-model') || 'llama-3.3-70b'; } -export function setVeniceModel(model) { - localStorage.setItem('labcharts-venice-model', model); - markAISettingsLocal(); - notifyAISelectionChanged(); -} - -function readStoredArray(key) { - try { - const parsed = JSON.parse(localStorage.getItem(key) || '[]'); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function modelListHasId(models, id) { - return models.some(function(m) { return m && m.id === id; }); -} - -function veniceE2EEModelsCacheKnown() { - return localStorage.getItem('labcharts-venice-e2ee-models') !== null; -} - -function modelSupportsVeniceE2EE(model) { - const supports = model?.model_spec?.capabilities?.supportsE2EE; - if (supports === true) return true; - if (supports === false) return false; - return typeof model?.id === 'string' && model.id.startsWith('e2ee-'); -} - -function preferredVeniceModelId(models, savedId, preferLlama = false) { - if (!models.length) return ''; - if (savedId && modelListHasId(models, savedId)) return savedId; - if (preferLlama) { - const llama = models.find(function(m) { return m.id && m.id.includes('llama-3.3-70b'); }); - if (llama) return llama.id; - } - return models[0].id; -} - -function syncVeniceModelSelection(regularModels, e2eeModels) { - const current = getVeniceModel(); - const e2eeOn = getVeniceE2EE(); - if (e2eeOn) { - if (e2eeModels.length) { - if (!modelListHasId(e2eeModels, current)) { - const next = preferredVeniceModelId(e2eeModels, localStorage.getItem('labcharts-venice-model-e2ee')); - if (next) { - setVeniceModel(next); - localStorage.setItem('labcharts-venice-model-e2ee', next); - } - } - return; - } - return; - } - if (regularModels.length && !modelListHasId(regularModels, getVeniceModel())) { - const next = preferredVeniceModelId(regularModels, localStorage.getItem('labcharts-venice-model-regular'), true); - if (next) setVeniceModel(next); - } -} -function veniceModelsCacheStale() { - const fetchedAt = Number(localStorage.getItem('labcharts-venice-models-fetched-at') || 0); - return !fetchedAt || Date.now() - fetchedAt > 60 * 60 * 1000; -} - -export function getVeniceModelDisplay() { - const id = getVeniceModel(); - const cached = [ - ...readStoredArray('labcharts-venice-models'), - ...readStoredArray('labcharts-venice-e2ee-models') - ]; - const m = cached.find(function(x) { return x.id === id; }); - return m ? (m.name || m.id) : id; -} - -export function getVeniceE2EE() { return localStorage.getItem('labcharts-venice-e2ee') === 'on'; } -export function setVeniceE2EE(on) { - localStorage.setItem('labcharts-venice-e2ee', on ? 'on' : 'off'); - markAISettingsLocal(); -} - -export function getOpenRouterKey() { return getCachedKey('labcharts-openrouter-key') || ''; } -export async function saveOpenRouterKey(key) { await encryptedSetItem('labcharts-openrouter-key', key); updateKeyCache('labcharts-openrouter-key', key); markAISettingsLocal(); } -export function hasOpenRouterKey() { return !!getOpenRouterKey(); } export async function getOpenRouterBalance() { const key = getOpenRouterKey(); if (!key) return null; @@ -217,85 +183,6 @@ export async function getOpenRouterBalance() { } catch { return null; } } -// ─── Routstr ─── -export function getRoutstrKey() { return getCachedKey('labcharts-routstr-key') || ''; } -export async function saveRoutstrKey(key) { await encryptedSetItem('labcharts-routstr-key', key); updateKeyCache('labcharts-routstr-key', key); markAISettingsLocal(); } -export function hasRoutstrKey() { return !!getRoutstrKey(); } -export function getRoutstrModel() { return localStorage.getItem('labcharts-routstr-model') || 'claude-sonnet-4.6'; } -export function setRoutstrModel(model) { - localStorage.setItem('labcharts-routstr-model', model); - markAISettingsLocal(); - notifyAISelectionChanged(); -} -export function getRoutstrModelDisplay() { - const id = getRoutstrModel(); - let cached = []; try { cached = JSON.parse(localStorage.getItem('labcharts-routstr-models') || '[]'); } catch(e) {} - const m = cached.find(function(x) { return x.id === id; }); - return m ? (m.name || m.id) : id; -} - -// ─── PPQ (PayPerQ — pay-per-prompt, crypto + fiat) ─── -export function getPpqKey() { return getCachedKey('labcharts-ppq-key') || ''; } -export async function savePpqKey(key) { await encryptedSetItem('labcharts-ppq-key', key); updateKeyCache('labcharts-ppq-key', key); markAISettingsLocal(); } -export function hasPpqKey() { return !!getPpqKey(); } -export function getPpqModel() { return localStorage.getItem('labcharts-ppq-model') || 'claude-sonnet-4.6'; } -export function setPpqModel(model) { - localStorage.setItem('labcharts-ppq-model', model); - markAISettingsLocal(); - notifyAISelectionChanged(); -} -export function getPpqModelDisplay() { - const id = getPpqModel(); - let cached = []; try { cached = JSON.parse(localStorage.getItem('labcharts-ppq-models') || '[]'); } catch(e) {} - const m = cached.find(function(x) { return x.id === id; }); - return m ? (m.name || m.id) : id; -} -export function getPpqCreditId() { return localStorage.getItem('labcharts-ppq-credit-id') || ''; } -export function savePpqCreditId(id) { - localStorage.setItem('labcharts-ppq-credit-id', id); - markAISettingsLocal(); -} - -// ─── Custom API (any OpenAI-compatible endpoint) ─── -export function getCustomApiUrl() { return localStorage.getItem('labcharts-custom-url') || ''; } -export function setCustomApiUrl(url) { - localStorage.setItem('labcharts-custom-url', url); - markAISettingsLocal(); -} -export function getCustomApiKey() { return getCachedKey('labcharts-custom-key') || ''; } -export async function saveCustomApiKey(key) { await encryptedSetItem('labcharts-custom-key', key); updateKeyCache('labcharts-custom-key', key); markAISettingsLocal(); } -export function hasCustomApiKey() { return !!getCustomApiKey(); } -export function getCustomApiModel() { return localStorage.getItem('labcharts-custom-model') || ''; } -export function setCustomApiModel(model) { - localStorage.setItem('labcharts-custom-model', model); - markAISettingsLocal(); - notifyAISelectionChanged(); -} -export function getCustomApiModelDisplay() { - const id = getCustomApiModel(); - if (!id) return '(no model selected)'; - let cached = []; try { cached = JSON.parse(localStorage.getItem('labcharts-custom-models') || '[]'); } catch(e) {} - const m = cached.find(function(x) { return x.id === id; }); - return m ? (m.name || m.id) : id; -} -export function getOpenRouterModel() { - let m = localStorage.getItem('labcharts-openrouter-model'); - // Fix legacy hyphenated IDs (OpenRouter uses dots: anthropic/claude-sonnet-4.6) - if (m === 'anthropic/claude-sonnet-4-6') { m = 'anthropic/claude-sonnet-4.6'; localStorage.setItem('labcharts-openrouter-model', m); } - return m || 'anthropic/claude-sonnet-4.6'; -} -export function setOpenRouterModel(model) { - localStorage.setItem('labcharts-openrouter-model', model); - markAISettingsLocal(); - notifyAISelectionChanged(); -} -export function getOpenRouterModelDisplay() { - const id = getOpenRouterModel(); - let cached = []; try { cached = JSON.parse(localStorage.getItem('labcharts-openrouter-models') || '[]'); } catch(e) {} - const m = cached.find(function(x) { return x.id === id; }); - return m ? (m.name || m.id) : id; -} - // ─── OpenRouter OAuth PKCE ─── export async function generatePKCE() { const array = new Uint8Array(32); @@ -498,10 +385,6 @@ export async function fetchOpenRouterModels(key) { return models; } catch (e) { return []; } } -export function getOpenRouterPricing(modelId) { - let cached = {}; try { cached = JSON.parse(localStorage.getItem('labcharts-openrouter-pricing') || '{}'); } catch(e) {} - return cached[modelId] || null; -} /** Fetch and cache pricing for a custom OpenRouter model not in the curated list */ export async function fetchOpenRouterModelPricing(modelId) { if (!modelId) return null; @@ -631,18 +514,6 @@ export function supportsWebSearch(provider = getAIProvider()) { return provider === 'openrouter'; } -export function isE2EEModel(modelId) { - if (typeof modelId !== 'string') return false; - const e2eeModels = readStoredArray('labcharts-venice-e2ee-models'); - if (veniceE2EEModelsCacheKnown()) return modelListHasId(e2eeModels, modelId); - return modelId.startsWith('e2ee-'); -} - -// Is Venice E2EE currently active? -export function isVeniceE2EEActive() { - return isE2EEModel(getVeniceModel()); -} - // ═══════════════════════════════════════════════ // VISION SUPPORT // ═══════════════════════════════════════════════ diff --git a/service-worker.js b/service-worker.js index 48de791d..860598d7 100755 --- a/service-worker.js +++ b/service-worker.js @@ -80,6 +80,7 @@ const APP_SHELL = [ '/js/utils.js', '/js/theme.js', '/js/api.js', + '/js/api-provider-storage.js', '/js/api-transport.js', '/js/startup-foundation.js', '/js/startup-profile.js', diff --git a/tests/test-audit.js b/tests/test-audit.js index 43d12c7b..60616102 100644 --- a/tests/test-audit.js +++ b/tests/test-audit.js @@ -57,6 +57,7 @@ assert('SW has explicit dev-host offline test opt-in', const swAuditSrc = read('service-worker.js'); assert('SW uses importScripts for version', swAuditSrc.includes("importScripts('/version.js')")); assert('SW CACHE_NAME uses semver', swAuditSrc.includes('`labcharts-v${self.APP_VERSION}`')); +assert('SW APP_SHELL includes API provider storage module', swAuditSrc.includes("'/js/api-provider-storage.js'")); assert('SW APP_SHELL includes API transport module', swAuditSrc.includes("'/js/api-transport.js'")); assert('SW APP_SHELL includes PDF import review module', swAuditSrc.includes("'/js/pdf-import-review.js'")); assert('SW APP_SHELL includes PDF import support modules', @@ -588,9 +589,10 @@ if (apoMatch) { console.log('7. Error Handling'); const apiSrc = read('js/api.js'); -assert('Venice models JSON.parse guarded', apiSrc.includes('function readStoredArray(key)')); -assert('OpenRouter models JSON.parse guarded', apiSrc.includes("try { cached = JSON.parse(localStorage.getItem('labcharts-openrouter-models')")); -assert('OpenRouter pricing JSON.parse guarded', apiSrc.includes("try { cached = JSON.parse(localStorage.getItem('labcharts-openrouter-pricing')")); +const apiProviderStorageSrc = read('js/api-provider-storage.js'); +assert('Venice models JSON.parse guarded', apiProviderStorageSrc.includes('function readStoredArray(key)')); +assert('OpenRouter models JSON.parse guarded', apiProviderStorageSrc.includes("readStoredArray('labcharts-openrouter-models')")); +assert('OpenRouter pricing JSON.parse guarded', apiProviderStorageSrc.includes("try { cached = JSON.parse(localStorage.getItem('labcharts-openrouter-pricing')")); const exportSrc = read('js/export.js'); assert('PDF report null popup guard', exportSrc.includes('if (!win)')); diff --git a/tests/test-custom-api.js b/tests/test-custom-api.js index ea14b65d..dbfb580c 100644 --- a/tests/test-custom-api.js +++ b/tests/test-custom-api.js @@ -37,27 +37,28 @@ await import('../js/provider-panels.js'); // ─── 1. api.js source inspection ─── console.log('1. api.js source inspection'); const apiSrc = read('js/api.js'); -assert('getCustomApiUrl exists', apiSrc.includes('function getCustomApiUrl()')); -assert('setCustomApiUrl exists', apiSrc.includes('function setCustomApiUrl(')); -assert('getCustomApiKey exists', apiSrc.includes('function getCustomApiKey()')); -assert('saveCustomApiKey exists', apiSrc.includes('function saveCustomApiKey(')); -assert('hasCustomApiKey exists', apiSrc.includes('function hasCustomApiKey()')); -assert('getCustomApiModel exists', apiSrc.includes('function getCustomApiModel()')); -assert('setCustomApiModel exists', apiSrc.includes('function setCustomApiModel(')); -assert('getCustomApiModelDisplay exists', apiSrc.includes('function getCustomApiModelDisplay()')); +const apiProviderStorageSrc = read('js/api-provider-storage.js'); +assert('getCustomApiUrl exists', apiProviderStorageSrc.includes('function getCustomApiUrl()')); +assert('setCustomApiUrl exists', apiProviderStorageSrc.includes('function setCustomApiUrl(')); +assert('getCustomApiKey exists', apiProviderStorageSrc.includes('function getCustomApiKey()')); +assert('saveCustomApiKey exists', apiProviderStorageSrc.includes('function saveCustomApiKey(')); +assert('hasCustomApiKey exists', apiProviderStorageSrc.includes('function hasCustomApiKey()')); +assert('getCustomApiModel exists', apiProviderStorageSrc.includes('function getCustomApiModel()')); +assert('setCustomApiModel exists', apiProviderStorageSrc.includes('function setCustomApiModel(')); +assert('getCustomApiModelDisplay exists', apiProviderStorageSrc.includes('function getCustomApiModelDisplay()')); assert('fetchCustomApiModels exists', apiSrc.includes('function fetchCustomApiModels(')); assert('validateCustomApiKey exists', apiSrc.includes('function validateCustomApiKey(')); assert('callCustomAPI exists', apiSrc.includes('function callCustomAPI(')); -assert('hasAIProvider handles custom', apiSrc.includes("provider === 'custom') return hasCustomApiKey()")); -assert('hasAIProvider custom requires URL', apiSrc.includes("hasCustomApiKey() && !!getCustomApiUrl()")); +assert('hasAIProvider handles custom', apiProviderStorageSrc.includes("provider === 'custom') return hasCustomApiKey()")); +assert('hasAIProvider custom requires URL', apiProviderStorageSrc.includes("hasCustomApiKey() && !!getCustomApiUrl()")); assert('getActiveModelId handles custom', apiSrc.includes("provider === 'custom') return getCustomApiModel()")); assert('getActiveModelDisplay handles custom', apiSrc.includes("provider === 'custom') return getCustomApiModelDisplay()")); assert('callClaudeAPI handles custom', apiSrc.includes("provider === 'custom') return callCustomAPI(")); assert('supportsWebSearch false for custom', apiSrc.includes("provider === 'custom') return false")); assert('supportsVision true for custom', apiSrc.includes("provider === 'custom') return true")); assert('callCustomAPI routes through proxy', apiSrc.includes("'Custom', opts,\n {}")); -assert('saveCustomApiKey uses encryptedSetItem', apiSrc.includes("encryptedSetItem('labcharts-custom-key'")); -assert('getCustomApiKey uses getCachedKey', apiSrc.includes("getCachedKey('labcharts-custom-key')")); +assert('saveCustomApiKey uses encryptedSetItem', apiProviderStorageSrc.includes("encryptedSetItem('labcharts-custom-key'")); +assert('getCustomApiKey uses getCachedKey', apiProviderStorageSrc.includes("getCachedKey('labcharts-custom-key')")); // ─── 2. Window function exports ─── console.log('\n2. Window function exports'); diff --git a/tests/test-openrouter.js b/tests/test-openrouter.js index f11aea24..49d1cfce 100644 --- a/tests/test-openrouter.js +++ b/tests/test-openrouter.js @@ -34,18 +34,19 @@ await import('../js/provider-panels.js'); // ─── 1. api.js source inspection ─── console.log('1. api.js source inspection'); const apiSrc = read('js/api.js'); -assert('getOpenRouterKey exists', apiSrc.includes('function getOpenRouterKey()')); -assert('saveOpenRouterKey exists', apiSrc.includes('function saveOpenRouterKey(')); -assert('hasOpenRouterKey exists', apiSrc.includes('function hasOpenRouterKey()')); -assert('getOpenRouterModel exists', apiSrc.includes('function getOpenRouterModel()')); -assert('setOpenRouterModel exists', apiSrc.includes('function setOpenRouterModel(')); -assert('getOpenRouterModelDisplay exists', apiSrc.includes('function getOpenRouterModelDisplay()')); +const apiProviderStorageSrc = read('js/api-provider-storage.js'); +assert('getOpenRouterKey exists', apiProviderStorageSrc.includes('function getOpenRouterKey()')); +assert('saveOpenRouterKey exists', apiProviderStorageSrc.includes('function saveOpenRouterKey(')); +assert('hasOpenRouterKey exists', apiProviderStorageSrc.includes('function hasOpenRouterKey()')); +assert('getOpenRouterModel exists', apiProviderStorageSrc.includes('function getOpenRouterModel()')); +assert('setOpenRouterModel exists', apiProviderStorageSrc.includes('function setOpenRouterModel(')); +assert('getOpenRouterModelDisplay exists', apiProviderStorageSrc.includes('function getOpenRouterModelDisplay()')); assert('fetchOpenRouterModels exists', apiSrc.includes('function fetchOpenRouterModels(')); assert('validateOpenRouterKey exists', apiSrc.includes('function validateOpenRouterKey(')); assert('callOpenRouterAPI exists', apiSrc.includes('function callOpenRouterAPI(')); assert('extraHeaders in helper signature', apiSrc.includes('extraHeaders = {}')); assert('extraHeaders spread in fetch headers', apiSrc.includes('...extraHeaders')); -assert('hasAIProvider handles openrouter', apiSrc.includes("provider === 'openrouter') return hasOpenRouterKey()")); +assert('hasAIProvider handles openrouter', apiProviderStorageSrc.includes("provider === 'openrouter') return hasOpenRouterKey()")); assert('callClaudeAPI handles openrouter', apiSrc.includes("provider === 'openrouter') return callOpenRouterAPI(")); assert('callOpenRouterAPI sends HTTP-Referer', apiSrc.includes("'HTTP-Referer'")); assert('callOpenRouterAPI sends X-Title', apiSrc.includes("'X-Title': 'getbased'")); @@ -53,7 +54,7 @@ assert('callOpenRouterAPI sends X-Title', apiSrc.includes("'X-Title': 'getbased' // legacy-ID it migrates FROM — getOpenRouterModel() rewrites it to the dotted // canonical 'anthropic/claude-sonnet-4.6' (verified by the section-8 default // assertion). This checks the legacy-migration source string is still present. -assert('api.js still references legacy hyphenated ID for migration', apiSrc.includes("'anthropic/claude-sonnet-4-6'")); +assert('provider storage still references legacy hyphenated ID for migration', apiProviderStorageSrc.includes("'anthropic/claude-sonnet-4-6'")); assert('OpenRouter API endpoint', apiSrc.includes('openrouter.ai/api/v1/chat/completions')); assert('OpenRouter models endpoint', apiSrc.includes('openrouter.ai/api/v1/models')); @@ -80,7 +81,7 @@ assert('Exclude filter applied in fetch', apiSrc.includes('OPENROUTER_EXCLUDE.so assert('fetchOpenRouterModels extracts pricing.prompt', apiSrc.includes('m.pricing.prompt')); assert('fetchOpenRouterModels converts to per-million', apiSrc.includes('* 1_000_000')); assert('fetchOpenRouterModels caches pricing', apiSrc.includes("'labcharts-openrouter-pricing'")); -assert('getOpenRouterPricing function exists', apiSrc.includes('function getOpenRouterPricing(')); +assert('getOpenRouterPricing function exists', apiProviderStorageSrc.includes('function getOpenRouterPricing(')); assert('window.getOpenRouterPricing is function', typeof window.getOpenRouterPricing === 'function'); const oldPricing = localStorage.getItem('labcharts-openrouter-pricing'); diff --git a/tests/test-venice-e2ee.js b/tests/test-venice-e2ee.js index 7d3ad9e1..ce522740 100644 --- a/tests/test-venice-e2ee.js +++ b/tests/test-venice-e2ee.js @@ -28,12 +28,13 @@ const cryptoMod = await import('../js/crypto.js'); // 1. Source: api.js has isE2EEModel and E2EE branch const apiSrc = read('js/api.js'); -assert('isE2EEModel exported in api.js', apiSrc.includes('export function isE2EEModel(')); -assert('e2ee prefix detection', apiSrc.includes("modelId.startsWith('e2ee-')")); +const apiProviderStorageSrc = read('js/api-provider-storage.js'); +assert('isE2EEModel exported through api.js', apiSrc.includes('isE2EEModel,')); +assert('e2ee prefix detection', apiProviderStorageSrc.includes("modelId.startsWith('e2ee-')")); assert('callVeniceAPI has E2EE import', apiSrc.includes("import('../vendor/venice-e2ee.js')")); -assert('supportsWebSearch excludes E2EE', apiSrc.includes('isE2EEModel(getVeniceModel())')); -assert('supportsVision excludes E2EE', apiSrc.includes('isE2EEModel(getVeniceModel())') && apiSrc.includes('return false')); -assert('fetchVeniceModels preserves e2ee- prefix', apiSrc.includes("id.startsWith('e2ee-')")); +assert('supportsWebSearch excludes E2EE', apiSrc.includes('isVeniceE2EEActive()')); +assert('supportsVision excludes E2EE', apiSrc.includes('isVeniceE2EEActive()') && apiSrc.includes('return false')); +assert('fetchVeniceModels preserves e2ee- prefix', apiProviderStorageSrc.includes("id.startsWith('e2ee-')")); assert('Venice E2EE supports forced non-stream retry path', /forceNonStream/.test(apiSrc) && /requestTimeoutMs/.test(apiSrc) diff --git a/version.js b/version.js index 643367f7..56270ccd 100644 --- a/version.js +++ b/version.js @@ -2,4 +2,4 @@ // Classic script (not ES module) so it works in both browser and service worker. // Browser: