Menu β–Ύ β–΄

#444 feat(settings): add the centralised AI-provider choice

closed
nobody
None
2026-07-16
2026-07-14
Anonymous
No

Originally created by: adityaharishch

First of six PRs adding a centralised, user-selectable AI provider: the user picks which AI runs their pipeline (their own Claude / Codex / Cursor / Copilot CLI subscription, or the on-device MLX model) once during setup, switchable afterwards from Settings.

This PR is the foundation only - nothing reads the new fields yet. The resolver, the backends, and the wizard step follow in later PRs.

What lands

LlmProvider { Claude, Codex, Cursor, Copilot, Local } in meridian-core/src/llm_provider.rs, modelled on the existing canonical_task::Provider. Wire forms deliberately match the coding-agent summariser's Source::as_str(), so the two map without a translation table. Default is Local - on-device is the product's pitch, and it is the only backend guaranteed to be present.

Three settings fields: llm_provider, llm_provider_model, llm_local_chat_model_ready. #[serde(default)] was already on the struct, so every existing settings.json upgrades with no migration.

Validation in update_settings - the only writer. An unrecognised provider would otherwise save cleanly and then quietly resolve back to on-device, so the user would pick Claude and keep getting the local model.

ui/lib/llm-providers.ts - the single source of truth for the UI, deliberately mirroring integrations.ts's TRACKERS. One list, shared by the wizard and Settings later.

The one design decision worth reviewing

llm_provider is stored as a String, not the enum. load_runtime_settings() (settings.rs:185) falls back to RuntimeSettings::default() on any deserialise error, so a single unparseable field silently resets every setting the daemon reads. Had this been typed as the enum, a value written by a newer build would cost an older daemon not just the provider but the user's work hours, poll interval and log level.

It is parsed with LlmProvider::from_wire, which returns None for anything unknown - the caller falls back to the default. An unknown provider costs you the provider, and nothing else. an_unknown_llm_provider_does_not_reset_every_other_setting pins this, and fails loudly if anyone retypes the field.

Two dead fields deleted

  • llm_prefer_local - declared in Rust, defaulted, mirrored in TS, and toggleable with a Save button in Advanced settings... and read by nobody. grep across src/ services/ tray/ meridian-core/ finds only the declaration and the writer. Users can flip this switch today and it does nothing. llm_provider == "local" is what it was trying to say, expressed once and actually honoured.
  • llm_model_preference - TS-only, with a comment claiming it "mirrors RuntimeSettings.llm_model_preference". That Rust field never existed.

Tests

  • LlmProvider round-trip: as_str() <-> from_wire() <-> serde, all five variants; unknown string -> None, not a panic.
  • Settings upgrade: a file with no llm_provider loads as local, existing keys survive.
  • The landmine test above.
  • Also fixes a latent flaw in the existing settings tests: they set MERIDIAN_SETTINGS_PATH, a process-global env var, and cargo runs tests in parallel. With only one such test it never raced; adding two exposed it. They now share a lock.

cargo test --workspace (427 passed), cargo clippy -- -D warnings, bun test in ui/ (181 passed), and npm run build are all green.

Related

Tickets: #456

Discussion

  • Anonymous

    Anonymous - 2026-07-14

    Originally posted by: coderabbitai[bot]

    [!IMPORTANT]

    Review skipped

    Auto reviews are disabled on base/target branches other than the default branch.

    Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.


    βš™οΈ Run configuration

    Configuration used: Organization UI

    Review profile: ASSERTIVE

    Plan: Pro Plus

    Run ID: e64d002f-4304-4232-9ade-972aaaab694e

    You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

    Use the checkbox below for a quick retry:
    - [ ] πŸ” Trigger review

    ✨ Finishing Touches
    πŸ§ͺ Generate unit tests (beta) - [ ] Create PR with unit tests - [ ] Commit unit tests in branch `feat/llm-provider-enum`

    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 - [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai) - [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai) - [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai) - [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

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

     
  • Anonymous

    Anonymous - 2026-07-16

    Originally posted by: adityaharishch

    Review: correctness-focused pass (large PR β€” 12,247 additions across multiple bundled workstreams; reviewed by area)

    LLM provider core (llm_provider.rs, settings.rs, src/llm/*)

    • Claims 1-3 in the PR description all verified true: llm_provider really is stored as an unvalidated String in RuntimeSettings (settings.rs:52), LlmProvider::from_wire really does degrade gracefully to Noneβ†’default on an unrecognized value rather than nuking the whole settings load, and update_settings (tray/src-tauri/src/commands/settings.rs:96-108) really does hard-reject an unrecognized provider server-side before writing. llm_prefer_local/llm_model_preference are fully removed on both sides (one stale mention survives only in a comment at AdvancedSection.tsx:119 β€” harmless, worth a follow-up cleanup).
    • PR description is misleading on scope: despite "nothing reads the new fields yet / resolver and backends follow [later]," llm::complete is already called from three live production paths in this PR β€” src/pm_worklog/generate.rs, src/worklog_pipeline/hour.rs::build_report, and src/worklog_pipeline/workstream.rs. The resolver's fallback chain (retryβ†’fallback-to-local, rate-limit backoff) is sound and well-tested, and no panics were found in detect.rs/claude.rs/resolver.rs (proper timeouts, kill_on_drop, no unwraps on external command output) β€” but reviewers should evaluate this PR knowing the LLM calls are active now, not deferred.

    Worklog pipeline / day-task generation (generate.rs, post_comment.rs, migrations 058-060)

    • Bug β€” retry after a crash can double-post a comment (src/pm_worklog/generate.rs approve_inner, ~lines 290-307): post_comment(...) succeeds, then mark_posted(...) persists state='posted'. If the process crashes or mark_posted itself errors, the row is stuck at state='approved' (the error path only sets last_error, it doesn't revert state). The idempotency check at the top of approve() (line 197) only short-circuits on state == "posted", so a retry re-enters approve_inner, correctly skips re-creating the ticket (guarded by created_task_key), but calls post_comment again unconditionally β€” and post_comment.rs deliberately carries no dedup marker in the comment body (unlike format_worklog_comment's ⏱/meridian-worklog marker elsewhere), so there's no way to detect the duplicate after the fact. This breaks the "idempotent + retry-safe" claim in the module docs. Suggest persisting posted_comment_id atomically with the post attempt, or embedding a stable per-(day_local, task_id) marker in the comment body.
    • Minor: mark_created (day_task_worklogs.rs ~line 240) has no WHERE state = 'approved' guard, unlike mark_approved/mark_posted β€” not exploitable today (single caller), but a latent trap for a future caller.
    • Minor: fetch_open_candidates (generate.rs ~419-445) does LIMIT 30 with no ORDER BY β€” SQLite doesn't guarantee row order here, so on boards with >30 open tickets the candidate set fed to the matcher can vary nondeterministically between calls.
    • Good: upsert_draft's ON CONFLICT ... DO UPDATE ... WHERE state = 'drafted' correctly follows the repo's no-DELETE-then-INSERT rule; parse_answer never panics on malformed LLM JSON.

    Ticket status picker, per provider (ticket_update/*.rs)

    • Bug β€” Jira: transition/status misalignment risk (jira.rs:684-699, pick_transition_for_choice): transition_status_options() filters requiring both to.id and to.name; pick_transition_for_choice() separately filters the raw transitions on only to.id.is_some(), then .zip()s the two lists. If any transition has to.id but missing to.name, the two filtered sequences misalign and .zip() pairs the wrong transition id with the wrong target status β€” set_status could silently POST a transition to a different status than the one requested. Given Jira is the priority-1 provider, recommend building (transition_id, StatusOption) pairs in one pass instead of filtering two lists independently and zipping.
    • Bug β€” Azure DevOps: real API failures masked as "redirected" (azure_devops.rs:364-393, try_patch_state): any non-2xx response (401/403/404/429/500) is treated identically to a legitimate workflow-reject and returned as Ok(Some(text)) β†’ redirected(...), logged via tracing::info! on what should be an error path. An expired PAT or rate-limit surfaces to the user as "your board can't move it to that status" instead of a real error, and hides credential/config problems from logs/alerting.
    • GitHub, Trello, Linear all look correct (GitHub correctly restricted to open/closed pseudo-states; Trello uses list-move; Linear uses issueUpdate(stateId) with real errors bubbling as Err).
    • No hardcoded credentials/tokens found. Undo has no staleness/optimistic-concurrency check against the current status, so two rapid status changes could clobber each other β€” not blocking, but worth a note if it comes up in practice.

    DRM detector / capture (drm_detector.rs, screenpipe.rs)

    • Correctness β€” front-window/frame mismatch, cuts both ways on the feature's core purpose: resolve_url_via_applescript always queries the front window of the named app, but capture_once_ocr iterates all visible windows. A background Netflix window that isn't frontmost can get captured anyway (the privacy/legal risk this feature exists to prevent), while a legitimate non-front window can get wrongly skipped if the front window happens to be a streaming URL. Worth gating on window identity, or at minimum documenting this as a known gap.
    • Performance β€” blocking subprocess spawns on the async capture hot path: resolve_url_via_applescriptβ†’run_osascriptβ†’std::process::Command::output() (drm_detector.rs:184-199) is a synchronous blocking call inside the async capture loop, invoked for every frame lacking a browser_url on Safari/Chromium-family browsers, not just when streaming is suspected. any_streaming_content_visible() similarly does up to 9 pgrep calls plus AppleScript, once per tick, unconditionally when pause_on_streaming is on. Recommend spawn_blocking and/or caching across ticks rather than shelling out every tick in a continuously-running background daemon.
    • No panic risk found (Command output handled via .ok()?/.unwrap_or(false) throughout). observability/mod.rs/uninstall.rs changes look intentional, no leftover WIP.

    Given the scope here, I'd suggest splitting this into the six PRs the description originally promised rather than merging as one β€” at minimum the two hard bugs above (Jira zip-misalignment, worklog duplicate-comment-on-retry) should block merge until fixed.

     
  • Anonymous

    Anonymous - 2026-07-16

    Ticket changed by: adityaharishch

    • status: open --> closed
     

Log in to post a comment.