From c39e72301f1afea5a654eda974be0600a2d92f6c Mon Sep 17 00:00:00 2001 From: itzMRZ <57846631+itzMRZ@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:48:13 +0600 Subject: [PATCH 1/3] Add provider presets and custom model support for cover letters --- api/openai.ts | 169 ++++++++++++----- entrypoints/background/index.ts | 22 ++- entrypoints/content/ChatGptDialog.tsx | 44 ++++- entrypoints/options/pages/CoverLetter.tsx | 212 +++++++++++++++++++--- utils/openAiApiConfig.ts | 135 ++++++++++++++ utils/runtime.ts | 14 +- wxt.config.ts | 5 + 7 files changed, 522 insertions(+), 79 deletions(-) create mode 100644 utils/openAiApiConfig.ts diff --git a/api/openai.ts b/api/openai.ts index 2c82b11..7158f70 100644 --- a/api/openai.ts +++ b/api/openai.ts @@ -1,41 +1,71 @@ -const ENDPOINT = 'https://api.openai.com/v1/chat/completions' -const MODEL = 'gpt-4o-mini' +export type GenerationErrorCode = + | 'ACCESS_DENIED' + | 'INSUFFICIENT_CREDITS' + | 'INVALID_API_KEY' + | 'MODEL_UNAVAILABLE' + | 'NETWORK_ERROR' + | 'RATE_LIMITED' + | 'GENERATION_FAILED' -/** - * 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 +class GenerationError extends Error { + constructor(public readonly code: GenerationErrorCode) { + super(code) + this.name = 'GenerationError' + } +} + +const isGenerationError = (error: unknown): error is GenerationError => + error instanceof GenerationError + +const getErrorCode = ( + status: number, + details: string +): GenerationErrorCode => { + switch (status) { + case 401: + return 'INVALID_API_KEY' + case 403: + return 'ACCESS_DENIED' + case 402: + return 'INSUFFICIENT_CREDITS' + case 404: + return 'MODEL_UNAVAILABLE' + case 400: + return /\bmodel\b/i.test(details) + ? 'MODEL_UNAVAILABLE' + : 'GENERATION_FAILED' + case 429: + return 'RATE_LIMITED' + default: + return 'GENERATION_FAILED' + } +} + +const handleStreamLine = ( + line: string, 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}`) +): boolean => { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) return false + + const data = trimmed.slice('data:'.length).trim() + if (data === '[DONE]') return true + + try { + const content = JSON.parse(data).choices?.[0]?.delta?.content + if (content) onChunk(content) + } catch { + // Ignore keep-alive comments and partially-buffered JSON. } - const reader = response.body.getReader() + return false +} + +const readStream = async ( + body: ReadableStream, + onChunk: (chunk: string) => void +): Promise => { + const reader = body.getReader() const decoder = new TextDecoder() let buffer = '' @@ -54,26 +84,65 @@ const generateCoverLetter = async (props: { 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 + if (handleStreamLine(line, onChunk)) return + } + } - let content: string | undefined - try { - content = JSON.parse(data).choices?.[0]?.delta?.content - } catch { - // Ignore keep-alive comments and partially-buffered JSON. - } + // 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 GenerationError('GENERATION_FAILED') +} - if (content) props.onChunk(content) +/** + * Streams a cover letter from an OpenAI-compatible 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 + endpoint: string + model: string + prompt: string + signal?: AbortSignal + onChunk: (chunk: string) => void +}): Promise => { + let response: Response + try { + response = await fetch(props.endpoint, { + method: 'POST', + signal: props.signal, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${props.apiKey}`, + }, + body: JSON.stringify({ + model: props.model, + stream: true, + messages: [{ role: 'user', content: props.prompt }], + }), + }) + } catch (error) { + if (props.signal?.aborted) { + throw error } + + throw new GenerationError('NETWORK_ERROR') } - // 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') + if (!response.ok) { + const details = await response.text().catch(() => '') + throw new GenerationError(getErrorCode(response.status, details)) + } + + if (!response.body) { + throw new GenerationError('GENERATION_FAILED') + } + + await readStream(response.body, props.onChunk) } -export default { generateCoverLetter } +export default { generateCoverLetter, isGenerationError } diff --git a/entrypoints/background/index.ts b/entrypoints/background/index.ts index a07e39a..81d9a70 100644 --- a/entrypoints/background/index.ts +++ b/entrypoints/background/index.ts @@ -2,6 +2,7 @@ import { browser, defineBackground } from '#imports' import openAiApi from '@/api/openai' import extension, { Cycles } from '@/utils/extension' import stateStorage, { GlobalState } from '@/utils/globalState' +import openAiApiConfigStorage from '@/utils/openAiApiConfig' import openAiApiKeyStorage from '@/utils/openAiApiKey' import runtime, { GenerateCoverLetterResponse } from '@/utils/runtime' import { captureException } from '@/utils/sentry' @@ -175,15 +176,27 @@ export default defineBackground({ } try { - const apiKey = await openAiApiKeyStorage.get() + const [apiKey, savedApiConfig] = await Promise.all([ + openAiApiKeyStorage.get(), + openAiApiConfigStorage.get(), + ]) if (!apiKey) { post({ type: 'error', error: 'NO_API_KEY' }) return } + if (!(await openAiApiConfigStorage.hasPermission(savedApiConfig.provider))) { + post({ type: 'error', error: 'API_PROVIDER_PERMISSION_REQUIRED' }) + return + } + await openAiApi.generateCoverLetter({ apiKey, + endpoint: openAiApiConfigStorage.getChatCompletionsEndpoint( + savedApiConfig.provider + ), + model: savedApiConfig.model, prompt: message.prompt, signal: abortController.signal, onChunk: (content) => post({ type: 'chunk', content }), @@ -197,7 +210,12 @@ export default defineBackground({ } captureException(error) - post({ type: 'error', error: 'GENERATION_FAILED' }) + post({ + type: 'error', + error: openAiApi.isGenerationError(error) + ? error.code + : 'GENERATION_FAILED', + }) } }) }) diff --git a/entrypoints/content/ChatGptDialog.tsx b/entrypoints/content/ChatGptDialog.tsx index c49af6d..bae2876 100644 --- a/entrypoints/content/ChatGptDialog.tsx +++ b/entrypoints/content/ChatGptDialog.tsx @@ -16,6 +16,44 @@ import { } from '@mui/material' import { useContext, useEffect, useRef, useState } from 'react' +const getGenerationErrorMessage = ( + error: Extract['error'] +) => { + if (error === 'NO_API_KEY') { + return 'Add an API key in the extension’s Cover letter settings to generate cover letters.' + } + + if (error === 'API_PROVIDER_PERMISSION_REQUIRED') { + return 'Grant access to your selected AI provider in the extension’s Cover letter settings.' + } + + if (error === 'ACCESS_DENIED') { + return 'Your selected AI provider denied access. Check your account permissions and API key.' + } + + if (error === 'INVALID_API_KEY') { + return 'Your API key was rejected. Check the key for your selected AI provider.' + } + + if (error === 'INSUFFICIENT_CREDITS') { + return 'Your selected AI provider account has insufficient credit to generate a cover letter.' + } + + if (error === 'MODEL_UNAVAILABLE') { + return 'This model is unavailable from your selected AI provider. Select another model and try again.' + } + + if (error === 'NETWORK_ERROR') { + return 'Could not reach your selected AI provider. Check your connection and try again.' + } + + if (error === 'RATE_LIMITED') { + return 'Your selected AI provider is rate-limiting requests. Wait a moment and try again.' + } + + return 'Cover letter generation failed. Please try again.' +} + const ChatGptDialog = (props: { onClose: () => void jobTitle: string @@ -93,11 +131,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(getGenerationErrorMessage(response.error)) finish() } }) diff --git a/entrypoints/options/pages/CoverLetter.tsx b/entrypoints/options/pages/CoverLetter.tsx index ca72801..3c7b927 100644 --- a/entrypoints/options/pages/CoverLetter.tsx +++ b/entrypoints/options/pages/CoverLetter.tsx @@ -2,6 +2,11 @@ import PromptForm from '@/components/PromptForm' import useMediaQuery from '@/hooks/useMediaQuery' import coverLetterStorage from '@/utils/coverLetter' import errors from '@/utils/errors' +import openAiApiConfigStorage, { + ApiProvider, + OpenAiApiConfig, + providerIds, +} from '@/utils/openAiApiConfig' import openAiApiKeyStorage from '@/utils/openAiApiKey' import promptStorage from '@/utils/prompt' import { captureException } from '@/utils/sentry' @@ -13,8 +18,12 @@ import { Box, Button, Divider, + FormControl, IconButton, + InputLabel, Link, + MenuItem, + Select, TextField, Typography, } from '@mui/material' @@ -24,6 +33,44 @@ import { useEffect, useState } from 'react' const OPENAI_API_KEYS_URL = 'https://platform.openai.com/api-keys' const MAX_TEXT_SIZE = 8000 +const CUSTOM_MODEL = '__custom_model__' + +const getApiKeyLabel = (provider: ApiProvider) => + `${openAiApiConfigStorage.getProvider(provider).label} API key` + +const getApiKeyPlaceholder = (provider: ApiProvider) => { + if (provider === 'groq') return 'gsk_...' + if (provider === 'openrouter') return 'sk-or-v1-...' + + return 'sk-...' +} + +const ApiKeyInfoAlert = (props: { provider: ApiProvider }) => { + if (props.provider !== 'openai') { + const provider = openAiApiConfigStorage.getProvider(props.provider) + + return ( + + Connect your API key to start generating cover letters + Get an API key from {provider.label} and paste it below. + + ) + } + + return ( + + Connect your OpenAI API key to start generating cover letters + You can now apply to jobs with custom-tailored AI generated cover letters - just add your OpenAI-compatible API key by clicking the button below. +
+ Create a key at{' '} + + platform.openai.com/api-keys + + {' '} + and paste it below. +
+ ) +} /** Shows the first and last few characters, masking the rest. */ const maskApiKey = (key: string) => { @@ -36,6 +83,86 @@ const maskApiKey = (key: string) => { return `${trimmed.slice(0, 3)}${'•'.repeat(8)}${trimmed.slice(-4)}` } +const AiProviderFields = (props: { + apiConfig: OpenAiApiConfig + disabled: boolean + onConfigChange: (config: OpenAiApiConfig) => void + onProviderChange: (provider: ApiProvider) => void +}) => { + const selectedProvider = openAiApiConfigStorage.getProvider( + props.apiConfig.provider + ) + const supportedModels = openAiApiConfigStorage.getModels( + props.apiConfig.provider + ) + const isCustomModel = !supportedModels.includes(props.apiConfig.model) + + return ( + <> + + Provider + + + + { + const model = event.target.value + + props.onConfigChange({ + ...props.apiConfig, + model: model === CUSTOM_MODEL ? '' : model, + }) + }} + disabled={props.disabled} + helperText={`Select a preset or enter a model ID supported by ${selectedProvider.label}`} + > + {supportedModels.map((model: string) => ( + + {model} + + ))} + Custom model ID + + + {isCustomModel && ( + + props.onConfigChange({ + ...props.apiConfig, + model: event.target.value, + }) + } + disabled={props.disabled} + helperText={`Enter a model ID supported by ${selectedProvider.label}`} + /> + )} + + ) +} + const CoverLetter = () => { const { isMobile } = useMediaQuery() const { enqueueSnackbar } = useSnackbar() @@ -44,6 +171,12 @@ const CoverLetter = () => { const [prompt, setPrompt] = useState(promptStorage.defaultPrompt) const [apiKey, setApiKey] = useState('') const [savedApiKey, setSavedApiKey] = useState('') + const [apiConfig, setApiConfig] = useState( + openAiApiConfigStorage.defaultConfig + ) + const [savedApiConfig, setSavedApiConfig] = useState( + openAiApiConfigStorage.defaultConfig + ) const [showApiKey, setShowApiKey] = useState(false) const [editingApiKey, setEditingApiKey] = useState(false) const [initialized, setInitialized] = useState(false) @@ -54,6 +187,8 @@ const CoverLetter = () => { // Gating reflects the persisted key, not the in-progress input value. const hasApiKey = savedApiKey.trim().length > 0 const showKeyForm = !hasApiKey || editingApiKey + const apiKeyLabel = getApiKeyLabel(apiConfig.provider) + const apiKeyPlaceholder = getApiKeyPlaceholder(apiConfig.provider) const onEditApiKey = () => { setApiKey(savedApiKey) @@ -62,10 +197,19 @@ const CoverLetter = () => { const onCancelEditApiKey = () => { setApiKey(savedApiKey) + setApiConfig(savedApiConfig) setShowApiKey(false) setEditingApiKey(false) } + const onProviderChange = (provider: ApiProvider) => { + setApiConfig({ + provider, + model: openAiApiConfigStorage.getProvider(provider).defaultModel, + }) + setApiKey('') + } + const makeSaveHandler = (setSaving: (saving: boolean) => void, save: () => Promise) => async () => { @@ -83,8 +227,36 @@ const CoverLetter = () => { } const onSaveApiKey = makeSaveHandler(setSavingApiKey, async () => { - setSavedApiKey(await openAiApiKeyStorage.save(apiKey.trim())) - setEditingApiKey(false) + const model = apiConfig.model.trim() + if (!model) { + throw new Error('Enter a model name') + } + + const nextConfig = { provider: apiConfig.provider, model } + const permissionGranted = await openAiApiConfigStorage.requestPermission( + nextConfig.provider + ) + + if (!permissionGranted) { + throw new Error('Permission was not granted for this AI provider') + } + + const providerChanged = savedApiConfig.provider !== nextConfig.provider + const savedKey = await openAiApiKeyStorage.save(apiKey.trim()) + + await openAiApiConfigStorage.save(nextConfig) + setApiKey(savedKey) + setSavedApiKey(savedKey) + setApiConfig(nextConfig) + setSavedApiConfig(nextConfig) + + if (providerChanged) { + try { + await openAiApiConfigStorage.removePermission(savedApiConfig.provider) + } catch (error) { + captureException(error) + } + } }) const onSaveCoverLetter = makeSaveHandler(setSavingCoverLetter, () => @@ -97,16 +269,19 @@ const CoverLetter = () => { useEffect(() => { const init = async () => { - const [prompt, text, apiKey] = await Promise.all([ + const [prompt, text, apiKey, apiConfig] = await Promise.all([ promptStorage.get(), coverLetterStorage.get(), openAiApiKeyStorage.get(), + openAiApiConfigStorage.get(), ]) setPrompt(prompt) setText(text) setApiKey(apiKey) setSavedApiKey(apiKey) + setApiConfig(apiConfig) + setSavedApiConfig(apiConfig) setInitialized(true) } @@ -123,26 +298,21 @@ const CoverLetter = () => { {showKeyForm ? ( <> {!hasApiKey && ( - - - Connect your OpenAI API key to start generating cover letters - - Cover letters are generated with your own OpenAI account. -
- Create a key at{' '} - - platform.openai.com/api-keys - - {' '} - and paste it below. -
+ )} + + setApiKey(e.target.value)} @@ -191,7 +361,7 @@ const CoverLetter = () => { @@ -206,8 +376,8 @@ const CoverLetter = () => { Create a detailed prompt below and next time you're on a job proposal page click "Generate" button under the cover letter input - (you can also use "{helperKey} + Enter" as a shortcut) and let - ChatGPT generate a unique cover letter. + (you can also use "{helperKey} + Enter" as a shortcut) and let AI + generate a unique cover letter.

You can also edit the prompt for each job separately when you apply. diff --git a/utils/openAiApiConfig.ts b/utils/openAiApiConfig.ts new file mode 100644 index 0000000..d892e07 --- /dev/null +++ b/utils/openAiApiConfig.ts @@ -0,0 +1,135 @@ +import { browser, storage } from '#imports' + +const DEFAULT_MODEL = 'gpt-4o-mini' + +const namespace = 'sync:__OPENAI_API_CONFIG' + +export type ApiProvider = + | 'openai' + | 'openrouter' + | 'groq' + | 'deepseek' + +type ApiProviderConfig = { + apiBaseUrl: string + defaultModel: string + label: string + models: string[] +} + +const providers: Record = { + openai: { + apiBaseUrl: 'https://api.openai.com/v1', + defaultModel: DEFAULT_MODEL, + label: 'OpenAI', + models: ['gpt-4o-mini', 'gpt-4.1-mini', 'gpt-5-mini'], + }, + openrouter: { + apiBaseUrl: 'https://openrouter.ai/api/v1', + defaultModel: 'openai/gpt-4o-mini', + label: 'OpenRouter', + models: [ + 'openai/gpt-4o-mini', + 'meta-llama/llama-3.1-8b-instruct', + 'qwen/qwen3-8b', + ], + }, + groq: { + apiBaseUrl: 'https://api.groq.com/openai/v1', + defaultModel: 'llama-3.1-8b-instant', + label: 'Groq', + models: ['llama-3.1-8b-instant', 'openai/gpt-oss-20b'], + }, + deepseek: { + apiBaseUrl: 'https://api.deepseek.com', + defaultModel: 'deepseek-v4-flash', + label: 'DeepSeek', + models: ['deepseek-v4-flash', 'deepseek-v4-pro'], + }, +} + +export const providerIds = Object.keys(providers) as ApiProvider[] + +const isApiProvider = (value: unknown): value is ApiProvider => + typeof value === 'string' && providerIds.includes(value as ApiProvider) + +export type OpenAiApiConfig = { + model: string + provider: ApiProvider +} + +const defaultConfig: OpenAiApiConfig = { + model: DEFAULT_MODEL, + provider: 'openai', +} + +export const getProvider = (provider: ApiProvider): ApiProviderConfig => + providers[provider] + +export const getModels = (provider: ApiProvider): string[] => + getProvider(provider).models + +export const getChatCompletionsEndpoint = (provider: ApiProvider): string => + new URL('chat/completions', `${getProvider(provider).apiBaseUrl}/`).toString() + +export const getPermissionOrigin = (provider: ApiProvider): string => { + const url = new URL(getProvider(provider).apiBaseUrl) + return `${url.protocol}//${url.hostname}/*` +} + +export const requestPermission = async ( + provider: ApiProvider +): Promise => { + if (provider === 'openai') { + return true + } + + return browser.permissions.request({ origins: [getPermissionOrigin(provider)] }) +} + +export const hasPermission = async (provider: ApiProvider): Promise => { + if (provider === 'openai') { + return true + } + + return browser.permissions.contains({ origins: [getPermissionOrigin(provider)] }) +} + +export const removePermission = async ( + provider: ApiProvider +): Promise => { + if (provider === 'openai') { + return false + } + + return browser.permissions.remove({ origins: [getPermissionOrigin(provider)] }) +} + +const get = async (): Promise => { + const config = await storage.getItem(namespace, { + fallback: defaultConfig, + }) + + return isApiProvider(config.provider) && typeof config.model === 'string' + ? config + : defaultConfig +} + +const save = async (value: OpenAiApiConfig) => { + await storage.setItem(namespace, value) + return value +} + +export default { + defaultConfig, + get, + save, + providerIds, + getProvider, + getModels, + getChatCompletionsEndpoint, + getPermissionOrigin, + requestPermission, + hasPermission, + removePermission, +} \ No newline at end of file diff --git a/utils/runtime.ts b/utils/runtime.ts index 239b7d9..64b772f 100644 --- a/utils/runtime.ts +++ b/utils/runtime.ts @@ -15,7 +15,19 @@ export type GenerateCoverLetterMessage = { export type GenerateCoverLetterResponse = | { type: 'chunk'; content: string } | { type: 'done' } - | { type: 'error'; error: 'NO_API_KEY' | 'GENERATION_FAILED' } + | { + type: 'error' + error: + | 'NO_API_KEY' + | 'API_PROVIDER_PERMISSION_REQUIRED' + | 'ACCESS_DENIED' + | 'INVALID_API_KEY' + | 'INSUFFICIENT_CREDITS' + | 'MODEL_UNAVAILABLE' + | 'NETWORK_ERROR' + | 'RATE_LIMITED' + | 'GENERATION_FAILED' + } export type PlaySoundMessage = { volume: number diff --git a/wxt.config.ts b/wxt.config.ts index accce3f..21d16dd 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -19,6 +19,11 @@ export default defineConfig({ 'declarativeNetRequest', ], host_permissions: ['https://*.upwork.com/', 'https://api.openai.com/'], + optional_host_permissions: [ + 'https://openrouter.ai/*', + 'https://api.groq.com/*', + 'https://api.deepseek.com/*', + ], declarative_net_request: { rule_resources: [ { From 69c500e61389c9ae1488265724a17cd1c7b7b972 Mon Sep 17 00:00:00 2001 From: itzMRZ <57846631+itzMRZ@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:00:50 +0600 Subject: [PATCH 2/3] CodeRabbit fix: process flushed SSE tail before GENERATION_FAILED --- api/openai.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/openai.ts b/api/openai.ts index 7158f70..5264df9 100644 --- a/api/openai.ts +++ b/api/openai.ts @@ -88,6 +88,10 @@ const readStream = async ( } } + if (buffer && handleStreamLine(buffer, onChunk)) { + return + } + // 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 GenerationError('GENERATION_FAILED') From a6a61997f1e9e7a433089838cd9703655a0d159e Mon Sep 17 00:00:00 2001 From: itzMRZ <57846631+itzMRZ@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:29:59 +0600 Subject: [PATCH 3/3] fix(settings): atomically persist API key, provider, and model --- entrypoints/background/index.ts | 14 +++--- entrypoints/content/ChatGptDialog.tsx | 54 +++++++++-------------- entrypoints/options/pages/CoverLetter.tsx | 37 +++++++++------- utils/openAiApiSettings.ts | 47 ++++++++++++++++++++ 4 files changed, 98 insertions(+), 54 deletions(-) create mode 100644 utils/openAiApiSettings.ts diff --git a/entrypoints/background/index.ts b/entrypoints/background/index.ts index 81d9a70..b2f0317 100644 --- a/entrypoints/background/index.ts +++ b/entrypoints/background/index.ts @@ -3,7 +3,7 @@ import openAiApi from '@/api/openai' import extension, { Cycles } from '@/utils/extension' import stateStorage, { GlobalState } from '@/utils/globalState' import openAiApiConfigStorage from '@/utils/openAiApiConfig' -import openAiApiKeyStorage from '@/utils/openAiApiKey' +import openAiApiSettingsStorage from '@/utils/openAiApiSettings' import runtime, { GenerateCoverLetterResponse } from '@/utils/runtime' import { captureException } from '@/utils/sentry' import dailyReport from './dailyReport' @@ -176,17 +176,19 @@ export default defineBackground({ } try { - const [apiKey, savedApiConfig] = await Promise.all([ - openAiApiKeyStorage.get(), - openAiApiConfigStorage.get(), - ]) + const { apiKey, config: savedApiConfig } = + await openAiApiSettingsStorage.get() if (!apiKey) { post({ type: 'error', error: 'NO_API_KEY' }) return } - if (!(await openAiApiConfigStorage.hasPermission(savedApiConfig.provider))) { + if ( + !(await openAiApiConfigStorage.hasPermission( + savedApiConfig.provider + )) + ) { post({ type: 'error', error: 'API_PROVIDER_PERMISSION_REQUIRED' }) return } diff --git a/entrypoints/content/ChatGptDialog.tsx b/entrypoints/content/ChatGptDialog.tsx index bae2876..c579cd7 100644 --- a/entrypoints/content/ChatGptDialog.tsx +++ b/entrypoints/content/ChatGptDialog.tsx @@ -19,39 +19,29 @@ import { useContext, useEffect, useRef, useState } from 'react' const getGenerationErrorMessage = ( error: Extract['error'] ) => { - if (error === 'NO_API_KEY') { - return 'Add an API key in the extension’s Cover letter settings to generate cover letters.' + switch (error) { + case 'NO_API_KEY': + return 'Add an API key in the extension’s Cover letter settings to generate cover letters.' + case 'API_PROVIDER_PERMISSION_REQUIRED': + return 'Grant access to your selected AI provider in the extension’s Cover letter settings.' + case 'ACCESS_DENIED': + return 'Your selected AI provider denied access. Check your account permissions and API key.' + case 'INVALID_API_KEY': + return 'Your API key was rejected. Check the key for your selected AI provider.' + case 'INSUFFICIENT_CREDITS': + return 'Your selected AI provider account has insufficient credit to generate a cover letter.' + case 'MODEL_UNAVAILABLE': + return 'This model is unavailable from your selected AI provider. Select another model and try again.' + case 'NETWORK_ERROR': + return 'Could not reach your selected AI provider. Check your connection and try again.' + case 'RATE_LIMITED': + return 'Your selected AI provider is rate-limiting requests. Wait a moment and try again.' + case 'GENERATION_FAILED': + return 'Cover letter generation failed. Please try again.' + default: + error satisfies never + return 'Cover letter generation failed. Please try again.' } - - if (error === 'API_PROVIDER_PERMISSION_REQUIRED') { - return 'Grant access to your selected AI provider in the extension’s Cover letter settings.' - } - - if (error === 'ACCESS_DENIED') { - return 'Your selected AI provider denied access. Check your account permissions and API key.' - } - - if (error === 'INVALID_API_KEY') { - return 'Your API key was rejected. Check the key for your selected AI provider.' - } - - if (error === 'INSUFFICIENT_CREDITS') { - return 'Your selected AI provider account has insufficient credit to generate a cover letter.' - } - - if (error === 'MODEL_UNAVAILABLE') { - return 'This model is unavailable from your selected AI provider. Select another model and try again.' - } - - if (error === 'NETWORK_ERROR') { - return 'Could not reach your selected AI provider. Check your connection and try again.' - } - - if (error === 'RATE_LIMITED') { - return 'Your selected AI provider is rate-limiting requests. Wait a moment and try again.' - } - - return 'Cover letter generation failed. Please try again.' } const ChatGptDialog = (props: { diff --git a/entrypoints/options/pages/CoverLetter.tsx b/entrypoints/options/pages/CoverLetter.tsx index 3c7b927..9ed7b49 100644 --- a/entrypoints/options/pages/CoverLetter.tsx +++ b/entrypoints/options/pages/CoverLetter.tsx @@ -7,7 +7,7 @@ import openAiApiConfigStorage, { OpenAiApiConfig, providerIds, } from '@/utils/openAiApiConfig' -import openAiApiKeyStorage from '@/utils/openAiApiKey' +import openAiApiSettingsStorage from '@/utils/openAiApiSettings' import promptStorage from '@/utils/prompt' import { captureException } from '@/utils/sentry' import { helperKey } from '@/utils/system' @@ -51,7 +51,9 @@ const ApiKeyInfoAlert = (props: { provider: ApiProvider }) => { return ( - Connect your API key to start generating cover letters + + Connect your API key to start generating cover letters + Get an API key from {provider.label} and paste it below. ) @@ -59,8 +61,11 @@ const ApiKeyInfoAlert = (props: { provider: ApiProvider }) => { return ( - Connect your OpenAI API key to start generating cover letters - You can now apply to jobs with custom-tailored AI generated cover letters - just add your OpenAI-compatible API key by clicking the button below. + + Connect your OpenAI API key to start generating cover letters + + You can now apply to jobs with custom-tailored AI generated cover letters + - just add your OpenAI-compatible API key by clicking the button below.
Create a key at{' '} @@ -242,9 +247,12 @@ const CoverLetter = () => { } const providerChanged = savedApiConfig.provider !== nextConfig.provider - const savedKey = await openAiApiKeyStorage.save(apiKey.trim()) + const savedSettings = await openAiApiSettingsStorage.save({ + apiKey: apiKey.trim(), + config: nextConfig, + }) + const savedKey = savedSettings.apiKey - await openAiApiConfigStorage.save(nextConfig) setApiKey(savedKey) setSavedApiKey(savedKey) setApiConfig(nextConfig) @@ -269,19 +277,18 @@ const CoverLetter = () => { useEffect(() => { const init = async () => { - const [prompt, text, apiKey, apiConfig] = await Promise.all([ + const [prompt, text, apiSettings] = await Promise.all([ promptStorage.get(), coverLetterStorage.get(), - openAiApiKeyStorage.get(), - openAiApiConfigStorage.get(), + openAiApiSettingsStorage.get(), ]) setPrompt(prompt) setText(text) - setApiKey(apiKey) - setSavedApiKey(apiKey) - setApiConfig(apiConfig) - setSavedApiConfig(apiConfig) + setApiKey(apiSettings.apiKey) + setSavedApiKey(apiSettings.apiKey) + setApiConfig(apiSettings.config) + setSavedApiConfig(apiSettings.config) setInitialized(true) } @@ -297,9 +304,7 @@ const CoverLetter = () => { {showKeyForm ? ( <> - {!hasApiKey && ( - - )} + {!hasApiKey && } { + if (!value || typeof value !== 'object') return false + + const settings = value as Partial + return ( + typeof settings.apiKey === 'string' && + !!settings.config && + typeof settings.config.model === 'string' && + providerIds.includes(settings.config.provider) + ) +} + +const get = async (): Promise => { + const settings = await storage.getItem(namespace, { + fallback: null, + }) + + if (isSettings(settings)) return settings + + // Production users only have the original OpenAI API key. Keep it and use + // the default OpenAI configuration until their next save. + return { + apiKey: await openAiApiKeyStorage.get(), + config: openAiApiConfigStorage.defaultConfig, + } +} + +const save = async (settings: OpenAiApiSettings) => { + await storage.setItem(namespace, settings) + return settings +} + +export default { get, save }