feat: multi-provider cover letters via the AI SDK, with AI Gateway model discovery - #44
feat: multi-provider cover letters via the AI SDK, with AI Gateway model discovery#44dorddis wants to merge 6 commits into
Conversation
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".
📝 WalkthroughWalkthroughThe 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. ChangesAI provider migration
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
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
utils/aiSettings.ts (1)
97-115: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd explicit return types to the exported helpers.
save,addEventListener,getApiKey,getModel, andgetHostPermissionare 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).hostPermissionAs 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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (13)
CLAUDE.mdapi/ai.tsapi/openai.tsentrypoints/background/index.tsentrypoints/content/ChatGptDialog.tsxentrypoints/options/pages/CoverLetter.tsxpackage.jsonutils/aiCatalog.tsutils/aiSettings.tsutils/alerts.tsxutils/openAiApiKey.tsutils/runtime.tswxt.config.ts
💤 Files with no reviewable changes (2)
- api/openai.ts
- utils/openAiApiKey.ts
| // 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 | ||
| } |
There was a problem hiding this comment.
🩺 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.
| // 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.
| 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)) | ||
| } |
There was a problem hiding this comment.
🩺 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:
- 1: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions/request
- 2: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions
- 3: https://www.typeerror.org/docs/web_extensions/api/permissions/request
- 4: https://stackoverflow.com/questions/71913706/is-it-possible-for-a-webextension-addon-to-request-permission-for-a-specific-web
- 5: https://stackoverflow.com/questions/47723297/firefox-extension-api-permissions-request-may-only-be-called-from-a-user-input
- 6: https://bugzilla.mozilla.org/show_bug.cgi?id=1369577
- 7: https://bugzilla.mozilla.org/show_bug.cgi?id=1398833
- 8: https://stackoverflow.com/questions/49894618/request-permissions-using-the-permissions-api-from-a-select-onchange-handler-i
🏁 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 -SRepository: 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:
- 1: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions/request
- 2: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions
- 3: https://stackoverflow.com/questions/71913706/is-it-possible-for-a-webextension-addon-to-request-permission-for-a-specific-web
- 4: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/optional_permissions
- 5: https://www.typeerror.org/docs/web_extensions/api/permissions/request
- 6: https://stackoverflow.com/questions/47723297/firefox-extension-api-permissions-request-may-only-be-called-from-a-user-input
- 7: https://www.mv3-extension.com/manifest-v3-architecture-extension-lifecycle/store-submission-permissions-compliance/requesting-optional-permissions-at-runtime/
- 8: https://bugzilla.mozilla.org/show_bug.cgi?id=1433292
- 9: https://bugzilla.mozilla.org/show_bug.cgi?id=1613796
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.
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:
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.tsis the single source of truth for provider origins.wxt.config.tsimports from it, so the manifest cannot drift from the code when a provider is added.manifestis now a function ofmanifestVersion.optional_host_permissionsis MV3-only and WXT strips it on MV2 with no conversion, so the Firefox build emitsoptional_permissionsinstead. Both built manifests verified.sync:__OPENAI_API_KEYis 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.gpt-4o-mini, so nobody's output silently changes model on update.host_permissionsforhttps://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.fullStream, nottextStream.textStreamfilters totext-deltaand a mid-stream provider failure is enqueued as anerrorpart and then the stream closes normally, so a naivefor awaitovertextStreamfinishes clean with zero output and reports success on a failed generation.fullStreamlets that surface as an error.Testing
Verified:
npm run compilecleannpm run buildandnpm run build:firefoxboth succeedllama-3.1-8b-instantandllama-3.3-70b-versatileas 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 realchrome.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
Bug Fixes
Documentation