Skip to content

feat: multi-provider cover letters via the AI SDK, with AI Gateway model discovery - #44

Open
dorddis wants to merge 6 commits into
neeilya:masterfrom
dorddis:feat/ai-sdk-gateway
Open

feat: multi-provider cover letters via the AI SDK, with AI Gateway model discovery#44
dorddis wants to merge 6 commits into
neeilya:masterfrom
dorddis:feat/ai-sdk-gateway

Conversation

@dorddis

@dorddis dorddis commented Aug 7, 2026

Copy link
Copy Markdown

Addresses #36, using the AI SDK + gateway.getAvailableModels() approach you suggested on that thread.

Opening this alongside #38 rather than against it. @itzMRZ already put real work into the provider-preset route and that PR is ahead of this one in your queue. Take whichever fits better, or the useful parts of both. Happy to close this if you'd rather go the other way.

What it does

Two connection modes, rather than replacing the bring-your-own-key model:

  1. Direct provider (default, and what existing users keep). Pick a provider, paste that provider's own key. OpenAI, Google Gemini, Anthropic, Groq, DeepSeek, OpenRouter, plus a free-text model id for anything not in the curated list.
  2. AI Gateway. Paste a Vercel AI Gateway key and getAvailableModels() fills the model dropdown at runtime. One key, hundreds of models, and adding a model stops being a code change.

The reason for two modes instead of gateway-only: getAvailableModels() requires a gateway key, and a gateway-only rewrite would break the "uses your own API key, no backend" promise in the README and strand every existing user's stored key on update. Mode 1 keeps that promise, mode 2 answers the dynamic-discovery ask.

Notable bits

  • utils/aiCatalog.ts is the single source of truth for provider origins. wxt.config.ts imports from it, so the manifest cannot drift from the code when a provider is added.
  • manifest is now a function of manifestVersion. optional_host_permissions is MV3-only and WXT strips it on MV2 with no conversion, so the Firefox build emits optional_permissions instead. Both built manifests verified.
  • sync:__OPENAI_API_KEY is migrated into one atomic settings record and then removed, so a rotated or replaced key does not linger in synced storage. Removal is ordered after the new record is confirmed written.
  • Migrated users stay on gpt-4o-mini, so nobody's output silently changes model on update.
  • host_permissions for https://api.openai.com/ is left byte-identical on purpose. Broadening a declared host permission on update makes Chrome disable the extension pending re-approval, which would hit every current user. Worth a separate look, not this PR.
  • Streaming reads fullStream, not textStream. textStream filters to text-delta and a mid-stream provider failure is enqueued as an error part and then the stream closes normally, so a naive for await over textStream finishes clean with zero output and reports success on a failed generation. fullStream lets that surface as an error.

Testing

Verified:

  • npm run compile clean
  • npm run build and npm run build:firefox both succeed
  • Both generated manifests carry the right optional-permission key for their manifest version
  • Every hardcoded model id checked against its vendor's current deprecation page rather than its models page. Worth flagging: Groq's models page still lists llama-3.1-8b-instant and llama-3.3-70b-versatile as Production, but both shut down 2026-08-16 per the deprecations page.

Not verified, and I want to be straight about it: I have not made a live request through any of the seven surfaces. No generation, no getAvailableModels() call, no permission prompt, and no migration run against a real chrome.storage.sync. The migration is the one I would want a second pair of eyes on before merge, since it rewrites an existing credential.

Let me know which direction you want and I'll finish it properly, including live verification.

Summary by CodeRabbit

  • New Features

    • Added support for multiple AI providers and optional AI Gateway connections.
    • Added provider, model, API key, and permission settings with custom model support.
    • Added gateway model discovery and runtime model selection.
    • Cover-letter generation now streams responses and supports clearer provider-specific error messages.
  • Bug Fixes

    • Improved handling of invalid credentials, quota limits, rate limits, unavailable models, empty responses, and missing permissions.
    • Migrated existing OpenAI API key settings automatically.
  • Documentation

    • Updated setup and configuration guidance for the new AI provider options.

dorddis added 6 commits August 7, 2026 18:09
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.
…y 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.
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.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.
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".
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The extension replaces OpenAI-only cover-letter generation with direct provider and AI Gateway connections. It adds persisted settings, provider discovery, host permissions, streamed generation, structured failures, migration support, and updated configuration messaging.

Changes

AI provider migration

Layer / File(s) Summary
Provider contracts and persisted settings
utils/aiCatalog.ts, utils/aiSettings.ts, utils/runtime.ts, wxt.config.ts
Provider metadata, settings validation, legacy-key migration, runtime error codes, and shared host permissions are added.
Provider API and error classification
api/ai.ts, package.json
The AI API supports direct providers and the gateway, streams text, discovers gateway models, and classifies redacted provider failures.
Configuration and background integration
entrypoints/options/pages/CoverLetter.tsx, entrypoints/background/index.ts
The options page saves provider or gateway settings and requests permissions. The background worker validates settings and forwards generation failures.
Client errors and project documentation
entrypoints/content/ChatGptDialog.tsx, utils/alerts.tsx, CLAUDE.md
The client displays messages for structured error codes. Alerts and documentation use provider-neutral terminology and describe the new modules and settings.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant OptionsPage
  participant BackgroundWorker
  participant aiApi
  participant Provider
  User->>OptionsPage: configure provider, model, and key
  OptionsPage->>BackgroundWorker: request model or permission
  BackgroundWorker->>aiApi: fetchGatewayModels or generateCoverLetter
  aiApi->>Provider: call configured AI service
  Provider-->>aiApi: models or streamed text
  aiApi-->>BackgroundWorker: result or classified failure
  BackgroundWorker-->>OptionsPage: configuration or generation response
Loading

Possibly related PRs

Suggested reviewers: neeilya

Poem

A rabbit tuned providers beneath the moon,
Gateway models sorted bright and soon.
Keys migrated, permissions grew,
Streams carried words in a gentle queue.
Errors now wear labels clear—
“Hop,” said the rabbit, “AI is here!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: multi-provider cover-letter generation through the AI SDK and AI Gateway model discovery.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
utils/aiSettings.ts (1)

97-115: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add explicit return types to the exported helpers.

save, addEventListener, getApiKey, getModel, and getHostPermission are exported through the default object. The coding guidelines require explicit return types on exported functions.

♻️ Proposed change
-const save = async (value: AiSettings) => {
+const save = async (value: AiSettings): Promise<AiSettings> => {
   await storage.setItem<AiSettings>(namespace, value)
   return value
 }
 
 const addEventListener = (
   callback: (newValue: AiSettings | null, oldValue: AiSettings | null) => void
-) => storage.watch<AiSettings>(namespace, callback)
+): (() => void) => storage.watch<AiSettings>(namespace, callback)
 
-const getApiKey = (settings: AiSettings) =>
+const getApiKey = (settings: AiSettings): string =>
   settings.mode === 'gateway' ? settings.gatewayApiKey : settings.apiKey
 
-const getModel = (settings: AiSettings) =>
+const getModel = (settings: AiSettings): string =>
   settings.mode === 'gateway' ? settings.gatewayModel : settings.model
 
-const getHostPermission = (settings: AiSettings) =>
+const getHostPermission = (settings: AiSettings): string =>
   settings.mode === 'gateway'
     ? gateway.hostPermission
     : (findProvider(settings.provider) ?? defaultProvider).hostPermission

As per coding guidelines: "explicit return types on exported functions".

🤖 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 `@utils/aiSettings.ts` around lines 97 - 115, Add explicit return type
annotations to the exported helpers save, addEventListener, getApiKey, getModel,
and getHostPermission in the default export. Preserve their existing behavior
and infer each annotation from the current returned value or callback
registration API.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Inline comments:
In `@entrypoints/background/index.ts`:
- Around line 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.

In `@entrypoints/options/pages/CoverLetter.tsx`:
- Around line 261-291: Update onSaveConnection to call
browser.permissions.contains({ origins: [hostPermission] }) before requesting
permission. If the origin is already granted, proceed directly to
persistConnection; otherwise request it and preserve the existing
denied-permission and error handling behavior.

---

Nitpick comments:
In `@utils/aiSettings.ts`:
- Around line 97-115: Add explicit return type annotations to the exported
helpers save, addEventListener, getApiKey, getModel, and getHostPermission in
the default export. Preserve their existing behavior and infer each annotation
from the current returned value or callback registration API.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bbca92ae-aeb0-43db-a825-5ae66d94b19b

📥 Commits

Reviewing files that changed from the base of the PR and between 4be0325 and c234c9e.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (13)
  • CLAUDE.md
  • api/ai.ts
  • api/openai.ts
  • entrypoints/background/index.ts
  • entrypoints/content/ChatGptDialog.tsx
  • entrypoints/options/pages/CoverLetter.tsx
  • package.json
  • utils/aiCatalog.ts
  • utils/aiSettings.ts
  • utils/alerts.tsx
  • utils/openAiApiKey.ts
  • utils/runtime.ts
  • wxt.config.ts
💤 Files with no reviewable changes (2)
  • api/openai.ts
  • utils/openAiApiKey.ts

Comment on lines +197 to +206
// 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
}

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.

Comment on lines +261 to 291
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))
}

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

🧩 Analysis chain

🌐 Web query:

Does Firefox browser.permissions.request() reject origins that are listed in manifest permissions but not in optional_permissions?

💡 Result:

Yes, the browser.permissions.request method in Firefox will reject requests for origins that are not declared in the optional_permissions (or optional_host_permissions in Manifest V3) key of your manifest.json file [1][2][3]. The permissions.request API is specifically designed to allow extensions to request additional permissions at runtime that were explicitly marked as optional during development [2][4]. Attempting to use this API to request permissions that are not listed in the optional set will result in the request being denied [1][3]. Key points regarding this behavior: 1. Requirement: Any origin or API permission you intend to request at runtime must be included in the optional_permissions or optional_host_permissions manifest keys [1][2]. 2. Validation: The browser validates the requested permissions against the manifest's optional definitions [1][3]. If an origin is not part of the allowed optional set, the Promise returned by browser.permissions.request will resolve with false (or potentially throw an error depending on the invalid input) [1][2][3]. 3. User Action: Additionally, remember that browser.permissions.request must be called directly within the handler of a user-initiated action (such as a click event) [1][5][6]. If the call is delayed by asynchronous operations—such as being inside a Promise chain—Firefox will reject the call with an error stating it must be called from a user input handler [5][7][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate relevant files"
fd -a 'CoverLetter\.tsx|manifest\.(json|json5|jsonc)$' . | sed 's#^\./##' | sort

echo
echo "Inspect CoverLetter relevant state/handler (if present)"
cover=$(fd 'CoverLetter\.tsx' . | head -n1 || true)
if [ -n "${cover:-}" ]; then
  wc -l "$cover"
  sed -n '1,120p' "$cover" | cat -n
  sed -n '230,310p' "$cover" | cat -n
fi

echo
echo "Search for hostPermission, optional permissions, OpenAI host in manifest and code"
rg -n "hostPermission|optional_permissions|optional_host_permissions|api\.openai\.com|permissions\.request|permissions\.contains" -S .

Repository: neeilya/upwork-toolkit

Length of output: 10462


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Read package/config snippets"
fd 'package\.json|wxt\.config\.ts|manifest\.json' . -x sh -c 'echo "===== $1 ====="; sed -n "1,220p" "$1" | cat -n' sh {}

echo
echo "Read optionalHostPermissions declarations"
rg -n "optionalHostPermissions|optional_host_permissions|optional_permissions|staticHostPermissions|findProvider\\(|defaultProvider|" wxt.config.ts utils/aiCatalog.ts -S

Repository: neeilya/upwork-toolkit

Length of output: 16127


🌐 Web query:

MDN browser.permissions.request Firefox manifest permissions not optional_permissions resolves false or rejects

💡 Result:

In Firefox, the browser.permissions.request API is strictly designed to request permissions that have been pre-declared in your extension's manifest.json file under the optional_permissions (or optional_host_permissions) key [1][2][3][4]. Regarding the behavior of the returned promise: 1. Resolutions and Rejections: The promise returned by browser.permissions.request resolves with true if the user grants the requested permissions and false if the user denies them [1][5]. It generally does not reject simply because a user denied the request; rather, the promise resolves to false [1]. 2. When the Promise Might Reject or Fail: The promise may reject or fail to behave as expected under specific technical conditions, often related to how the request is initiated: - User Gesture Requirement: The API must be called from within a user input handler (e.g., a click event) [6][5][7]. If called outside of a direct user interaction—for example, in a promise chain that has lost the original event context or from a background page without a direct trigger—the call will often throw an error (e.g., "permissions.request may only be called from a user input handler") or fail silently [6][8]. - Context/Window Issues: If the browser cannot determine the active window or tab to anchor the permission notification (e.g., calling it from certain sidebars or non-browser contexts), the request may reject or fail to trigger the UI prompt [9][8]. - Undeclared Permissions: Attempting to request a permission that is not listed in your manifest's optional_permissions will cause the call to reject or fail, as runtime requests are restricted to the pre-declared optional set [7]. In summary, the promise resolves to false when the user explicitly denies the request [1]. If the promise rejects or throws an error, it is typically due to a violation of the user-gesture requirement or an inability of the browser to anchor the permission UI to an active window [6][8].

Citations:


Skip the Firefox permission request for already-granted static OpenAI origins.

https://api.openai.com/ is in host_permissions, but Firefox only requests origins from optional_host_permissions. Calling browser.permissions.request({ origins: [hostPermission] }) for this default provider can reject and set permissionError, so the connection is never saved. Use browser.permissions.contains() first, and only request if the origin is missing.

🤖 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/options/pages/CoverLetter.tsx` around lines 261 - 291, Update
onSaveConnection to call browser.permissions.contains({ origins:
[hostPermission] }) before requesting permission. If the origin is already
granted, proceed directly to persistConnection; otherwise request it and
preserve the existing denied-permission and error handling behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant