From be5e26387464abe9cfb1097fadd41fa4e0369740 Mon Sep 17 00:00:00 2001 From: dorddis Date: Fri, 7 Aug 2026 18:09:00 +0530 Subject: [PATCH 1/6] feat(ai): add the AI SDK and a provider catalog Installs ai@7 plus the OpenAI, Google, Anthropic and openai-compatible provider packages, and adds utils/aiCatalog.ts as the single source of truth for providers, curated models and host-permission origins. wxt.config.ts reads its origins from here so adding a provider cannot silently miss the manifest. Model ids checked against each vendor's deprecation page on 2026-08-07. Groq's llama-3.1-8b-instant and llama-3.3-70b-versatile are deliberately absent - both shut down 2026-08-16. --- package-lock.json | 180 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 5 ++ utils/aiCatalog.ts | 165 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 utils/aiCatalog.ts diff --git a/package-lock.json b/package-lock.json index 7a125ae..c86cddb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,10 @@ "version": "1.6.0", "hasInstallScript": true, "dependencies": { + "@ai-sdk/anthropic": "^4.0.34", + "@ai-sdk/google": "^4.0.37", + "@ai-sdk/openai": "^4.0.34", + "@ai-sdk/openai-compatible": "^3.0.25", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/inter": "^5.2.5", @@ -17,6 +21,7 @@ "@mui/x-date-pickers": "^9.5.0", "@sentry/browser": "^10.58.0", "@sentry/vite-plugin": "^5.3.0", + "ai": "^7.0.56", "axios": "^1.18.0", "date-fns": "^4.1.0", "lodash": "^4.17.21", @@ -54,6 +59,118 @@ "many-keys-map": "^3.0.0" } }, + "node_modules/@ai-sdk/anthropic": { + "version": "4.0.34", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-4.0.34.tgz", + "integrity": "sha512-k3cj4AQ2calM6idUodma1cYlQuEeRxBjzyc2IBO+5kx+yAI8x0fku/Fq+DBKdOgSwNoBbv7lO1CsmmEkBBFbmw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.6", + "@ai-sdk/provider-utils": "5.0.23" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.44", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.44.tgz", + "integrity": "sha512-dQ/AHH1do1iEpWEXdAge37FtpXV17fsj7NkL9w+xuzVgvmI7+dQFeDerul+F2I5czmxe61Lq+TnUREjU92UXYg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.6", + "@ai-sdk/provider-utils": "5.0.23", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "4.0.37", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-4.0.37.tgz", + "integrity": "sha512-wtf81hl7lAy/cMREfwxeMRcfGl7WrSiZI5nTEDwHqH/dEWEM8lPQn13s9S97nbQn1DHNeyduljH4huCeILP/bw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.6", + "@ai-sdk/provider-utils": "5.0.23" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "4.0.34", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-4.0.34.tgz", + "integrity": "sha512-DPGehPPwuTmnWxCDxeWZJgGLn7Lh+QfiiN20tvqnEQMUwCmuadZ3FLlRtTkTpeEa2l+Lsa/sWVkBwShWGjGmKA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.6", + "@ai-sdk/provider-utils": "5.0.23" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "3.0.25", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-3.0.25.tgz", + "integrity": "sha512-6NMHwYsjF8QQ8sKEnC6tt9w1z87Z0XwKgOGe1ZB48SwH+HvFEHsn8nCEvKML3MUwRtVu0mxRG08FTjXmew/ELA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.6", + "@ai-sdk/provider-utils": "5.0.23" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.6.tgz", + "integrity": "sha512-YYXjvs8F3q/BdEn9tBDoDuQACotfR7c5foGw/ADsM6iAVC1JabqEjQSkwHv/Kg2vNIr1c2AVHcQb+JYsYk76Qw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.23", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.23.tgz", + "integrity": "sha512-mFvAAszenK8Xny3v+4Vntke5IcUkyzjss3gTBOxVWNrSIWmt/FhQ5gCgoJXW7IK11cklRaicMPwOXTnnBtaqFQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.6", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@aklinker1/rollup-plugin-visualizer": { "version": "5.12.0", "resolved": "https://registry.npmjs.org/@aklinker1/rollup-plugin-visualizer/-/rollup-plugin-visualizer-5.12.0.tgz", @@ -2153,6 +2270,12 @@ "node": ">= 18" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2277,6 +2400,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -2334,6 +2466,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, "node_modules/@wxt-dev/browser": { "version": "0.1.43", "resolved": "https://registry.npmjs.org/@wxt-dev/browser/-/browser-0.1.43.tgz", @@ -2412,6 +2550,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ai": { + "version": "7.0.56", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.56.tgz", + "integrity": "sha512-Lp7q9tZFl/TGOpBBNgt2wZ85SoCvJJVlHQksMkqrgiFUnIGOw+L2FWJ0gXuNEngLQTp9+t32k8QuIB7Jc5uKRA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.44", + "@ai-sdk/provider": "4.0.6", + "@ai-sdk/provider-utils": "5.0.23" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -3863,6 +4018,15 @@ "dev": true, "license": "MIT" }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -4703,6 +4867,12 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -7221,6 +7391,15 @@ "dev": true, "license": "ISC" }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", @@ -8073,7 +8252,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index fdde026..ea1ad98 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,10 @@ "format": "prettier --write ." }, "dependencies": { + "@ai-sdk/anthropic": "^4.0.34", + "@ai-sdk/google": "^4.0.37", + "@ai-sdk/openai": "^4.0.34", + "@ai-sdk/openai-compatible": "^3.0.25", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/inter": "^5.2.5", @@ -26,6 +30,7 @@ "@mui/x-date-pickers": "^9.5.0", "@sentry/browser": "^10.58.0", "@sentry/vite-plugin": "^5.3.0", + "ai": "^7.0.56", "axios": "^1.18.0", "date-fns": "^4.1.0", "lodash": "^4.17.21", diff --git a/utils/aiCatalog.ts b/utils/aiCatalog.ts new file mode 100644 index 0000000..70bcf2f --- /dev/null +++ b/utils/aiCatalog.ts @@ -0,0 +1,165 @@ +export type ConnectionMode = 'direct' | 'gateway' + +export type ProviderId = + | 'openai' + | 'google' + | 'anthropic' + | 'groq' + | 'deepseek' + | 'openrouter' + +export type ModelPreset = { + id: string + label: string +} + +type ProviderBase = { + id: ProviderId + label: string + hostPermission: string + apiKeysUrl: string + apiKeysLabel: string + apiKeyPlaceholder: string + defaultModel: string + models: ModelPreset[] +} + +export type ProviderDefinition = ProviderBase & + ( + | { kind: 'openai' | 'google' | 'anthropic' } + | { kind: 'openai-compatible'; baseUrl: string } + ) + +/** + * Model ids were checked against each vendor's deprecation page on 2026-08-07. + * Groq's `llama-3.1-8b-instant` and `llama-3.3-70b-versatile` are omitted + * deliberately — both shut down 2026-08-16. + */ +export const providers: ProviderDefinition[] = [ + { + id: 'openai', + kind: 'openai', + label: 'OpenAI', + // Narrower than the `/*` pattern used below because it must match the + // already-granted manifest entry; broadening it would re-prompt every + // installed user. + hostPermission: 'https://api.openai.com/', + apiKeysUrl: 'https://platform.openai.com/api-keys', + apiKeysLabel: 'platform.openai.com/api-keys', + apiKeyPlaceholder: 'sk-...', + defaultModel: 'gpt-5.6-luna', + models: [ + { id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna (fastest, cheapest)' }, + { id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra (balanced)' }, + { id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol (most capable)' }, + { id: 'gpt-4o-mini', label: 'GPT-4o mini (legacy)' }, + ], + }, + { + id: 'google', + kind: 'google', + label: 'Google Gemini', + hostPermission: 'https://generativelanguage.googleapis.com/*', + apiKeysUrl: 'https://aistudio.google.com/apikey', + apiKeysLabel: 'aistudio.google.com/apikey', + apiKeyPlaceholder: 'AIza...', + defaultModel: 'gemini-3.6-flash', + models: [ + { id: 'gemini-3.6-flash', label: 'Gemini 3.6 Flash (latest)' }, + { id: 'gemini-3.5-flash', label: 'Gemini 3.5 Flash' }, + { + id: 'gemini-3.5-flash-lite', + label: 'Gemini 3.5 Flash Lite (cheapest)', + }, + { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + ], + }, + { + id: 'anthropic', + kind: 'anthropic', + label: 'Anthropic Claude', + hostPermission: 'https://api.anthropic.com/*', + apiKeysUrl: 'https://console.anthropic.com/settings/keys', + apiKeysLabel: 'console.anthropic.com/settings/keys', + apiKeyPlaceholder: 'sk-ant-...', + defaultModel: 'claude-sonnet-4-6', + models: [ + { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, + { id: 'claude-sonnet-5', label: 'Claude Sonnet 5' }, + { id: 'claude-opus-4-8', label: 'Claude Opus 4.8 (most capable)' }, + ], + }, + { + id: 'groq', + kind: 'openai-compatible', + baseUrl: 'https://api.groq.com/openai/v1', + label: 'Groq', + hostPermission: 'https://api.groq.com/*', + apiKeysUrl: 'https://console.groq.com/keys', + apiKeysLabel: 'console.groq.com/keys', + apiKeyPlaceholder: 'gsk_...', + defaultModel: 'openai/gpt-oss-20b', + models: [ + { id: 'openai/gpt-oss-20b', label: 'GPT-OSS 20B (fastest)' }, + { id: 'openai/gpt-oss-120b', label: 'GPT-OSS 120B' }, + ], + }, + { + id: 'deepseek', + kind: 'openai-compatible', + baseUrl: 'https://api.deepseek.com/v1', + label: 'DeepSeek', + hostPermission: 'https://api.deepseek.com/*', + apiKeysUrl: 'https://platform.deepseek.com/api_keys', + apiKeysLabel: 'platform.deepseek.com/api_keys', + apiKeyPlaceholder: 'sk-...', + defaultModel: 'deepseek-v4-flash', + models: [ + { id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash' }, + { id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro' }, + ], + }, + { + id: 'openrouter', + kind: 'openai-compatible', + baseUrl: 'https://openrouter.ai/api/v1', + label: 'OpenRouter', + hostPermission: 'https://openrouter.ai/*', + apiKeysUrl: 'https://openrouter.ai/keys', + apiKeysLabel: 'openrouter.ai/keys', + apiKeyPlaceholder: 'sk-or-v1-...', + defaultModel: 'openai/gpt-5.6-luna', + models: [ + { id: 'openai/gpt-5.6-luna', label: 'GPT-5.6 Luna' }, + { id: 'anthropic/claude-sonnet-4.6', label: 'Claude Sonnet 4.6' }, + { id: 'google/gemini-3.5-flash', label: 'Gemini 3.5 Flash' }, + { id: 'deepseek/deepseek-v4-flash', label: 'DeepSeek V4 Flash' }, + ], + }, +] + +export const gateway = { + hostPermission: 'https://ai-gateway.vercel.sh/*', + docsUrl: 'https://vercel.com/docs/ai-gateway/authentication', + docsLabel: 'vercel.com/docs/ai-gateway/authentication', + apiKeyPlaceholder: 'vck_...', +} + +export const defaultProvider = providers[0] + +export const findProvider = (id: ProviderId): ProviderDefinition | undefined => + providers.find((provider) => provider.id === id) + +/** + * Origins already covered by the manifest's static `host_permissions`. Kept + * static so installs that predate multi-provider support keep working without + * granting anything. + */ +export const staticHostPermissions = [defaultProvider.hostPermission] + +export const optionalHostPermissions = [ + ...providers + .map((provider) => provider.hostPermission) + .filter((origin) => !staticHostPermissions.includes(origin)), + gateway.hostPermission, +] From 15f72a98d726af9013d25b17a4e1c5d556b1eb96 Mon Sep 17 00:00:00 2001 From: dorddis Date: Fri, 7 Aug 2026 18:09:08 +0530 Subject: [PATCH 2/6] feat(ai): store the AI connection as one record and migrate the legacy key Replaces utils/openAiApiKey.ts with utils/aiSettings.ts, which persists mode, provider, model and keys atomically at sync:__AI_SETTINGS. An absent record migrates sync:__OPENAI_API_KEY into it, pinning gpt-4o-mini so existing installs keep the model they were already using, then deletes the legacy namespace - ordered after the write, so an interrupted migration can never leave the key in neither place. A record that exists but fails validation throws instead of falling back to the legacy key: silently reverting a Gemini user to OpenAI would send their prompt to a provider they did not choose. --- utils/aiSettings.ts | 125 ++++++++++++++++++++++++++++++++++++++++++ utils/openAiApiKey.ts | 16 ------ 2 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 utils/aiSettings.ts delete mode 100644 utils/openAiApiKey.ts diff --git a/utils/aiSettings.ts b/utils/aiSettings.ts new file mode 100644 index 0000000..e453684 --- /dev/null +++ b/utils/aiSettings.ts @@ -0,0 +1,125 @@ +import { storage } from '#imports' +import { + ConnectionMode, + ProviderId, + defaultProvider, + findProvider, + gateway, + providers, +} from './aiCatalog' + +const namespace = 'sync:__AI_SETTINGS' +const legacyNamespace = 'sync:__OPENAI_API_KEY' + +/** The model the OpenAI-only build hardcoded, kept for migrated installs. */ +const legacyModel = 'gpt-4o-mini' + +export type AiSettings = { + mode: ConnectionMode + provider: ProviderId + model: string + apiKey: string + gatewayModel: string + gatewayApiKey: string +} + +/** The stored record exists but does not match the current shape. */ +export class InvalidAiSettingsError extends Error { + constructor() { + super('Stored AI settings are not readable') + this.name = 'InvalidAiSettingsError' + } +} + +const getDefaultSettings = (): AiSettings => ({ + mode: 'direct', + provider: defaultProvider.id, + model: defaultProvider.defaultModel, + apiKey: '', + gatewayModel: '', + gatewayApiKey: '', +}) + +const isAiSettings = (value: unknown): value is AiSettings => { + if (!value || typeof value !== 'object') return false + + const candidate = value as Record + + return ( + (candidate.mode === 'direct' || candidate.mode === 'gateway') && + providers.some((provider) => provider.id === candidate.provider) && + typeof candidate.model === 'string' && + typeof candidate.apiKey === 'string' && + typeof candidate.gatewayModel === 'string' && + typeof candidate.gatewayApiKey === 'string' + ) +} + +const migrateLegacyKey = async (): Promise => { + const legacyApiKey = ( + (await storage.getItem(legacyNamespace)) ?? '' + ).trim() + + if (!legacyApiKey) { + return getDefaultSettings() + } + + const migrated: AiSettings = { + ...getDefaultSettings(), + provider: 'openai', + model: legacyModel, + apiKey: legacyApiKey, + } + + await storage.setItem(namespace, migrated) + // Only after the new record is durable, so an interrupted migration leaves + // the key readable from one namespace or the other, never neither. + await storage.removeItem(legacyNamespace) + + return migrated +} + +/** @throws {InvalidAiSettingsError} when a stored record fails validation. */ +const get = async (): Promise => { + const stored = await storage.getItem(namespace) + + if (stored == null) { + return migrateLegacyKey() + } + + if (!isAiSettings(stored)) { + throw new InvalidAiSettingsError() + } + + return stored +} + +const save = async (value: AiSettings) => { + await storage.setItem(namespace, value) + return value +} + +const addEventListener = ( + callback: (newValue: AiSettings | null, oldValue: AiSettings | null) => void +) => storage.watch(namespace, callback) + +const getApiKey = (settings: AiSettings) => + settings.mode === 'gateway' ? settings.gatewayApiKey : settings.apiKey + +const getModel = (settings: AiSettings) => + settings.mode === 'gateway' ? settings.gatewayModel : settings.model + +const getHostPermission = (settings: AiSettings) => + settings.mode === 'gateway' + ? gateway.hostPermission + : (findProvider(settings.provider) ?? defaultProvider).hostPermission + +export default { + get, + save, + addEventListener, + getApiKey, + getModel, + getHostPermission, + getDefaultSettings, +} diff --git a/utils/openAiApiKey.ts b/utils/openAiApiKey.ts deleted file mode 100644 index 562e3de..0000000 --- a/utils/openAiApiKey.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { storage } from '#imports' - -const namespace = 'sync:__OPENAI_API_KEY' - -const get = () => storage.getItem(namespace, { fallback: '' }) - -const save = async (value: string) => { - await storage.setItem(namespace, value) - return value -} - -const addEventListener = ( - callback: (newValue: string | null, oldValue: string | null) => void -) => storage.watch(namespace, callback) - -export default { get, save, addEventListener } From ef3b9f0da83617bc3756a662d84f59e09d806e79 Mon Sep 17 00:00:00 2001 From: dorddis Date: Fri, 7 Aug 2026 18:09:17 +0530 Subject: [PATCH 3/6] feat(ai): stream cover letters through the AI SDK with typed failures api/ai.ts replaces the hand-rolled OpenAI SSE reader with streamText over the selected provider or the AI Gateway. It consumes fullStream, not textStream: streamText reports a mid-stream failure as an in-band error part and then closes the stream normally, so textStream would have reported success with an empty result. An empty generation is treated as a failure for the same reason. classifyError maps provider failures to codes the UI can act on. 402 means out of credit; 429 is disambiguated on the response body because OpenAI never returns 402 and signals an exhausted balance with insufficient_quota inside a 429. Sentry receives the original error - so utils/sentry.ts's 'Failed to fetch' ignore rule still matches - plus status, url and a truncated body with credential-shaped runs redacted. User-fixable failures are not reported at all. The background worker re-checks permissions.contains before every generation rather than trusting the settings page; the user can revoke an origin at any time from the browser's own controls. --- api/ai.ts | 155 ++++++++++++++++++++++++++++++++ api/openai.ts | 79 ---------------- entrypoints/background/index.ts | 51 ++++++++--- utils/runtime.ts | 17 +++- 4 files changed, 211 insertions(+), 91 deletions(-) create mode 100644 api/ai.ts delete mode 100644 api/openai.ts diff --git a/api/ai.ts b/api/ai.ts new file mode 100644 index 0000000..36fcb21 --- /dev/null +++ b/api/ai.ts @@ -0,0 +1,155 @@ +import { AiSettings } from '@/utils/aiSettings' +import { findProvider, ModelPreset } from '@/utils/aiCatalog' +import { AiErrorCode } from '@/utils/runtime' +import { createAnthropic } from '@ai-sdk/anthropic' +import { createGoogle } from '@ai-sdk/google' +import { createOpenAI } from '@ai-sdk/openai' +import { createOpenAICompatible } from '@ai-sdk/openai-compatible' +import { APICallError, createGateway, LanguageModel, streamText } from 'ai' + +const DIAGNOSTIC_BODY_LIMIT = 500 + +export type AiFailure = { + code: AiErrorCode + /** False for failures the user can fix themselves; those are not bugs. */ + reportable: boolean + diagnostics: { url?: string; status?: number; body?: string } +} + +const createModel = (settings: AiSettings): LanguageModel => { + if (settings.mode === 'gateway') { + return createGateway({ apiKey: settings.gatewayApiKey })( + settings.gatewayModel + ) + } + + const provider = findProvider(settings.provider) + + if (!provider) { + throw new Error(`Unknown AI provider: ${settings.provider}`) + } + + const apiKey = settings.apiKey + + switch (provider.kind) { + case 'openai': + return createOpenAI({ apiKey })(settings.model) + case 'google': + return createGoogle({ apiKey })(settings.model) + case 'anthropic': + // The Claude API rejects browser-originated requests unless they opt in. + return createAnthropic({ + apiKey, + headers: { 'anthropic-dangerous-direct-browser-access': 'true' }, + })(settings.model) + case 'openai-compatible': + return createOpenAICompatible({ + apiKey, + name: provider.id, + baseURL: provider.baseUrl, + })(settings.model) + } +} + +/** + * Streams a cover letter from the configured provider, delivering each token + * through `onChunk` as it arrives. + */ +const generateCoverLetter = async (props: { + settings: AiSettings + prompt: string + signal?: AbortSignal + onChunk: (chunk: string) => void +}): Promise => { + const result = streamText({ + model: createModel(props.settings), + prompt: props.prompt, + abortSignal: props.signal, + }) + + let received = false + + for await (const part of result.fullStream) { + if (part.type === 'text-delta') { + received = true + props.onChunk(part.text) + continue + } + + // `streamText` reports a failure as an in-band part and then closes the + // stream normally, so consuming `textStream` instead would report success + // with an empty result. + if (part.type === 'error') { + throw part.error + } + } + + if (!received) { + throw new Error('The provider returned an empty cover letter') + } +} + +const fetchGatewayModels = async (apiKey: string): Promise => { + const { models } = await createGateway({ apiKey }).getAvailableModels() + + return models + .filter( + (model) => model.modelType == null || model.modelType === 'language' + ) + .map((model) => ({ id: model.id, label: model.name || model.id })) + .sort((a, b) => a.label.localeCompare(b.label)) +} + +/** Provider error bodies quote the rejected credential back at you. */ +const redactSecrets = (body: string) => + body.replace(/[A-Za-z0-9_-]{24,}/g, '[redacted]') + +const matchesQuota = (body: string) => + /insufficient_quota|insufficient credit|exceeded your current quota|billing|payment required/i.test( + body + ) + +const matchesUnknownModel = (body: string) => + /model.{0,30}(not found|does not exist|is not available|invalid)/i.test(body) + +const classifyError = (error: unknown): AiFailure => { + if (!APICallError.isInstance(error)) { + return { code: 'GENERATION_FAILED', reportable: true, diagnostics: {} } + } + + const body = (error.responseBody ?? '').slice(0, DIAGNOSTIC_BODY_LIMIT) + const diagnostics = { + url: error.url, + status: error.statusCode, + body: redactSecrets(body), + } + + const userFixable = (code: AiErrorCode): AiFailure => ({ + code, + reportable: false, + diagnostics, + }) + + switch (error.statusCode) { + case 401: + case 403: + return userFixable('INVALID_API_KEY') + case 402: + return userFixable('INSUFFICIENT_QUOTA') + case 404: + return userFixable('MODEL_UNAVAILABLE') + case 429: + // OpenAI never returns 402; it signals an exhausted balance with a 429 + // whose body carries `insufficient_quota`. + return userFixable( + matchesQuota(body) ? 'INSUFFICIENT_QUOTA' : 'RATE_LIMITED' + ) + case 400: + if (matchesUnknownModel(body)) return userFixable('MODEL_UNAVAILABLE') + break + } + + return { code: 'GENERATION_FAILED', reportable: true, diagnostics } +} + +export default { generateCoverLetter, fetchGatewayModels, classifyError } diff --git a/api/openai.ts b/api/openai.ts deleted file mode 100644 index 2c82b11..0000000 --- a/api/openai.ts +++ /dev/null @@ -1,79 +0,0 @@ -const ENDPOINT = 'https://api.openai.com/v1/chat/completions' -const MODEL = 'gpt-4o-mini' - -/** - * Streams a cover letter from the OpenAI Chat Completions API using the user's - * own API key. Each token is delivered through `onChunk` as it arrives. - * - * Uses the native `fetch` rather than the project's Axios + logger convention - * because consuming an SSE stream requires `ReadableStream` - * (`response.body.getReader()`), which Axios does not expose. Failures surface - * to the caller and are reported via Sentry in the background worker. - */ -const generateCoverLetter = async (props: { - apiKey: string - prompt: string - signal?: AbortSignal - onChunk: (chunk: string) => void -}): Promise => { - const response = await fetch(ENDPOINT, { - method: 'POST', - signal: props.signal, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${props.apiKey}`, - }, - body: JSON.stringify({ - model: MODEL, - stream: true, - messages: [{ role: 'user', content: props.prompt }], - }), - }) - - if (!response.ok || !response.body) { - const details = await response.text().catch(() => '') - throw new Error(`OpenAI request failed (${response.status}): ${details}`) - } - - const reader = response.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - - while (true) { - const { done, value } = await reader.read() - if (done) { - // Flush any multi-byte UTF-8 sequence still held by the decoder. - buffer += decoder.decode() - break - } - - buffer += decoder.decode(value, { stream: true }) - - // Server-sent events are newline-delimited; keep the trailing partial line. - const lines = buffer.split('\n') - buffer = lines.pop() ?? '' - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed.startsWith('data:')) continue - - const data = trimmed.slice('data:'.length).trim() - if (data === '[DONE]') return - - let content: string | undefined - try { - content = JSON.parse(data).choices?.[0]?.delta?.content - } catch { - // Ignore keep-alive comments and partially-buffered JSON. - } - - if (content) props.onChunk(content) - } - } - - // Reaching here means the stream closed without a `[DONE]` sentinel, so the - // response was truncated — surface it instead of reporting a partial success. - throw new Error('OpenAI stream ended before completion') -} - -export default { generateCoverLetter } diff --git a/entrypoints/background/index.ts b/entrypoints/background/index.ts index a07e39a..4f3a794 100644 --- a/entrypoints/background/index.ts +++ b/entrypoints/background/index.ts @@ -1,8 +1,8 @@ import { browser, defineBackground } from '#imports' -import openAiApi from '@/api/openai' +import aiApi from '@/api/ai' +import aiSettingsStorage, { AiSettings } from '@/utils/aiSettings' import extension, { Cycles } from '@/utils/extension' import stateStorage, { GlobalState } from '@/utils/globalState' -import openAiApiKeyStorage from '@/utils/openAiApiKey' import runtime, { GenerateCoverLetterResponse } from '@/utils/runtime' import { captureException } from '@/utils/sentry' import dailyReport from './dailyReport' @@ -174,16 +174,40 @@ export default defineBackground({ return } + let settings: AiSettings + try { - const apiKey = await openAiApiKeyStorage.get() + settings = await aiSettingsStorage.get() + } catch (error) { + captureException(error) + post({ type: 'error', error: 'INVALID_SETTINGS' }) + return + } - if (!apiKey) { - post({ type: 'error', error: 'NO_API_KEY' }) - return - } + if (!aiSettingsStorage.getApiKey(settings).trim()) { + post({ type: 'error', error: 'NO_API_KEY' }) + return + } + + if (!aiSettingsStorage.getModel(settings).trim()) { + post({ type: 'error', error: 'NO_MODEL' }) + return + } + + // The settings page requested this origin, but the user can revoke it + // at any time from the browser's own extension controls. + const granted = await browser.permissions.contains({ + origins: [aiSettingsStorage.getHostPermission(settings)], + }) + + if (!granted) { + post({ type: 'error', error: 'MISSING_HOST_PERMISSION' }) + return + } - await openAiApi.generateCoverLetter({ - apiKey, + try { + await aiApi.generateCoverLetter({ + settings, prompt: message.prompt, signal: abortController.signal, onChunk: (content) => post({ type: 'chunk', content }), @@ -196,8 +220,13 @@ export default defineBackground({ return } - captureException(error) - post({ type: 'error', error: 'GENERATION_FAILED' }) + const failure = aiApi.classifyError(error) + + if (failure.reportable) { + captureException(error, { data: failure.diagnostics }) + } + + post({ type: 'error', error: failure.code }) } }) }) diff --git a/utils/runtime.ts b/utils/runtime.ts index 239b7d9..26756a2 100644 --- a/utils/runtime.ts +++ b/utils/runtime.ts @@ -9,13 +9,28 @@ export type GenerateCoverLetterMessage = { type: Message.GENERATE_COVER_LETTER } +/** Machine-readable failures the provider itself reported. */ +export type AiErrorCode = + | 'INVALID_API_KEY' + | 'INSUFFICIENT_QUOTA' + | 'RATE_LIMITED' + | 'MODEL_UNAVAILABLE' + | 'GENERATION_FAILED' + +export type GenerateCoverLetterError = + | AiErrorCode + | 'NO_API_KEY' + | 'NO_MODEL' + | 'INVALID_SETTINGS' + | 'MISSING_HOST_PERMISSION' + /** * Streamed responses sent back over the GENERATE_COVER_LETTER port. */ export type GenerateCoverLetterResponse = | { type: 'chunk'; content: string } | { type: 'done' } - | { type: 'error'; error: 'NO_API_KEY' | 'GENERATION_FAILED' } + | { type: 'error'; error: GenerateCoverLetterError } export type PlaySoundMessage = { volume: number From b1fce78fe1a9f5a6202a42519260a06ba2b49259 Mon Sep 17 00:00:00 2001 From: dorddis Date: Fri, 7 Aug 2026 18:09:29 +0530 Subject: [PATCH 4/6] feat(ai): declare provider origins as optional host permissions api.openai.com stays in host_permissions so installs that predate this change keep working without granting anything; every other provider origin and the AI Gateway are optional and requested from the settings page. WXT strips optional_host_permissions from MV2 builds and does not convert it, which would leave the Firefox build unable to request any of them, so the manifest is now a function of manifestVersion and emits optional_permissions instead on MV2. --- wxt.config.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/wxt.config.ts b/wxt.config.ts index accce3f..d966d91 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from 'wxt' import { sentryVitePlugin } from '@sentry/vite-plugin' +import { + optionalHostPermissions, + staticHostPermissions, +} from './utils/aiCatalog' // See https://wxt.dev/api/config.html export default defineConfig({ - manifest: { + manifest: ({ manifestVersion }) => ({ name: 'Upwork toolkit - your own freelance assistant', description: 'Save time and earn more with Upwork toolkit.', action: { @@ -18,7 +22,12 @@ export default defineConfig({ 'notifications', 'declarativeNetRequest', ], - host_permissions: ['https://*.upwork.com/', 'https://api.openai.com/'], + host_permissions: ['https://*.upwork.com/', ...staticHostPermissions], + // WXT drops `optional_host_permissions` from MV2 builds without converting + // it, which would leave Firefox unable to request any provider origin. + ...(manifestVersion === 3 + ? { optional_host_permissions: optionalHostPermissions } + : { optional_permissions: optionalHostPermissions }), declarative_net_request: { rule_resources: [ { @@ -28,7 +37,7 @@ export default defineConfig({ }, ], }, - }, + }), imports: false, modules: ['@wxt-dev/module-react'], outDir: 'build', From 53df583fd3179a1ded92a1c7984d1646a7d4e328 Mon Sep 17 00:00:00 2001 From: dorddis Date: Fri, 7 Aug 2026 18:09:29 +0530 Subject: [PATCH 5/6] feat(ai): let users pick a provider, key and model in settings Cover letter settings now offer two connection modes: a direct provider key with a curated model list, or an AI Gateway key whose model list is fetched with gateway.getAvailableModels(). Discovery needs auth, so the list is only loadable once a key is entered and the UI says so. permissions.request() runs in the click's own task with nothing awaited before it, as Chrome requires. Denying it sets an inline message and saves nothing - it is a user decision, not an exception. Custom-vs-preset model is explicit state. Deriving it from the model string unmounts the input mid-keystroke, because every preset is a prefix of some longer real model id. Saving always clears the editing flag, so the form closes whether the save started from a fresh install or from Edit. Copy is provider-agnostic throughout, including the release alert that used to say "Set up OpenAI API key". --- entrypoints/content/ChatGptDialog.tsx | 27 +- entrypoints/options/pages/CoverLetter.tsx | 445 ++++++++++++++++++++-- utils/alerts.tsx | 4 +- 3 files changed, 428 insertions(+), 48 deletions(-) diff --git a/entrypoints/content/ChatGptDialog.tsx b/entrypoints/content/ChatGptDialog.tsx index c49af6d..ac41def 100644 --- a/entrypoints/content/ChatGptDialog.tsx +++ b/entrypoints/content/ChatGptDialog.tsx @@ -2,7 +2,10 @@ import { browser } from '#imports' import PromptForm from '@/components/PromptForm' import Storage, { StorageInterface } from '@/contexts/storage' import useKeyPress from '@/hooks/useKeyPress' -import runtime, { GenerateCoverLetterResponse } from '@/utils/runtime' +import runtime, { + GenerateCoverLetterError, + GenerateCoverLetterResponse, +} from '@/utils/runtime' import { helperKey } from '@/utils/system' import { Alert, @@ -16,6 +19,22 @@ import { } from '@mui/material' import { useContext, useEffect, useRef, useState } from 'react' +const settingsLocation = 'the extension’s Cover letter settings' + +const errorMessages: Record = { + NO_API_KEY: `Add an AI provider API key in ${settingsLocation} to generate cover letters.`, + NO_MODEL: `Choose a model in ${settingsLocation} to generate cover letters.`, + INVALID_SETTINGS: `Your AI settings could not be read. Open ${settingsLocation} and save them again.`, + MISSING_HOST_PERMISSION: `The extension is no longer allowed to reach your AI provider. Open ${settingsLocation} and save your settings again to restore access.`, + INVALID_API_KEY: `Your AI provider rejected the API key. Update it in ${settingsLocation}.`, + INSUFFICIENT_QUOTA: + 'Your AI provider account has run out of credit. Top it up and try again.', + RATE_LIMITED: + 'Your AI provider is rate limiting requests. Wait a moment and try again.', + MODEL_UNAVAILABLE: `The selected model is not available on your account. Pick another one in ${settingsLocation}.`, + GENERATION_FAILED: 'Cover letter generation failed. Please try again.', +} + const ChatGptDialog = (props: { onClose: () => void jobTitle: string @@ -93,11 +112,7 @@ const ChatGptDialog = (props: { if (response.type === 'error') { setStreaming(false) setMode('writingPrompt') - setError( - response.error === 'NO_API_KEY' - ? 'Add your OpenAI API key in the extension’s Cover letter settings to generate cover letters.' - : 'Cover letter generation failed. Please try again.' - ) + setError(errorMessages[response.error]) finish() } }) diff --git a/entrypoints/options/pages/CoverLetter.tsx b/entrypoints/options/pages/CoverLetter.tsx index ca72801..ff39d9d 100644 --- a/entrypoints/options/pages/CoverLetter.tsx +++ b/entrypoints/options/pages/CoverLetter.tsx @@ -1,12 +1,32 @@ +import { browser } from '#imports' +import aiApi from '@/api/ai' import PromptForm from '@/components/PromptForm' import useMediaQuery from '@/hooks/useMediaQuery' +import { + ConnectionMode, + ModelPreset, + ProviderDefinition, + ProviderId, + defaultProvider, + findProvider, + gateway, + providers, +} from '@/utils/aiCatalog' +import aiSettingsStorage, { AiSettings } from '@/utils/aiSettings' import coverLetterStorage from '@/utils/coverLetter' import errors from '@/utils/errors' -import openAiApiKeyStorage from '@/utils/openAiApiKey' import promptStorage from '@/utils/prompt' +import { AiErrorCode } from '@/utils/runtime' import { captureException } from '@/utils/sentry' import { helperKey } from '@/utils/system' -import { OpenInNew, Save, Visibility, VisibilityOff } from '@mui/icons-material' +import { + Cloud, + Key, + OpenInNew, + Save, + Visibility, + VisibilityOff, +} from '@mui/icons-material' import { Alert, AlertTitle, @@ -15,16 +35,33 @@ import { Divider, IconButton, Link, + MenuItem, TextField, + ToggleButton, + ToggleButtonGroup, Typography, } from '@mui/material' import { useSnackbar } from 'notistack' import { useEffect, useState } from 'react' -const OPENAI_API_KEYS_URL = 'https://platform.openai.com/api-keys' - const MAX_TEXT_SIZE = 8000 +/** Sentinel value for the "type your own model id" option in the model select. */ +const CUSTOM_MODEL = '__custom__' + +const modelDiscoveryErrors: Record = { + INVALID_API_KEY: 'The AI Gateway rejected this key.', + INSUFFICIENT_QUOTA: 'This AI Gateway account has run out of credit.', + RATE_LIMITED: 'The AI Gateway is rate limiting requests. Try again shortly.', + MODEL_UNAVAILABLE: 'The AI Gateway could not list models for this key.', + GENERATION_FAILED: 'Could not load models from the AI Gateway.', +} + +const permissionFailure = 'The browser refused the permission request.' + +const originLabel = (origin: string) => + origin.replace(/^https:\/\//, '').replace(/\/\*?$/, '') + /** Shows the first and last few characters, masking the rest. */ const maskApiKey = (key: string) => { const trimmed = key.trim() @@ -42,18 +79,107 @@ const CoverLetter = () => { const [text, setText] = useState('') const [prompt, setPrompt] = useState(promptStorage.defaultPrompt) + + const [saved, setSaved] = useState(null) + const [settingsUnreadable, setSettingsUnreadable] = useState(false) + + const [mode, setMode] = useState('direct') + const [providerId, setProviderId] = useState(defaultProvider.id) + const [model, setModel] = useState(defaultProvider.defaultModel) const [apiKey, setApiKey] = useState('') - const [savedApiKey, setSavedApiKey] = useState('') + const [usingCustomModel, setUsingCustomModel] = useState(false) + + const [gatewayModels, setGatewayModels] = useState(null) + const [loadingGatewayModels, setLoadingGatewayModels] = useState(false) + const [gatewayModelsError, setGatewayModelsError] = useState( + null + ) + const [permissionError, setPermissionError] = useState(null) + const [showApiKey, setShowApiKey] = useState(false) const [editingApiKey, setEditingApiKey] = useState(false) const [initialized, setInitialized] = useState(false) const [savingCoverLetter, setSavingCoverLetter] = useState(false) const [savingPrompt, setSavingPrompt] = useState(false) - const [savingApiKey, setSavingApiKey] = useState(false) + const [savingConnection, setSavingConnection] = useState(false) + + const provider: ProviderDefinition = + findProvider(providerId) ?? defaultProvider + const isGateway = mode === 'gateway' + + // Gating reflects the persisted connection, not the in-progress input value. + const savedApiKey = saved ? aiSettingsStorage.getApiKey(saved) : '' + const savedModel = saved ? aiSettingsStorage.getModel(saved) : '' + const isConnected = savedApiKey.trim().length > 0 + const showKeyForm = !isConnected || editingApiKey + + const presetModels: ModelPreset[] = isGateway + ? (gatewayModels ?? []) + : provider.models + + // Keep the selected model listed even when the gateway stops offering it, so + // the field never renders blank against a value the user did save. + const modelOptions: ModelPreset[] = + usingCustomModel || !model || presetModels.some(({ id }) => id === model) + ? presetModels + : [...presetModels, { id: model, label: model }] + + const applySettings = (settings: AiSettings) => { + setMode(settings.mode) + setProviderId(settings.provider) + setApiKey(aiSettingsStorage.getApiKey(settings)) + + const activeModel = aiSettingsStorage.getModel(settings) + setModel(activeModel) + + const presets = + settings.mode === 'gateway' + ? activeModel + ? [activeModel] + : [] + : (findProvider(settings.provider) ?? defaultProvider).models.map( + (preset) => preset.id + ) + + setUsingCustomModel( + activeModel.length > 0 && !presets.includes(activeModel) + ) + } - // Gating reflects the persisted key, not the in-progress input value. - const hasApiKey = savedApiKey.trim().length > 0 - const showKeyForm = !hasApiKey || editingApiKey + const onModeChange = (next: ConnectionMode | null) => { + if (!next || next === mode) return + + setMode(next) + setPermissionError(null) + setGatewayModelsError(null) + setUsingCustomModel(false) + + if (next === 'gateway') { + setApiKey(saved?.gatewayApiKey ?? '') + setModel(saved?.gatewayModel ?? '') + return + } + + setApiKey(saved?.apiKey ?? '') + setModel(saved?.model ?? defaultProvider.defaultModel) + } + + const onProviderChange = (next: ProviderId) => { + setProviderId(next) + setPermissionError(null) + setUsingCustomModel(false) + setModel((findProvider(next) ?? defaultProvider).defaultModel) + } + + const onModelSelect = (value: string) => { + if (value === CUSTOM_MODEL) { + setUsingCustomModel(true) + return + } + + setUsingCustomModel(false) + setModel(value) + } const onEditApiKey = () => { setApiKey(savedApiKey) @@ -61,9 +187,107 @@ const CoverLetter = () => { } const onCancelEditApiKey = () => { - setApiKey(savedApiKey) + if (saved) applySettings(saved) setShowApiKey(false) setEditingApiKey(false) + setPermissionError(null) + } + + const hostPermission = isGateway + ? gateway.hostPermission + : provider.hostPermission + + const onLoadGatewayModels = () => { + setGatewayModelsError(null) + setLoadingGatewayModels(true) + + // Chrome only honours permissions.request() inside the click's own task, so + // nothing may be awaited before it. + browser.permissions + .request({ origins: [gateway.hostPermission] }) + .then(async (granted) => { + if (!granted) { + setGatewayModelsError( + 'Loading the model list needs access to ai-gateway.vercel.sh.' + ) + return + } + + try { + setGatewayModels(await aiApi.fetchGatewayModels(apiKey.trim())) + } catch (error) { + const failure = aiApi.classifyError(error) + + if (failure.reportable) { + captureException(error, { data: failure.diagnostics }) + } + + setGatewayModelsError(modelDiscoveryErrors[failure.code]) + } + }) + .catch((error) => { + captureException(error) + setGatewayModelsError(permissionFailure) + }) + .finally(() => setLoadingGatewayModels(false)) + } + + const persistConnection = async () => { + const trimmedKey = apiKey.trim() + const trimmedModel = model.trim() + const previous = saved ?? aiSettingsStorage.getDefaultSettings() + + const next: AiSettings = isGateway + ? { + ...previous, + mode, + gatewayApiKey: trimmedKey, + gatewayModel: trimmedModel, + } + : { + ...previous, + mode, + provider: providerId, + apiKey: trimmedKey, + model: trimmedModel, + } + + setSaved(await aiSettingsStorage.save(next)) + setSettingsUnreadable(false) + setEditingApiKey(false) + setShowApiKey(false) + } + + const onSaveConnection = () => { + setPermissionError(null) + setSavingConnection(true) + + // Same user-gesture constraint as onLoadGatewayModels. + browser.permissions + .request({ origins: [hostPermission] }) + .then(async (granted) => { + if (!granted) { + setPermissionError( + `Nothing was saved: cover letters can only be generated once the extension is allowed to reach ${originLabel(hostPermission)}.` + ) + return + } + + try { + await persistConnection() + enqueueSnackbar('Your changes have been saved', { + variant: 'success', + }) + } catch (error) { + captureException(error) + enqueueSnackbar(errors.getErrorMessage(error), { variant: 'error' }) + } + }) + .catch((error) => { + captureException(error) + setPermissionError(permissionFailure) + }) + .finally(() => setSavingConnection(false)) } const makeSaveHandler = @@ -82,11 +306,6 @@ const CoverLetter = () => { } } - const onSaveApiKey = makeSaveHandler(setSavingApiKey, async () => { - setSavedApiKey(await openAiApiKeyStorage.save(apiKey.trim())) - setEditingApiKey(false) - }) - const onSaveCoverLetter = makeSaveHandler(setSavingCoverLetter, () => coverLetterStorage.save(text) ) @@ -97,16 +316,24 @@ const CoverLetter = () => { useEffect(() => { const init = async () => { - const [prompt, text, apiKey] = await Promise.all([ + const [prompt, text] = await Promise.all([ promptStorage.get(), coverLetterStorage.get(), - openAiApiKeyStorage.get(), ]) setPrompt(prompt) setText(text) - setApiKey(apiKey) - setSavedApiKey(apiKey) + + try { + const settings = await aiSettingsStorage.get() + setSaved(settings) + applySettings(settings) + } catch (error) { + // A record that exists but cannot be parsed is a bug, not a fresh + // install — never silently fall back to the pre-migration key. + captureException(error) + setSettingsUnreadable(true) + } setInitialized(true) } @@ -114,39 +341,113 @@ const CoverLetter = () => { init() }, []) + const modelSelectValue = usingCustomModel ? CUSTOM_MODEL : model + const canSaveConnection = + initialized && + !savingConnection && + apiKey.trim().length > 0 && + model.trim().length > 0 + return ( - Use ChatGPT to apply faster with personalized cover letters + Generate personalized cover letters with your own AI account + {settingsUnreadable && ( + + Your saved AI settings could not be read + Pick a provider and re-enter your API key below to restore cover + letter generation. + + )} + {showKeyForm ? ( <> - {!hasApiKey && ( + {!isConnected && !settingsUnreadable && ( - Connect your OpenAI API key to start generating cover letters + Connect an AI provider to start generating cover letters - Cover letters are generated with your own OpenAI account. -
- Create a key at{' '} - - platform.openai.com/api-keys + Cover letters are generated with your own account — the extension + has no backend and your key never leaves this browser. +
+ )} + + onModeChange(value)} + > + + + Direct provider + + + + AI Gateway + + + + {isGateway ? ( + + One Vercel AI Gateway key gives you every model it routes to. See{' '} + + {gateway.docsLabel} {' '} - and paste it below. - + to create one, paste it below, then load the model list. + + ) : ( + onProviderChange(e.target.value as ProviderId)} + > + {providers.map((option) => ( + + {option.label} + + ))} + )} setApiKey(e.target.value)} - disabled={savingApiKey || !initialized} + disabled={savingConnection || !initialized} + helperText={ + isGateway ? undefined : ( + <> + Create a key at{' '} + + {provider.apiKeysLabel} + + + + ) + } slotProps={{ input: { endAdornment: ( @@ -162,13 +463,74 @@ const CoverLetter = () => { }} /> + {isGateway && ( + + + + )} + + {gatewayModelsError && ( + + {gatewayModelsError} + + )} + + onModelSelect(e.target.value)} + helperText={ + isGateway && !gatewayModels + ? 'Load the model list to browse everything your gateway key can reach, or enter a model id yourself.' + : undefined + } + > + {modelOptions.map((option) => ( + + {option.label} + + ))} + Other (enter a model id) + + + {usingCustomModel && ( + setModel(e.target.value)} + /> + )} + + {permissionError && ( + + {permissionError} + + )} + - {hasApiKey && ( + {isConnected && ( @@ -179,8 +541,8 @@ const CoverLetter = () => { endIcon={} variant="contained" fullWidth={isMobile} - onClick={onSaveApiKey} - disabled={savingApiKey || !initialized || !apiKey.trim()} + onClick={onSaveConnection} + disabled={!canSaveConnection} > Save @@ -191,8 +553,11 @@ const CoverLetter = () => { From c234c9effe4076b3b914edc2f0447c05a55146de Mon Sep 17 00:00:00 2001 From: dorddis Date: Fri, 7 Aug 2026 18:09:34 +0530 Subject: [PATCH 6/6] docs: describe the multi-provider cover letter path --- CLAUDE.md | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e5f53bf..812c262 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,9 +42,11 @@ uptoolkit/ │ │ └── Debug.tsx # Hidden debug tools ├── api/ │ ├── upwork.ts # Upwork GraphQL API -│ ├── openai.ts # OpenAI API (AI cover letter generation) +│ ├── ai.ts # AI SDK provider/gateway calls (cover letters) │ └── gqlQueries.ts # GraphQL query definitions ├── utils/ +│ ├── aiCatalog.ts # Providers, models, host permissions (data only) +│ ├── aiSettings.ts # Stored AI connection + legacy key migration │ ├── globalState.ts # Cloud-synced state management │ ├── jobs.ts # Job caching │ ├── notifications.ts # Desktop notifications @@ -82,8 +84,10 @@ uptoolkit/ ### 3. AI-Powered Cover Letter Generation -- **Status**: Free — uses the user's own OpenAI API key -- **ChatGPT integration** via a direct call to the OpenAI API (no backend) +- **Status**: Free — uses the user's own AI provider API key +- **Two connection modes** (no backend): a direct provider key (OpenAI, Google + Gemini, Anthropic, Groq, DeepSeek, OpenRouter) or a Vercel AI Gateway key + whose model list is discovered at runtime - **Prompt template system** with variables: - `#{title}` - Job title - `#{job_description}` - Full job description @@ -121,11 +125,14 @@ uptoolkit/ - Cookie-based authentication - Queries: MyFeed, BestMatches, MostRecent, UserInfo, JobDetails -### OpenAI API (`api/openai.ts`) +### AI providers (`api/ai.ts`) -- Endpoint: `https://api.openai.com/v1/chat/completions` +- Vercel AI SDK (`streamText`) over the provider chosen in settings, or over the + AI Gateway when the user supplies a gateway key - Authenticated with the user's own API key (stored in synced storage) -- Streaming chat completions; cover letter generation runs through the background worker +- Non-OpenAI origins are optional host permissions, requested from the settings + page and re-checked in the background worker before every generation +- Streaming; cover letter generation runs through the background worker ### External Services @@ -156,6 +163,8 @@ Synced across devices via Chrome storage: - `__COVER_LETTER` - Cover letter template - `__COVER_LETTER_PROMPT` - AI prompt template +- `__AI_SETTINGS` - Connection mode, provider, model, API keys (migrated from + the retired `__OPENAI_API_KEY`, which is deleted once the new record lands) --- @@ -193,6 +202,12 @@ Synced across devices via Chrome storage: - `notifications` - Desktop notifications - `declarativeNetRequest` - Modify requests +Host permissions are declared once in `utils/aiCatalog.ts` and consumed by +`wxt.config.ts`. Every AI origin except `api.openai.com` is optional and +requested from the Cover letter settings page. WXT strips +`optional_host_permissions` on MV2, so the Firefox build emits the same origins +under `optional_permissions`. + --- ## Key Files Reference @@ -205,7 +220,7 @@ Synced across devices via Chrome storage: | Options app | `entrypoints/options/App.tsx` | | Global state | `utils/globalState.ts` | | Upwork API | `api/upwork.ts` | -| OpenAI API | `api/openai.ts` | +| AI providers | `api/ai.ts` | | Theme config | `theme.ts` | | WXT config | `wxt.config.ts` | @@ -229,7 +244,7 @@ SENTRY_PROJECT # Sentry project ### File Organization - **Entrypoints**: One folder per extension context (background, content, options) -- **API modules**: Separate file per external service (`api/upwork.ts`, `api/openai.ts`) +- **API modules**: Separate file per external service (`api/upwork.ts`, `api/ai.ts`) - **Utils**: Single-responsibility utility files - **Components**: Reusable UI in `components/`, page-specific in `entrypoints/options/pages/`