Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)

---

Expand Down Expand Up @@ -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
Expand All @@ -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` |

Expand All @@ -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/`

Expand Down
155 changes: 155 additions & 0 deletions api/ai.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<ModelPreset[]> => {
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 }
79 changes: 0 additions & 79 deletions api/openai.ts

This file was deleted.

51 changes: 40 additions & 11 deletions entrypoints/background/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
}
Comment on lines +197 to +206

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard the permission check so a rejection still answers the port.

browser.permissions.contains() runs outside a try/catch inside an async listener. If it rejects, the handler throws, no message is posted, and the port stays connected. The content dialog then keeps showing the streaming state until the user cancels.

🛡️ Proposed fix
-        const granted = await browser.permissions.contains({
-          origins: [aiSettingsStorage.getHostPermission(settings)],
-        })
+        let granted = false
+
+        try {
+          granted = await browser.permissions.contains({
+            origins: [aiSettingsStorage.getHostPermission(settings)],
+          })
+        } catch (error) {
+          captureException(error)
+        }
 
         if (!granted) {
           post({ type: 'error', error: 'MISSING_HOST_PERMISSION' })
           return
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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
}
// The settings page requested this origin, but the user can revoke it
// at any time from the browser's own extension controls.
let granted = false
try {
granted = await browser.permissions.contains({
origins: [aiSettingsStorage.getHostPermission(settings)],
})
} catch (error) {
captureException(error)
}
if (!granted) {
post({ type: 'error', error: 'MISSING_HOST_PERMISSION' })
return
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@entrypoints/background/index.ts` around lines 197 - 206, Wrap the
browser.permissions.contains call in the async listener around the
permission-check flow with try/catch so rejections are handled. On failure, post
an appropriate error response through post and return, ensuring the port
receives a reply instead of leaving the content dialog streaming; preserve the
existing MISSING_HOST_PERMISSION response when the check resolves false.


await openAiApi.generateCoverLetter({
apiKey,
try {
await aiApi.generateCoverLetter({
settings,
prompt: message.prompt,
signal: abortController.signal,
onChunk: (content) => post({ type: 'chunk', content }),
Expand All @@ -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 })
}
})
})
Expand Down
Loading