Add Anthropic prompt caching, pluggable storage, and CORS proxy support

Storage Architecture:
- New pluggable storage system with backends (LocalStorage, ChromeStorage, IndexedDB)
- SettingsRepository for app settings (proxy config, etc.)
- ProviderKeysRepository for API key management
- AppStorage with global accessors (getAppStorage, setAppStorage, initAppStorage)

Transport Refactoring:
- Renamed DirectTransport → ProviderTransport (calls LLM providers with optional CORS proxy)
- Renamed ProxyTransport → AppTransport (uses app server with user auth)
- Updated TransportMode: "direct" → "provider", "proxy" → "app"

CORS Proxy Integration:
- ProviderTransport checks proxy.enabled/proxy.url from storage
- When enabled, modifies model baseUrl to route through proxy: {proxyUrl}/?url={originalBaseUrl}
- ProviderKeyInput test function also honors proxy settings
- Settings dialog with Proxy tab (Switch toggle, URL input, explanatory description)

Anthropic Prompt Caching:
- System prompt cached with cache_control markers (both OAuth and regular API keys)
- Last user message cached to cache conversation history
- Saves 90% on input tokens for cached content (10x cost reduction)

Settings Dialog Improvements:
- Configurable tab system with SettingsTab base class
- ApiKeysTab and ProxyTab as custom elements
- Switch toggle for proxy enable (instead of Checkbox)
- Explanatory paragraphs for each tab
- ApiKeyPromptDialog reuses ProviderKeyInput component

Removed:
- Deprecated ApiKeysDialog (replaced by ProviderKeyInput in SettingsDialog)
- Old storage-adapter and key-store (replaced by new storage architecture)
This commit is contained in:
Mario Zechner 2025-10-05 23:00:36 +02:00
parent 66f092c0c6
commit 0496651308
31 changed files with 1141 additions and 488 deletions

View file

@ -0,0 +1,53 @@
import { LocalStorageBackend } from "./backends/local-storage-backend.js";
import { ProviderKeysRepository } from "./repositories/provider-keys-repository.js";
import { SettingsRepository } from "./repositories/settings-repository.js";
import type { AppStorageConfig } from "./types.js";
/**
* High-level storage API aggregating all repositories.
* Apps configure backends and use repositories through this interface.
*/
export class AppStorage {
readonly settings: SettingsRepository;
readonly providerKeys: ProviderKeysRepository;
constructor(config: AppStorageConfig = {}) {
// Use LocalStorage with prefixes as defaults
const settingsBackend = config.settings ?? new LocalStorageBackend("settings");
const providerKeysBackend = config.providerKeys ?? new LocalStorageBackend("providerKeys");
this.settings = new SettingsRepository(settingsBackend);
this.providerKeys = new ProviderKeysRepository(providerKeysBackend);
}
}
// Global instance management
let globalAppStorage: AppStorage | null = null;
/**
* Get the global AppStorage instance.
* Throws if not initialized.
*/
export function getAppStorage(): AppStorage {
if (!globalAppStorage) {
throw new Error("AppStorage not initialized. Call setAppStorage() first.");
}
return globalAppStorage;
}
/**
* Set the global AppStorage instance.
*/
export function setAppStorage(storage: AppStorage): void {
globalAppStorage = storage;
}
/**
* Initialize AppStorage with default configuration if not already set.
*/
export function initAppStorage(config: AppStorageConfig = {}): AppStorage {
if (!globalAppStorage) {
globalAppStorage = new AppStorage(config);
}
return globalAppStorage;
}

View file

@ -0,0 +1,82 @@
import type { StorageBackend } from "../types.js";
// Chrome extension API types (optional)
declare const chrome: any;
/**
* Storage backend using chrome.storage.local.
* Good for: Browser extensions, syncing across devices (with chrome.storage.sync).
* Limits: ~10MB for local, ~100KB for sync, async API.
*/
export class ChromeStorageBackend implements StorageBackend {
constructor(private prefix: string = "") {}
private getKey(key: string): string {
return this.prefix ? `${this.prefix}:${key}` : key;
}
async get<T = unknown>(key: string): Promise<T | null> {
if (!chrome?.storage?.local) {
throw new Error("chrome.storage.local is not available");
}
const fullKey = this.getKey(key);
const result = await chrome.storage.local.get([fullKey]);
return result[fullKey] !== undefined ? (result[fullKey] as T) : null;
}
async set<T = unknown>(key: string, value: T): Promise<void> {
if (!chrome?.storage?.local) {
throw new Error("chrome.storage.local is not available");
}
const fullKey = this.getKey(key);
await chrome.storage.local.set({ [fullKey]: value });
}
async delete(key: string): Promise<void> {
if (!chrome?.storage?.local) {
throw new Error("chrome.storage.local is not available");
}
const fullKey = this.getKey(key);
await chrome.storage.local.remove(fullKey);
}
async keys(): Promise<string[]> {
if (!chrome?.storage?.local) {
throw new Error("chrome.storage.local is not available");
}
const allData = await chrome.storage.local.get(null);
const allKeys = Object.keys(allData);
const prefixWithColon = this.prefix ? `${this.prefix}:` : "";
if (this.prefix) {
return allKeys
.filter((key) => key.startsWith(prefixWithColon))
.map((key) => key.substring(prefixWithColon.length));
}
return allKeys;
}
async clear(): Promise<void> {
if (!chrome?.storage?.local) {
throw new Error("chrome.storage.local is not available");
}
if (this.prefix) {
const keysToRemove = await this.keys();
const fullKeys = keysToRemove.map((key) => this.getKey(key));
await chrome.storage.local.remove(fullKeys);
} else {
await chrome.storage.local.clear();
}
}
async has(key: string): Promise<boolean> {
const value = await this.get(key);
return value !== null;
}
}

View file

@ -0,0 +1,107 @@
import type { StorageBackend } from "../types.js";
/**
* Storage backend using IndexedDB.
* Good for: Large data, binary blobs, complex queries.
* Limits: ~50MB-unlimited (browser dependent), async API, more complex.
*/
export class IndexedDBBackend implements StorageBackend {
private dbPromise: Promise<IDBDatabase> | null = null;
constructor(
private dbName: string,
private storeName: string = "keyvalue",
) {}
private async getDB(): Promise<IDBDatabase> {
if (this.dbPromise) {
return this.dbPromise;
}
this.dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
};
});
return this.dbPromise;
}
async get<T = unknown>(key: string): Promise<T | null> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(this.storeName, "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const value = request.result;
resolve(value !== undefined ? (value as T) : null);
};
});
}
async set<T = unknown>(key: string, value: T): Promise<void> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(this.storeName, "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.put(value, key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async delete(key: string): Promise<void> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(this.storeName, "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.delete(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async keys(): Promise<string[]> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(this.storeName, "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.getAllKeys();
request.onerror = () => reject(request.error);
request.onsuccess = () => {
resolve(request.result.map((key) => String(key)));
};
});
}
async clear(): Promise<void> {
const db = await this.getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(this.storeName, "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async has(key: string): Promise<boolean> {
const value = await this.get(key);
return value !== null;
}
}

View file

@ -0,0 +1,74 @@
import type { StorageBackend } from "../types.js";
/**
* Storage backend using browser localStorage.
* Good for: Simple settings, small data.
* Limits: ~5MB, synchronous API (wrapped in promises), string-only (JSON serialization).
*/
export class LocalStorageBackend implements StorageBackend {
constructor(private prefix: string = "") {}
private getKey(key: string): string {
return this.prefix ? `${this.prefix}:${key}` : key;
}
async get<T = unknown>(key: string): Promise<T | null> {
const fullKey = this.getKey(key);
const value = localStorage.getItem(fullKey);
if (value === null) return null;
try {
return JSON.parse(value) as T;
} catch {
// If JSON parse fails, return as string
return value as T;
}
}
async set<T = unknown>(key: string, value: T): Promise<void> {
const fullKey = this.getKey(key);
const serialized = JSON.stringify(value);
localStorage.setItem(fullKey, serialized);
}
async delete(key: string): Promise<void> {
const fullKey = this.getKey(key);
localStorage.removeItem(fullKey);
}
async keys(): Promise<string[]> {
const allKeys: string[] = [];
const prefixWithColon = this.prefix ? `${this.prefix}:` : "";
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
if (this.prefix) {
if (key.startsWith(prefixWithColon)) {
allKeys.push(key.substring(prefixWithColon.length));
}
} else {
allKeys.push(key);
}
}
}
return allKeys;
}
async clear(): Promise<void> {
if (this.prefix) {
const keysToRemove = await this.keys();
for (const key of keysToRemove) {
await this.delete(key);
}
} else {
localStorage.clear();
}
}
async has(key: string): Promise<boolean> {
const fullKey = this.getKey(key);
return localStorage.getItem(fullKey) !== null;
}
}

View file

@ -0,0 +1,55 @@
import type { StorageBackend } from "../types.js";
/**
* Repository for managing provider API keys.
* Provides domain-specific methods for key management.
*/
export class ProviderKeysRepository {
constructor(private backend: StorageBackend) {}
/**
* Get the API key for a provider.
*/
async getKey(provider: string): Promise<string | null> {
return this.backend.get<string>(`key:${provider}`);
}
/**
* Set the API key for a provider.
*/
async setKey(provider: string, key: string): Promise<void> {
await this.backend.set(`key:${provider}`, key);
}
/**
* Remove the API key for a provider.
*/
async removeKey(provider: string): Promise<void> {
await this.backend.delete(`key:${provider}`);
}
/**
* Get all providers that have keys stored.
*/
async getProviders(): Promise<string[]> {
const allKeys = await this.backend.keys();
return allKeys.filter((key) => key.startsWith("key:")).map((key) => key.substring(4));
}
/**
* Check if a provider has a key stored.
*/
async hasKey(provider: string): Promise<boolean> {
return this.backend.has(`key:${provider}`);
}
/**
* Clear all stored API keys.
*/
async clearAll(): Promise<void> {
const providers = await this.getProviders();
for (const provider of providers) {
await this.removeKey(provider);
}
}
}

View file

@ -0,0 +1,51 @@
import type { StorageBackend } from "../types.js";
/**
* Repository for simple application settings (proxy, theme, etc.).
* Uses a single backend for all settings.
*/
export class SettingsRepository {
constructor(private backend: StorageBackend) {}
/**
* Get a setting value by key.
*/
async get<T = unknown>(key: string): Promise<T | null> {
return this.backend.get<T>(key);
}
/**
* Set a setting value.
*/
async set<T = unknown>(key: string, value: T): Promise<void> {
await this.backend.set(key, value);
}
/**
* Delete a setting.
*/
async delete(key: string): Promise<void> {
await this.backend.delete(key);
}
/**
* Get all setting keys.
*/
async keys(): Promise<string[]> {
return this.backend.keys();
}
/**
* Check if a setting exists.
*/
async has(key: string): Promise<boolean> {
return this.backend.has(key);
}
/**
* Clear all settings.
*/
async clear(): Promise<void> {
await this.backend.clear();
}
}

View file

@ -0,0 +1,48 @@
/**
* Base interface for all storage backends.
* Provides a simple key-value storage abstraction that can be implemented
* by localStorage, IndexedDB, chrome.storage, or remote APIs.
*/
export interface StorageBackend {
/**
* Get a value by key. Returns null if key doesn't exist.
*/
get<T = unknown>(key: string): Promise<T | null>;
/**
* Set a value for a key.
*/
set<T = unknown>(key: string, value: T): Promise<void>;
/**
* Delete a key.
*/
delete(key: string): Promise<void>;
/**
* Get all keys.
*/
keys(): Promise<string[]>;
/**
* Clear all data.
*/
clear(): Promise<void>;
/**
* Check if a key exists.
*/
has(key: string): Promise<boolean>;
}
/**
* Options for configuring AppStorage.
*/
export interface AppStorageConfig {
/** Backend for simple settings (proxy, theme, etc.) */
settings?: StorageBackend;
/** Backend for provider API keys */
providerKeys?: StorageBackend;
/** Backend for sessions (chat history, attachments) */
sessions?: StorageBackend;
}