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
171 changes: 122 additions & 49 deletions api/openai.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<Uint8Array>,
onChunk: (chunk: string) => void
): Promise<void> => {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''

Expand All @@ -54,26 +84,69 @@ const generateCoverLetter = async (props: {
buffer = lines.pop() ?? ''

for (const line of lines) {
const trimmed = line.trim()
if (!trimmed.startsWith('data:')) continue
if (handleStreamLine(line, onChunk)) return
}
}

const data = trimmed.slice('data:'.length).trim()
if (data === '[DONE]') return
if (buffer && handleStreamLine(buffer, 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<void> => {
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 }
26 changes: 23 additions & 3 deletions entrypoints/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { browser, defineBackground } from '#imports'
import openAiApi from '@/api/openai'
import extension, { Cycles } from '@/utils/extension'
import stateStorage, { GlobalState } from '@/utils/globalState'
import openAiApiKeyStorage from '@/utils/openAiApiKey'
import openAiApiConfigStorage from '@/utils/openAiApiConfig'
import openAiApiSettingsStorage from '@/utils/openAiApiSettings'
import runtime, { GenerateCoverLetterResponse } from '@/utils/runtime'
import { captureException } from '@/utils/sentry'
import dailyReport from './dailyReport'
Expand Down Expand Up @@ -175,15 +176,29 @@ export default defineBackground({
}

try {
const apiKey = await openAiApiKeyStorage.get()
const { apiKey, config: savedApiConfig } =
await openAiApiSettingsStorage.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 }),
Expand All @@ -197,7 +212,12 @@ export default defineBackground({
}

captureException(error)
post({ type: 'error', error: 'GENERATION_FAILED' })
post({
type: 'error',
error: openAiApi.isGenerationError(error)
? error.code
: 'GENERATION_FAILED',
})
}
})
})
Expand Down
34 changes: 29 additions & 5 deletions entrypoints/content/ChatGptDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,34 @@ import {
} from '@mui/material'
import { useContext, useEffect, useRef, useState } from 'react'

const getGenerationErrorMessage = (
error: Extract<GenerateCoverLetterResponse, { type: 'error' }>['error']
) => {
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.'
}
}

const ChatGptDialog = (props: {
onClose: () => void
jobTitle: string
Expand Down Expand Up @@ -93,11 +121,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()
}
})
Expand Down
Loading