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/` 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/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 = () => { 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 } 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 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',