Skip to content

Fix cloud LLM 404: honor plugin model selection, parse Gemini array errors, stop poisoning legacy global - #696

Merged
SeoFood merged 6 commits into
TypeWhisper:mainfrom
r00tat:fix/llm-preferred-model-resolution
Jun 11, 2026
Merged

Fix cloud LLM 404: honor plugin model selection, parse Gemini array errors, stop poisoning legacy global#696
SeoFood merged 6 commits into
TypeWhisper:mainfrom
r00tat:fix/llm-preferred-model-resolution

Conversation

@r00tat

@r00tat r00tat commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Closes #697

Summary

Post-processing workflows using a cloud LLM provider (Gemini, Groq, Cerebras, Fireworks, Claude) could fail with a bare API error: HTTP 404 and silently drop the dictation. Root cause was three independent defects that combine; this PR fixes all three.

1. Provider plugins ignored the model picked in their own UI

PromptProcessingService.resolvedModelId() resolves a workflow's model via (plugin as? LLMModelSelectable)?.preferredModelId. GeminiPlugin, GroqPlugin, CerebrasPlugin, FireworksPlugin and ClaudePlugin stored _selectedLLMModelId but did not conform to LLMModelSelectable / expose preferredModelId (unlike OpenRouterPlugin / XAIPlugin / Gemma4Plugin). The cast returned nil, so the model the user selected in the plugin UI was never consulted and the host fell through to the legacy global llmCloudModel.

→ All five plugins now conform to LLMModelSelectable and expose @objc var preferredModelId, matching the existing OpenRouter/xAI pattern.

2. The legacy llmCloudModel global was auto-poisoned with the oldest model

normalizeSelectedCloudModelIfNeeded() persisted models.first?.id into llmCloudModel whenever no model was selected. The fetched model list is sorted alphabetically, so models.first is the oldest model (e.g. gemini-2.0-flash). Once Google retired that model, the persisted global kept sending it on every run — and Google's /models endpoint still lists it, so validation could not catch it.

→ The resolution decision is extracted into a pure, unit-testable resolveModel(...). The global is now persisted only for a deliberate choice (the plugin's preferredModelId, or a self-healing repair of a non-empty but invalid global). When no model was ever selected, the alphabetical fallback is used transiently for that run but not persisted. The existing self-healing behaviour is preserved.

3. Gemini's error body is a top-level array — the helpful message was dropped

Google's OpenAI-compat endpoint returns the error wrapped in a top-level JSON array:

[{ "error": { "code": 404, "message": "This model models/gemini-2.0-flash is no longer available...", "status": "NOT_FOUND" } }]

PluginOpenAIChatHelper only parsed dictionary bodies ([String: Any]), so the cast failed and the user only saw API error: HTTP 404.

→ Error parsing is extracted into a testable PluginOpenAIChatHelper.errorMessage(from:statusCode:) that handles both the dictionary shape and Gemini's top-level-array shape, falling back to HTTP <status> only when no message is present.

Verified API behaviour

Against POST https://generativelanguage.googleapis.com/v1beta/openai/chat/completions:

model HTTP status
gemini-2.0-flash 404 (retired, still listed by /models)
gemini-2.5-flash 200
gemini-flash-latest 200

Changes

  • LLMModelSelectable + preferredModelId for Gemini / Groq / Cerebras / Fireworks / Claude plugins.
  • PluginOpenAIChatHelper.errorMessage(from:statusCode:) handles array + dictionary error bodies.
  • PromptProcessingService.resolveModel(...) extracted as a pure function; the legacy global is no longer poisoned with the alphabetically-oldest model.
  • Added GeminiPlugin / CerebrasPlugin / FireworksPlugin / ClaudePlugin as SwiftPM targets + test targets (mirroring the existing GroqPlugin / OpenRouterPlugin setup) so the conformance is covered by swift test.

Test plan

# Plugin SDK (new error-parsing tests + per-plugin preferredModelId conformance tests)
swift test --package-path TypeWhisperPluginSDK

# App (new PromptProcessingModelResolutionTests + existing resolution/repair coverage)
xcodebuild test -project TypeWhisper.xcodeproj -scheme TypeWhisper \
  -destination 'platform=macOS,arch=arm64' -parallel-testing-enabled NO \
  CODE_SIGN_IDENTITY='-' CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \
  -only-testing:TypeWhisperTests/PromptProcessingModelResolutionTests \
  -only-testing:TypeWhisperTests/APIRouterAndHandlersTests/testPromptProcessingRepairsInvalidGlobalCloudModelBeforeRequest
  • swift test --package-path TypeWhisperPluginSDK: 218 tests, 0 failures (incl. the new error-body and conformance tests, and ProtocolContractTests).
  • New PromptProcessingModelResolutionTests (6) and the existing prompt-processing integration tests pass. Each fix was developed test-first (failing test → minimal change → green).

Summary by CodeRabbit

  • New Features

    • Exposed preferred-model and optional curated default model across multiple plugins
    • UI model picker accepts and shows a provider-recommended fallback
    • Added new plugin packages/targets for additional providers
  • Bug Fixes / Behavior Changes

    • Safer, more predictable model selection and persistence (transient vs persisted; repair behavior)
    • Improved, centralized API error-message extraction
  • Tests

    • Added plugin model-selection tests and comprehensive model-resolution suite

Post-processing workflows that selected a provider but no explicit model
override silently ignored the model chosen in the plugin UI, falling back
to the legacy global llmCloudModel key. When that key still pointed at a
retired model (e.g. gemini-2.0-flash), every run failed with a bare
"API error: HTTP 404" because Gemini's error body was dropped.

Two defects fixed:

1. Conform GeminiPlugin, GroqPlugin, CerebrasPlugin, FireworksPlugin and
   ClaudePlugin to LLMModelSelectable and expose preferredModelId, so
   PromptProcessingService.resolvedModelId() honours the user's selection
   instead of the legacy global key (matches OpenRouter/XAI/Gemma4).

2. PluginOpenAIChatHelper now parses error bodies wrapped in a top-level
   JSON array (Gemini's OpenAI-compat shape) in addition to the dictionary
   shape, so the descriptive API message survives instead of collapsing to
   "HTTP <status>". Extracted into a testable errorMessage(from:statusCode:).

Adds GeminiPlugin/CerebrasPlugin/FireworksPlugin/ClaudePlugin as SwiftPM
targets with test targets (mirroring GroqPlugin/OpenRouterPlugin) and
covers all changes with tests.
normalizeSelectedCloudModelIfNeeded() persisted models.first?.id into the
legacy llmCloudModel key whenever no model was selected. Because the fetched
model list is sorted alphabetically, models.first is the oldest model (e.g.
gemini-2.0-flash). Once Google retired that model the persisted global kept
sending it on every workflow run, producing HTTP 404 even after the user
picked a current model in the UI.

Extract the model-resolution decision into a pure, unit-testable
resolveModel(...) that returns whether the result should be written through
to the global. The global is now only persisted for a deliberate choice -
the provider plugin's preference, or a self-healing repair of a non-empty
but invalid global. When no model was ever selected, the alphabetical
fallback is used transiently for the run but not persisted, so a
later-retired oldest model can no longer silently poison the global.

Existing self-healing behaviour (repairing a non-empty invalid global) is
preserved and still covered by APIRouterAndHandlersTests.
@r00tat
r00tat requested a review from SeoFood as a code owner June 10, 2026 13:17
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a1c1073b-7022-4a06-9684-212788713523

📥 Commits

Reviewing files that changed from the base of the PR and between 9a486ff and 256c68a.

📒 Files selected for processing (2)
  • TypeWhisper/Views/PromptActionsSettingsView.swift
  • TypeWhisperTests/APIRouterAndHandlersTests.swift

📝 Walkthrough

Walkthrough

Centralizes model-resolution into a pure resolver with explicit persistence semantics, exposes plugin-selected model IDs via LLMModelSelectable across providers, adds SPM plugin targets/tests and Xcode test wiring, and centralizes OpenAI/Gemini error-body parsing with unit tests.

Changes

Model Selection & Plugin Resolution

Layer / File(s) Summary
Model Resolution Core Logic
TypeWhisper/Services/PromptProcessingService.swift, TypeWhisperTests/PromptProcessingModelResolutionTests.swift, TypeWhisper.xcodeproj/project.pbxproj, TypeWhisper/Views/PromptActionsSettingsView.swift
resolveModel() pure function extracts model-selection logic and returns ModelResolution (modelId + persistGlobally). resolvedModelId updates global selection only when caller allows and resolver marks it persistent. Provider default lookup and ModelPicker fallback wiring added; Xcode project updated to register new test source. Tests cover requested, preferred, fallback, repair, preservation, and empty-available cases.
Plugin SDK Package & Build System
TypeWhisperPluginSDK/Package.swift
Added SPM targets and testTargets for Gemini, Cerebras, Fireworks, and Claude.
Plugin LLMModelSelectable Conformance
TypeWhisperPluginSDK/Plugins/*/*Plugin.swift, TypeWhisperPluginSDK/Plugins/*/Tests/*PluginTests.swift, TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/TypeWhisperPlugin.swift
Gemini, Cerebras, Claude, Fireworks, and Groq now conform to LLMModelSelectable and expose @objc var preferredModelId: String? (and in Gemini a defaultModelId). Activation/init behavior was adjusted to avoid seeding/persisting fallback selections; tests verify preferred/default behaviors and activation semantics.
OpenAI API Error Message Extraction
TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/HostServices.swift, TypeWhisperPluginSDK/Tests/TypeWhisperPluginSDKTests/OpenAIChatHelperTests.swift
New PluginOpenAIChatHelper.errorMessage(from:statusCode:) parses dictionary or array-wrapped JSON error bodies and selects message via precedence (detail → error.message → message), falling back to HTTP <statusCode>. Tests cover OpenAI and Gemini-compat shapes and fallback cases.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🐰 I hopped through plugins, sniffed each preferred name,
I nudged the resolver tidy, so defaults don't misbehave,
When servers grumble, I peeked inside the JSON sea,
Now whispers tell which model, and errors speak clearly. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.42% 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
Title check ✅ Passed The title clearly summarizes the three main fixes: honoring plugin model selection ("honor plugin model selection"), preventing global poisoning ("stop poisoning legacy global"), and parsing array-wrapped errors ("parse Gemini array errors"). It is concise, specific, and directly reflects the primary changes.
Description check ✅ Passed The PR description comprehensively covers the root causes, changes, and verification. It includes a detailed summary of three independent defects fixed, the changes made, and a test plan with passing counts. It exceeds the template requirements.
Linked Issues check ✅ Passed The PR directly addresses all primary coding requirements from #697: plugins now conform to LLMModelSelectable (requirement 1), resolveModel() prevents global poisoning (requirement 2), and error parsing handles array-wrapped bodies (requirement 3). All objectives are met.
Out of Scope Changes check ✅ Passed All changes directly support the three core fixes. Updates to GeminiPlugin, GroqPlugin, CerebrasPlugin, FireworksPlugin, and ClaudePlugin add LLMModelSelectable conformance (in scope). Error parsing improvements and resolveModel() extraction serve defect fixes (in scope). SwiftPM target additions and tests provide coverage for these changes (in scope).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

@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: 1

🧹 Nitpick comments (2)
TypeWhisperPluginSDK/Plugins/GroqPlugin/Tests/GroqPluginTests.swift (1)

49-59: ⚡ Quick win

Add a pre-selection assertion for preferredModelId.

This test verifies the post-selection path, but it should also assert the pre-selection state to lock in “no explicit selection => no preferred model” behavior.

Suggested test addition
     func testPreferredModelIdReflectsSelectedLLMModel() throws {
         let host = try PluginTestHostServices()
         let plugin = GroqPlugin()
         plugin.activate(host: host)

+        XCTAssertNil((plugin as LLMModelSelectable).preferredModelId)
+
         let target = try XCTUnwrap(plugin.supportedModels.first?.id)
         plugin.selectLLMModel(target)

         let preferred = (plugin as? LLMModelSelectable)?.preferredModelId
         XCTAssertEqual(preferred, target)
     }
🤖 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 `@TypeWhisperPluginSDK/Plugins/GroqPlugin/Tests/GroqPluginTests.swift` around
lines 49 - 59, In testPreferredModelIdReflectsSelectedLLMModel, add a
pre-selection assertion that (plugin as? LLMModelSelectable)?.preferredModelId
is nil before calling plugin.selectLLMModel; locate the test function and insert
an XCTAssertNil(preferred) (or XCTAssertEqual to nil) immediately after
plugin.activate(host: host) and before deriving target from
plugin.supportedModels.first?.id to ensure the "no explicit selection => no
preferred model" behavior is validated.
TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/HostServices.swift (1)

807-831: Align HostServices error parsing with OpenAIPlugin precedence
HostServices.errorMessage(from:statusCode:) already preserves Gemini’s array-wrapped error.message, but it skips OpenAIPlugin-style fallbacks for top-level "detail" and top-level "message". Add checks in precedence order (detail → nested error.message → top-level message) and apply the same dictionary-vs-array handling when extracting these fields to avoid losing provider-specific error details.

🤖 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 `@TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/HostServices.swift` around
lines 807 - 831, HostServices.errorMessage(from:statusCode:) currently only
inspects nested error.message (and array-wrapped first element) but misses
OpenAIPlugin-style top-level "detail" and top-level "message"; update the
function to handle both JSON object and JSON array shapes and apply precedence:
first check top-level "detail" (String, non-empty), then nested "error.message"
(as currently), then top-level "message" (String, non-empty), and finally fall
back to "HTTP <statusCode>"; reference the function
HostServices.errorMessage(from:statusCode:) and ensure you inspect both the
dictionary and array-first cases for each key in that order.
🤖 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 `@TypeWhisperPluginSDK/Plugins/GroqPlugin/GroqPlugin.swift`:
- Line 8: preferredModelId is exposing an auto-seeded fallback because
_selectedLLMModelId is initialized with supportedModels.first?.id during
activation; change the logic so that preferredModelId only returns a model id
when the user has explicitly chosen one. Introduce a separate boolean/state
(e.g., _hasUserSelectedLLMModel or _userSelectedLLMModelId) or a distinct
property for the seeded default used internally in activate() (leave
_seededLLMModelId or similar) and update the selection setter used by user
actions to set the user-selected flag; then make preferredModelId return the
user-selected id (or nil if none), not the seeded fallback from activate().
Ensure references to _selectedLLMModelId, preferredModelId, and activation logic
are updated consistently.

---

Nitpick comments:
In `@TypeWhisperPluginSDK/Plugins/GroqPlugin/Tests/GroqPluginTests.swift`:
- Around line 49-59: In testPreferredModelIdReflectsSelectedLLMModel, add a
pre-selection assertion that (plugin as? LLMModelSelectable)?.preferredModelId
is nil before calling plugin.selectLLMModel; locate the test function and insert
an XCTAssertNil(preferred) (or XCTAssertEqual to nil) immediately after
plugin.activate(host: host) and before deriving target from
plugin.supportedModels.first?.id to ensure the "no explicit selection => no
preferred model" behavior is validated.

In `@TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/HostServices.swift`:
- Around line 807-831: HostServices.errorMessage(from:statusCode:) currently
only inspects nested error.message (and array-wrapped first element) but misses
OpenAIPlugin-style top-level "detail" and top-level "message"; update the
function to handle both JSON object and JSON array shapes and apply precedence:
first check top-level "detail" (String, non-empty), then nested "error.message"
(as currently), then top-level "message" (String, non-empty), and finally fall
back to "HTTP <statusCode>"; reference the function
HostServices.errorMessage(from:statusCode:) and ensure you inspect both the
dictionary and array-first cases for each key in that order.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 01e092d0-7541-48b4-9479-356650bcc93c

📥 Commits

Reviewing files that changed from the base of the PR and between df7d965 and ccf08e7.

📒 Files selected for processing (16)
  • TypeWhisper.xcodeproj/project.pbxproj
  • TypeWhisper/Services/PromptProcessingService.swift
  • TypeWhisperPluginSDK/Package.swift
  • TypeWhisperPluginSDK/Plugins/CerebrasPlugin/CerebrasPlugin.swift
  • TypeWhisperPluginSDK/Plugins/CerebrasPlugin/Tests/CerebrasPluginTests.swift
  • TypeWhisperPluginSDK/Plugins/ClaudePlugin/ClaudePlugin.swift
  • TypeWhisperPluginSDK/Plugins/ClaudePlugin/Tests/ClaudePluginTests.swift
  • TypeWhisperPluginSDK/Plugins/FireworksPlugin/FireworksPlugin.swift
  • TypeWhisperPluginSDK/Plugins/FireworksPlugin/Tests/FireworksPluginTests.swift
  • TypeWhisperPluginSDK/Plugins/GeminiPlugin/GeminiPlugin.swift
  • TypeWhisperPluginSDK/Plugins/GeminiPlugin/Tests/GeminiPluginTests.swift
  • TypeWhisperPluginSDK/Plugins/GroqPlugin/GroqPlugin.swift
  • TypeWhisperPluginSDK/Plugins/GroqPlugin/Tests/GroqPluginTests.swift
  • TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/HostServices.swift
  • TypeWhisperPluginSDK/Tests/TypeWhisperPluginSDKTests/OpenAIChatHelperTests.swift
  • TypeWhisperTests/PromptProcessingModelResolutionTests.swift

Comment thread TypeWhisperPluginSDK/Plugins/GroqPlugin/GroqPlugin.swift
CodeRabbit review on TypeWhisper#696:

- GroqPlugin/CerebrasPlugin/FireworksPlugin/ClaudePlugin seeded
  _selectedLLMModelId with supportedModels.first?.id on activation, so the
  newly-added preferredModelId exposed an auto-seeded fallback as if the user
  had chosen it - which the host would then persist into the legacy global,
  re-introducing the very poisoning this PR fixes. Drop the activate-time seed
  so preferredModelId is nil until the user selects a model; process() and the
  settings view already fall back to supportedModels.first independently, so
  runtime behaviour is unchanged. GeminiPlugin is intentionally left as-is: its
  default is a curated gemini-flash-latest (not the alphabetical oldest) that
  protects fresh users from the retired gemini-2.0-flash.
- Add a pre-selection assertion (preferredModelId == nil) to the four plugin
  tests to lock in the "no explicit selection => no preferred model" contract.
- Align PluginOpenAIChatHelper.errorMessage(from:statusCode:) with the
  OpenAIPlugin precedence (top-level detail -> nested error.message ->
  top-level message), applied to both dictionary and array-wrapped bodies.
@r00tat

r00tat commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the CodeRabbit review in 319f94c:

  • Major (preferredModelId exposing an auto-seeded fallback) — fixed for Groq/Cerebras/Fireworks/Claude; see the inline thread reply for details and why Gemini is intentionally left as-is.
  • Nitpick (pre-selection assertion) — added XCTAssertNil(preferredModelId) after activate() in the four plugin tests.
  • Nitpick (error-body precedence) — PluginOpenAIChatHelper.errorMessage(from:statusCode:) now follows the OpenAIPlugin precedence (detailerror.message → top-level message) for both dictionary and array-wrapped bodies, with new tests.

swift test --package-path TypeWhisperPluginSDK: 220 tests, 0 failures.

@SeoFood SeoFood left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes on one remaining blocker.

The CodeRabbit finding was fixed for Groq, Cerebras, Fireworks, and Claude, but the same contract still appears to be violated by Gemini.

GeminiPlugin.preferredModelId returns _selectedLLMModelId, and normalizeSelectedModel() still auto-populates _selectedLLMModelId with supportedModels.first when there is no valid stored selection. For Gemini, fetched models are sorted alphabetically, and the PR description notes that gemini-2.0-flash is retired but still listed by the /models endpoint. That means Gemini can still persist/expose gemini-2.0-flash as a preferred model even when the user never explicitly selected it.

This undermines the main fix in this PR: PromptProcessingService.resolveModel() now treats a plugin preferredModelId as a deliberate provider preference and may persist it to the legacy global. So Gemini can still reintroduce the “oldest listed model poisons model selection” failure mode.

Please make Gemini follow the same contract as the other fixed plugins:

  • preferredModelId should only represent an explicit user-selected model.
  • Auto/default fallback should be used transiently for processing/UI display, not written into _selectedLLMModelId as a preferred model.
  • Add a Gemini regression test with fetched models including gemini-2.0-flash, gemini-2.5-flash, and gemini-flash-latest, asserting that fresh activation/model normalization does not expose or persist gemini-2.0-flash as preferredModelId.

Relevant paths:

  • TypeWhisperPluginSDK/Plugins/GeminiPlugin/GeminiPlugin.swift
  • TypeWhisperPluginSDK/Plugins/GeminiPlugin/Tests/GeminiPluginTests.swift
  • TypeWhisper/Services/PromptProcessingService.swift

…dModelId

Review on TypeWhisper#696 (SeoFood): Gemini still violated the contract the other four
plugins were fixed to honor in 319f94c. normalizeSelectedModel() seeded
_selectedLLMModelId with supportedModels.first and persisted it, and since
fetched models sort alphabetically that means the retired gemini-2.0-flash
could be exposed/persisted as a preferred model the user never chose -
re-introducing the legacy-global poisoning this PR fixes.

- normalizeSelectedModel() is now validate-only: _selectedLLMModelId (and
  thus preferredModelId) only ever holds an explicit, still-valid user
  selection. No fallback is seeded into it or written to user defaults; a
  stored selection that is temporarily missing from the model list is kept
  so it re-validates after the next fetch.
- The transient default for process() and the settings UI prefers the
  curated auto-updating gemini-flash-latest alias over the
  alphabetically-oldest fetched model (post-refresh auto-select included).
- Drop the now-dead _selectedLLMModelId read in activate();
  normalizeSelectedModel() re-reads the stored value itself.
- Add regression tests with fetched models [gemini-2.0-flash,
  gemini-2.5-flash, gemini-flash-latest]: fresh activation neither exposes
  nor persists gemini-2.0-flash; a stale stored selection is not replaced
  by a fallback; a valid stored selection survives activation.
@r00tat

r00tat commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

@SeoFood Fixed in 4280af7 — Gemini now follows the same contract as the other four plugins.

  • normalizeSelectedModel() is validate-only: _selectedLLMModelId (and thus preferredModelId) only ever holds an explicit, still-valid user selection. No fallback is seeded into it or written to user defaults, so the alphabetically-first gemini-2.0-flash can no longer be exposed or persisted as a preference the user never made. A stored selection that is temporarily missing from the model list is kept on disk so it re-validates after the next fetch.
  • The transient default for process() and the settings UI (including post-refresh auto-select) now prefers the curated auto-updating gemini-flash-latest alias over supportedModels.first, so fresh users don't transiently hit the retired model either.
  • Added the requested regression test with fetched models gemini-2.0-flash / gemini-2.5-flash / gemini-flash-latest: fresh activation neither exposes nor persists gemini-2.0-flash as preferredModelId. Also covered: a stale stored selection is not replaced by a fallback, a valid stored selection survives activation, and the pre-selection XCTAssertNil the other plugins got in 319f94c.

No host-side change was needed — PromptProcessingService.resolveModel() already treats a nil preferredModelId as "no deliberate choice" and uses the fallback transiently without persisting.

Verified with:

  • swift test --package-path TypeWhisperPluginSDK (223 tests, 0 failures)
  • xcodebuild test … -only-testing:TypeWhisperTests/PromptProcessingModelResolutionTests (6 tests, 0 failures)

@SeoFood

SeoFood commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Thanks for the follow-up. The preferredModelId part is fixed now, but I think there is still a host-level gap.

GeminiPlugin.defaultLLMModelId will not be used in the post-processing path when the host has already resolved a model. PromptProcessingService.resolvedModelId() still falls through to availableModelIds.first when all of these are empty/nil:

  • workflow/requested model
  • plugin preferredModelId
  • legacy selectedCloudModel

For Gemini with fetched models, availableModelIds.first is still the alphabetically-first model, e.g. gemini-2.0-flash. process() then passes that non-nil model into processWithPlugin(...), so GeminiPlugin.process(... model:) never reaches its new defaultLLMModelId fallback.

There is already an app test encoding this behavior:

TypeWhisperTests/PromptProcessingModelResolutionTests.swift

func testAlphabeticalFallbackIsUsedButNeverPersisted() {
    let resolution = PromptProcessingService.resolveModel(
        requestedModel: nil,
        preferredModelId: nil,
        selectedCloudModel: "",
        availableModelIds: ["gemini-2.0-flash", "gemini-2.5-flash", "gemini-flash-latest"]
    )

    XCTAssertEqual(resolution.modelId, "gemini-2.0-flash")
    XCTAssertFalse(resolution.persistGlobally)
}

So the new Gemini tests prove that preferredModelId is no longer poisoned, but they do not prove the actual host-to-plugin processing path avoids the retired model. The host can still transiently choose gemini-2.0-flash and pass it explicitly.

I think the fix needs one more step: when there is no explicit requested/preferred/global model, the host should either pass nil and let the plugin pick its own transient default, or the resolver needs a provider-aware default that prefers gemini-flash-latest when present. The regression test should cover the host resolver/process-path behavior, not only GeminiPlugin.preferredModelId.

…ilable

Follow-up review on TypeWhisper#696 (SeoFood): the plugin-side fix alone left the host
resolution path choosing availableModelIds.first when no requested, preferred
or valid global model exists, and passing it explicitly to the plugin - so
GeminiPlugin's curated transient default was never reached and the host could
still (transiently) run the retired gemini-2.0-flash.

- SDK: add an optional defaultModelId to LLMModelSelectable - the model a
  provider recommends when no explicit selection exists. A transient fallback
  hint, never a user preference. @objc optional, so existing plugin binaries
  stay compatible.
- GeminiPlugin exposes its curated gemini-flash-latest default through it.
- PromptProcessingService.resolveModel() takes providerDefaultModelId and
  prefers it (when valid) over availableModelIds.first in the fallback branch.
  This covers both the transient no-selection case and the self-healing repair
  of an invalid non-empty global, which previously persisted the retired
  oldest model again.
- ModelPickerView preselects the provider default instead of models.first
  when the bound selection is empty/invalid - the picker writes through to
  the persisted llmCloudModel global, so this was a third site adopting the
  alphabetically-oldest model.
- Host resolver tests: provider default wins the fallback transiently, an
  unlisted default is ignored, and repair persists the provider default
  rather than the oldest model. SDK test: GeminiPlugin's host-visible
  defaultModelId is the curated alias when fetched models include retired
  gemini-2.0-flash.
@r00tat

r00tat commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

@SeoFood Confirmed — the resolver's last fallback returned availableModelIds.first and process() passed it explicitly into the plugin, so GeminiPlugin.defaultLLMModelId was unreachable on the host path. While verifying I found the same first-available pattern in two more places: the repair branch (an invalid non-empty global was self-healed to availableModelIds.first and persisted — for Gemini that re-poisons the global with gemini-2.0-flash), and ModelPickerView.ensureValidSelection(), which writes models.first through the binding into the persisted llmCloudModel global whenever the picker appears with an empty/invalid selection.

Fixed in 9a486ff with your second option (provider-aware default), since it covers all three sites where pass-nil only covers the process path:

  • New @objc optional var defaultModelId on LLMModelSelectable — the model a provider recommends when no explicit selection exists; a transient fallback hint, never a user preference. Optional member, so existing plugin binaries stay compatible. GeminiPlugin exposes its curated gemini-flash-latest through it.
  • resolveModel() takes providerDefaultModelId and prefers it (when present in the available list) over availableModelIds.first in the fallback branch — both for the transient no-selection case and for the self-healing repair, which now persists the provider default instead of the retired oldest model.
  • ModelPickerView preselects the provider default instead of models.first.

testAlphabeticalFallbackIsUsedButNeverPersisted still passes unchanged — it now documents the no-provider-default behavior (providers without a defaultModelId keep first-available). New host tests: testProviderDefaultIsPreferredOverAlphabeticalFallback, testProviderDefaultNotInAvailableModelsIsIgnored, testInvalidNonEmptyGlobalIsRepairedToProviderDefault. New SDK test: testDefaultModelIdPrefersCuratedAliasOverOldestFetchedModel (fetched models include gemini-2.0-flash / gemini-2.5-flash / gemini-flash-latest).

Verified with:

  • swift test --package-path TypeWhisperPluginSDK (224 tests, 0 failures)
  • xcodebuild test … -only-testing:TypeWhisperTests/PromptProcessingModelResolutionTests (9 tests, 0 failures)

@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: 1

🤖 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 `@TypeWhisper/Views/PromptActionsSettingsView.swift`:
- Around line 417-420: The current code unconditionally replaces selection with
fallbackModelId or first model when selection is empty, which auto-promotes a
transient hint into processingService.selectedCloudModel; change the logic in
PromptActionsSettingsView where selection is assigned so that you only
auto-repair when an existing non-empty selection is invalid: check if
selection.isEmpty — if true, leave it untouched (so runtime resolution via
LLMModelSelectable.defaultModelId can occur); otherwise, compute validFallback
from fallbackModelId and models and set selection = validFallback ?? selection
(or models.first?.id if you prefer to repair only invalid non-empty values).
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fe9bb118-5337-4401-b7a0-615df88169a7

📥 Commits

Reviewing files that changed from the base of the PR and between 4280af7 and 9a486ff.

📒 Files selected for processing (6)
  • TypeWhisper/Services/PromptProcessingService.swift
  • TypeWhisper/Views/PromptActionsSettingsView.swift
  • TypeWhisperPluginSDK/Plugins/GeminiPlugin/GeminiPlugin.swift
  • TypeWhisperPluginSDK/Plugins/GeminiPlugin/Tests/GeminiPluginTests.swift
  • TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/TypeWhisperPlugin.swift
  • TypeWhisperTests/PromptProcessingModelResolutionTests.swift
🚧 Files skipped from review as they are similar to previous changes (3)
  • TypeWhisperPluginSDK/Plugins/GeminiPlugin/Tests/GeminiPluginTests.swift
  • TypeWhisper/Services/PromptProcessingService.swift
  • TypeWhisperPluginSDK/Plugins/GeminiPlugin/GeminiPlugin.swift

Comment thread TypeWhisper/Views/PromptActionsSettingsView.swift Outdated
…rsisting defaults

Two follow-ups on TypeWhisper#696:

- testGeminiPluginActivationIgnoresLegacyCacheAndRepairsInvalidSelection
  (app-tests CI failure on 4280af7) still asserted the old behavior of
  normalizing an invalid stored selection into a persisted gemini-flash-latest.
  Update it to the explicit-selection contract: the invalid selection is
  neither exposed via selectedLLMModelId nor rewritten; the stored value is
  kept so it can re-validate after a fetch, and the legacy cache key is
  cleared. Renamed accordingly.

- CodeRabbit on 9a486ff: ModelPickerView.ensureValidSelection() wrote the
  provider-default fallback into the binding even when the selection was
  empty, auto-promoting a transient hint into the persisted llmCloudModel
  global the moment the settings view appeared. An empty selection is now a
  first-class "Default (<model>)" picker row and is left untouched; only an
  invalid non-empty selection self-heals (to the provider default when
  listed, matching the resolver's repair semantics).

@SeoFood SeoFood left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved. The follow-up commits address the model-resolution blockers I raised.

The host path now uses a provider-aware defaultModelId instead of blindly falling back to availableModelIds.first, and the picker no longer writes the transient default into selectedCloudModel for empty selections. CodeRabbit's follow-up thread is resolved and the current checks are green.

Thanks for tightening the tests around the Gemini default and resolver repair behavior.

@SeoFood
SeoFood merged commit 1e7283a into TypeWhisper:main Jun 11, 2026
9 checks passed
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.

Post-processing fails with "API error: HTTP 404": cloud model picker ignored, legacy llmCloudModel poisoned, Gemini error body dropped

3 participants