Skip to content

Saved AI providers (desktop + mobile) + shared model picker - #95

Merged
ddutchie merged 3 commits into
mainfrom
ddutchie/saveproviders
Jul 25, 2026
Merged

ddutchie merged 3 commits into
mainfrom
ddutchie/saveproviders

Conversation

@ddutchie

@ddutchie ddutchie commented Jul 25, 2026 •

Copy link
Copy Markdown
Owner

What does this PR do?

Adds saveable, switchable AI providers on desktop and mobile: users can store several named OpenAI-compatible connections (name, base URL, API key, model) and switch the active one instead of retyping their endpoint. On desktop the saved-provider list is shared between AI Chat and the Coding Agent (each keeps its own active selection), the chat model dropdown is extracted into a shared ModelPicker reused across the settings pages, and model lists are fetched/cached per endpoint (no more hardcoded fallback model names). Also fixes local endpoints failing on 127.0.0.1 and renames the provider toggles to OpenAI / On-Device.

Type of change

  • Bug fix
  • New feature
  • Refactor / code quality
  • Docs / changelog
  • Tests

Screenshots / recording

Checklist

  • npm run type-check:all passes
  • npm run lint passes
  • npm test passes (runs npm run compile first so electron/bundle-guard.test.ts actually executes — npm run test:bundle to run just that)
  • npm run test:e2e passes (run before merging UI changes or cutting a release)
  • No hardcoded colours — CSS variables only (var(--accent), var(--text-primary), etc.)
  • No text-[Npx] pixel font classes — rem equivalents only (text-[0.714rem], text-xs, etc.)
  • New IPC handlers wrapped in handle() and return IpcResult<T> — n/a (no new IPC handlers)
  • New DB migrations appended (not edited) in schema.ts — n/a (config persists to localStorage + the Electron config cache, not SQLite)
  • New SQL goes in electron/db/queries.ts — n/a (no new SQL)
  • New dependencies or devDependencies — n/a (none added)
  • New MCP tools — n/a (none)
  • --external:<pkg> flag changes — n/a (none)

Notes for reviewer

Design decisions (confirmed with product along the way):

  • Shared provider list, per-config active. The provider list is a single source of truth on aiConfig.savedProviders; the Coding Agent reads the same list but keeps its own activeProviderId on agentConfig, so it can run on a different model than chat. add/update/delete reconcile both configs; the Electron config cache persists the list under aiConfig and only the active id under agentConfig.
  • On-Device (Llama) toggle kept. It stays separate from saved providers, which apply only to the OpenAI/cloud-or-local API path. The two toggles were renamed to OpenAI and On-Device.
  • No hardcoded fallback models. Removed LOCAL_FALLBACK_MODELS / CLOUD_FALLBACK_MODELS. useEndpointConfig now caches fetched models per endpoint (base URL + whether a key is present) in localStorage with a 7-day TTL, and ensureModels() hydrates from cache or fetches once. Auto-fetch effects are keyed on provider / active-provider id (not the URL/key text) to avoid fetching on every keystroke — Refresh re-queries after edits.
  • 127.0.0.1 CSP fix. The renderer's CSP only allowed http://localhost:*; since CSP matches origins literally, http://127.0.0.1:* was blocked (model fetch/test failed while chat, which routes over IPC, worked — hence the confusing behaviour). Both loopback spellings are now whitelisted.
  • Mobile stores the provider list in the device-global meta DB and each provider's API key in the keychain (never synced), with a one-time migration of the previous flat OpenAI config into a default provider. Backward-compatible: all existing getOpenAI*/setOpenAI* callers keep working via the active provider.

ModelPicker + TruncatedModel were extracted from ChatQuickSettings into src/components/ui/model-picker.tsx and reused in AI settings, Coding Agent settings, and the provider form (with a size variant for the settings-row density).

Summary by CodeRabbit

  • New Features

    • Save, name, edit, delete, and switch between multiple OpenAI-compatible AI providers.
    • Reuse saved providers independently for AI chat and Coding Agent settings.
    • Automatically populate connection fields when switching providers.
    • Browse endpoint-provided models, refresh model lists, or enter a custom model.
    • Support 127.0.0.1 local endpoints in addition to localhost.
  • Bug Fixes

    • Fixed a crash caused by an infinite render loop when opening AI settings.
    • Standardized model selection across AI settings, Coding Agent settings, and provider forms.

ddutchie added 2 commits July 25, 2026 09:25
Desktop:
- Save named OpenAI-compatible providers (name/baseUrl/apiKey/model) and
  switch the active one; shared with the Coding Agent (one list, per-config
  active selection). Persisted to localStorage + Electron cache.
- Extract the chat model dropdown into a shared ModelPicker reused in AI
  settings, Agent settings, and the provider form.
- Drop hardcoded fallback model lists; cache fetched models per endpoint
  and auto-refresh when uncached.
- Allow 127.0.0.1 loopback endpoints in the CSP (not just localhost).

Mobile:
- Saved OpenAI providers (meta DB list + per-provider keychain keys) with a
  one-time migration of the existing flat config; chip switcher in settings.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ddutchie, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ab3316de-af4c-4c8e-bd3c-c3f4060636c2

📥 Commits

Reviewing files that changed from the base of the PR and between e6747d0 and 9d1d696.

📒 Files selected for processing (5)
  • electron/lib/protocol.ts
  • mobile/src/chat/ai-config.ts
  • mobile/src/components/AiSettingsForm.tsx
  • mobile/src/components/ai-settings/ProviderList.tsx
  • src/store/slices/ui.ts
📝 Walkthrough

Walkthrough

This change adds saved OpenAI-compatible providers with separate AI and Coding Agent selections, migrates mobile configuration, introduces shared provider and model-picker controls, caches endpoint model lists, removes preset pills, and allows Electron loopback access through both localhost and 127.0.0.1.

Changes

Provider and model settings

Layer / File(s) Summary
Shared provider state and persistence
electron/lib/config-cache.ts, src/store/slices/ui.ts
AI and agent configurations persist shared saved providers with independent active selections and reconciliation actions.
Mobile provider migration and management
mobile/src/chat/ai-config.ts, mobile/src/components/AiSettingsForm.tsx, mobile/src/components/ai-settings/*
Legacy mobile endpoint settings migrate into named providers, provider keys use secure storage, and settings support switching, adding, editing, and deleting providers.
Web provider controls and model catalog
src/components/settings/*, src/components/ui/model-picker.tsx, src/components/chat/chat-panel/ChatQuickSettings.tsx
Provider management is added to AI and Coding Agent settings; model lists are normalized, cached per endpoint, refreshed when needed, and displayed through a shared picker with custom-model entry.
Loopback endpoint and release notes
electron/lib/protocol.ts, changelogs/*
CSP allows localhost and 127.0.0.1 endpoints, while desktop and mobile changelogs describe the provider and model-selection updates.

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

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ProviderManager
  participant UISlice
  participant AISettings
  participant ModelPicker
  User->>ProviderManager: Select saved provider
  ProviderManager->>UISlice: Update active provider
  UISlice-->>AISettings: Mirror base URL, key, and model
  AISettings->>ModelPicker: Ensure endpoint models
  ModelPicker-->>User: Display fetched or cached models
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: saved AI providers across desktop and mobile plus the shared model picker.
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
  • Commit unit tests in branch ddutchie/saveproviders

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: 9

🧹 Nitpick comments (2)
mobile/src/chat/ai-config.ts (1)

186-190: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Unguarded keychain writes reject into UI handlers.

Lines 188 and 216 call SecureStore.setItemAsync bare, while every delete path uses .catch(() => {}). Callers (addProvider, save in mobile/src/components/AiSettingsForm.tsx) don't wrap these, so a keychain failure becomes an unhandled rejection with no user feedback. Either let the error surface deliberately (documented, and handled in the form) or keep it best-effort and return a success flag so the UI can report "key not saved".

🤖 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 `@mobile/src/chat/ai-config.ts` around lines 186 - 190, Update the provider
persistence flow around the SecureStore.setItemAsync calls in addProvider/save
to handle keychain write failures explicitly: either propagate a documented
error and handle it in AiSettingsForm, or make writes best-effort while
returning a success indicator the UI can use to report that the key was not
saved. Apply the same behavior to both write paths, including the path near line
216, while preserving existing delete handling.
src/store/slices/ui.ts (1)

72-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate setAIConfig/setAgentConfig onto persistAi/persistAgent.

persistAi/persistAgent duplicate logic already inlined in setAIConfig (Lines 350-353) and setAgentConfig (Lines 433-436, unchanged). Beyond duplication, the two paths diverge: persistAgent casts to Record<string, unknown> before calling saveAgentSettings (Line 84), but setAgentConfig's inline call does not (Line 435). Worth confirming via npm run type-check:all whether one of these is actually wrong, then routing both setters through the shared helpers to remove the duplication and the discrepancy.

♻️ Proposed refactor
   setAIConfig(patch) {
     set((s) => {
       const next = { ...s.aiConfig, ...patch };
-      storage.set(AI_CONFIG_KEY, next);
-      if (typeof window !== "undefined" && window.electron && window.electron.saveAiSettings) {
-        window.electron.saveAiSettings(next as unknown as Record<string, unknown>).catch(() => {});
-      }
+      persistAi(next);
       return { aiConfig: next };
     });
   },
   setAgentConfig(patch) {
     set((s) => {
       const next = { ...s.agentConfig, ...patch };
-      storage.set(AGENT_CONFIG_KEY, next);
-      if (typeof window !== "undefined" && window.electron && window.electron.saveAgentSettings) {
-        window.electron.saveAgentSettings(next).catch(() => {});
-      }
+      persistAgent(next);
       return { agentConfig: next };
     });
   },

As per coding guidelines, **/*.ts: "Run npm run type-check:all after changes" — please verify the cast discrepancy noted above with that command.

🤖 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 `@src/store/slices/ui.ts` around lines 72 - 86, Update setAIConfig and
setAgentConfig to delegate persistence entirely to persistAi and persistAgent,
removing their duplicated storage and Electron save logic while preserving
existing state updates. Run npm run type-check:all to verify the
saveAgentSettings cast discrepancy and retain the type-correct helper
invocation.

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 `@electron/lib/config-cache.ts`:
- Around line 106-111: Update saved-provider persistence across
electron/lib/config-cache.ts lines 106-111 and src/store/slices/ui.ts lines
72-86 so provider apiKey values never enter localStorage or the plain JSON
ai-settings-cache.json. Reuse the existing OS-keychain-backed secret store used
for MCP/service secrets, storing only provider references in the renderer/cache
and resolving raw keys on the Electron side while preserving savedProviders and
activeProviderId behavior.

In `@electron/lib/protocol.ts`:
- Around line 130-131: Update the Content Security Policy directives in the
protocol configuration so loopback expansion remains limited to connect-src:
remove localhost/127.0.0.1 HTTP origins from default-src and script-src, while
preserving any existing entries required for actual renderer script sources.

In `@mobile/src/chat/ai-config.ts`:
- Around line 93-96: Update the SavedProvider type guard in the parsed provider
filter to require that p.model is a string, alongside id, name, and baseUrl,
before accepting the stored row. Preserve the existing filtering behavior for
all other fields.
- Around line 361-370: Update getOpenAIApiKey so getLegacyApiKey is used only
when no providers exist, preserving provider-specific lookup otherwise; do not
fall back to the legacy key when an active provider has no key, preventing
unrelated providers and cleared keys from inheriting it. Also remove
SECURE_KEY_APIKEY after successful migration so the legacy copy does not
persist.
- Around line 116-136: Update migrateToProvidersOnce so the
PROVIDERS_MIGRATION_FLAG read is inside its try/catch, allowing getSetting or
migration-source failures to be swallowed and retried later. Add a module-level
in-flight Promise guard so concurrent callers share one migration execution;
clear the guard when that execution settles, while preserving the existing
readProviders check, provider creation, legacy-key storage, and flag-setting
behavior.

In `@mobile/src/components/ai-settings/ProviderList.tsx`:
- Around line 49-53: Update the delete Pressable in ProviderList to expose an
accessibilityRole and descriptive accessibilityLabel, and require an Alert.alert
confirmation before invoking onDelete(p.id). Preserve the existing
selected-and-multiple-providers visibility condition and only delete after the
user confirms.

In `@mobile/src/components/AiSettingsForm.tsx`:
- Around line 165-188: Update switchProvider and addProvider to prevent
concurrent invocations with a shared in-flight guard or equivalent disabled
state, ensuring rapid interactions cannot persist edits to the wrong provider.
In switchProvider, after persisting the outgoing provider via setOpenAIEndpoint
and setOpenAIApiKey, refresh providers with listSavedProviders before selecting
or loading another provider so subsequent switches use current values.
- Around line 137-161: Update the loading effect in AiSettingsForm so failures
from listSavedProviders or subsequent provider/key reads are caught and always
call setLoading(false), while preserving the existing successful-load state
updates and cancellation guard. Ensure rejected loads cannot leave the form
stuck on the ActivityIndicator.

In `@src/store/slices/ui.ts`:
- Around line 60-70: The reconcileConfig function should return configs with an
empty activeProviderId unchanged, preventing unselected surfaces from inheriting
list[0] or another surface’s provider. Retain reconciliation for configs with a
selected provider, including fallback when that previously selected provider was
deleted, and apply the equivalent guard in mobile/src/chat/ai-config.ts if its
implementation matches this behavior.

---

Nitpick comments:
In `@mobile/src/chat/ai-config.ts`:
- Around line 186-190: Update the provider persistence flow around the
SecureStore.setItemAsync calls in addProvider/save to handle keychain write
failures explicitly: either propagate a documented error and handle it in
AiSettingsForm, or make writes best-effort while returning a success indicator
the UI can use to report that the key was not saved. Apply the same behavior to
both write paths, including the path near line 216, while preserving existing
delete handling.

In `@src/store/slices/ui.ts`:
- Around line 72-86: Update setAIConfig and setAgentConfig to delegate
persistence entirely to persistAi and persistAgent, removing their duplicated
storage and Electron save logic while preserving existing state updates. Run npm
run type-check:all to verify the saveAgentSettings cast discrepancy and retain
the type-correct helper invocation.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a9b415fb-70e6-42c5-bdbd-368f046b7d8e

📥 Commits

Reviewing files that changed from the base of the PR and between a5a7bb4 and e6747d0.

📒 Files selected for processing (15)
  • changelogs/v2.5.9.md
  • electron/lib/config-cache.ts
  • electron/lib/protocol.ts
  • mobile/changelogs/v0.1.3.md
  • mobile/src/chat/ai-config.ts
  • mobile/src/components/AiSettingsForm.tsx
  • mobile/src/components/ai-settings/ProviderList.tsx
  • mobile/src/components/ai-settings/styles.ts
  • src/components/chat/chat-panel/ChatQuickSettings.tsx
  • src/components/settings/AISettings.tsx
  • src/components/settings/AgentSettings.tsx
  • src/components/settings/ProviderManager.tsx
  • src/components/settings/endpoint-components.tsx
  • src/components/ui/model-picker.tsx
  • src/store/slices/ui.ts

Comment on lines +106 to +111
// Saved-provider switcher state (array + active id). `configRecord` is
// typed for scalars, so read the raw config object for these.
savedProviders: Array.isArray((config as { savedProviders?: unknown }).savedProviders)
? (config as { savedProviders: NonNullable<CachedConfig["aiConfig"]>["savedProviders"] }).savedProviders
: current.aiConfig?.savedProviders,
activeProviderId: typeof configRecord.activeProviderId === "string" ? configRecord.activeProviderId : current.aiConfig?.activeProviderId,

@coderabbitai coderabbitai Bot Jul 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo root files relevant =="
git ls-files | rg '(^|/)(config-cache\.ts|ui\.ts)$|(^|/)package\.json$|node_modules/next/dist/docs/' || true

echo
echo "== electron/lib/config-cache.ts outline =="
ast-grep outline electron/lib/config-cache.ts || true

echo
echo "== electron/lib/config-cache.ts sections =="
wc -l electron/lib/config-cache.ts
sed -n '1,180p' electron/lib/config-cache.ts

echo
echo "== src/store/slices/ui.ts sections =="
wc -l src/store/slices/ui.ts
sed -n '1,130p' src/store/slices/ui.ts

echo
echo "== search savedProviders definitions/usages =="
rg -n "savedProviders|activeProviderId|AI_CONFIG_KEY|saveAiSettings|safeStorage|encryptString|decryptString" -S .

Repository: ddutchie/cairn

Length of output: 23053


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== storage and encryption implementation candidates =="
fd -a 'storage\.(ts|tsx|js)$|secure-store\.ts$|settings-handlers\.ts$' . | sed 's#^\./##'

echo
echo "== src/lib/storage.ts =="
wc -l src/lib/storage.ts
sed -n '1,220p' src/lib/storage.ts

echo
echo "== electron/lib/secure-store.ts relevant =="
wc -l electron/lib/secure-store.ts
sed -n '1,260p' electron/lib/secure-store.ts

echo
echo "== electron/ipc/settings-handlers.ts =="
wc -l electron/ipc/settings-handlers.ts
sed -n '1,120p' electron/ipc/settings-handlers.ts

echo
echo "== package metadata next/electron versions =="
node - <<'JS'
const fs = require('fs');
const p = JSON.parse(fs.readFileSync('package.json','utf8'));
console.log({dependencies:p.dependencies?.next, devDependencies: p.devDependencies?.electron, engines:p.engines});
JS

Repository: ddutchie/cairn

Length of output: 14080


Store saved-provider secrets through the encrypted store. saavedProviders carries apiKey, and persistAi() writes the full object to localStorage plus calls saveAiSettings(), which persists it to ai-settings-cache.json via plain JSON. Use the same OS-keychain-backed secret store used for MCP/service secrets, or store only provider references in localStorage/cache and keep the raw keys on the Electron side encrypted.

📍 Affects 2 files
  • electron/lib/config-cache.ts#L106-L111 (this comment)
  • src/store/slices/ui.ts#L72-L86
🤖 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 `@electron/lib/config-cache.ts` around lines 106 - 111, Update saved-provider
persistence across electron/lib/config-cache.ts lines 106-111 and
src/store/slices/ui.ts lines 72-86 so provider apiKey values never enter
localStorage or the plain JSON ai-settings-cache.json. Reuse the existing
OS-keychain-backed secret store used for MCP/service secrets, storing only
provider references in the renderer/cache and resolving raw keys on the Electron
side while preserving savedProviders and activeProviderId behavior.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Will follow up with quick PR

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@ddutchie, sounds good. The finding will remain open until the follow-up PR moves saved-provider API keys out of renderer/plain-JSON persistence and into the encrypted secret store.

Would you like me to open a GitHub issue to track it?

Comment thread electron/lib/protocol.ts Outdated
Comment thread mobile/src/chat/ai-config.ts
Comment thread mobile/src/chat/ai-config.ts
Comment thread mobile/src/chat/ai-config.ts
Comment thread mobile/src/components/ai-settings/ProviderList.tsx
Comment thread mobile/src/components/AiSettingsForm.tsx
Comment thread mobile/src/components/AiSettingsForm.tsx
Comment thread src/store/slices/ui.ts
- reconcileConfig: don't auto-select list[0] for an unselected surface
- setAIConfig/setAgentConfig delegate to persistAi/persistAgent (dedupe)
- CSP: scope 127.0.0.1 loopback to connect-src only (drop from default/script-src)
- mobile: require model string in provider type guard
- mobile: getOpenAIApiKey no longer inherits legacy key when a provider is active;
  migration deletes the legacy keychain key
- mobile: migrateToProvidersOnce reads flag inside try + shared in-flight guard
- mobile: ProviderList delete confirmation + a11y labels
- mobile: switchProvider/addProvider concurrency guard; refresh list before switch
- mobile: loading effect always clears the spinner on failure
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