Skip to content

feat: add LLM provider fallback configuration - #3477

Merged
JanCizmar merged 2 commits into
mainfrom
jancizmar/llm-provider-fallback
Feb 19, 2026
Merged

feat: add LLM provider fallback configuration#3477
JanCizmar merged 2 commits into
mainfrom
jancizmar/llm-provider-fallback

Conversation

@JanCizmar

@JanCizmar JanCizmar commented Feb 18, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds a tolgee.llm.fallbacks config property that maps provider names to fallback providers
  • When a provider is not found, the fallback chain is resolved upfront (with cycle detection and depth limit) before attempting the call
  • Extracts resolution logic into LlmProviderResolver component

Test plan

  • Unit tests for direct hit, single fallback, chain of 2, missing fallback, and circular chain detection
  • Manual test with YAML/env config to verify fallback resolution end-to-end

Summary by CodeRabbit

Release Notes

  • New Features
    • Added support for configuring LLM provider fallbacks to specify alternative providers when primary providers are unavailable
    • Implemented automatic fallback resolution with circular reference protection to ensure reliable provider selection

@coderabbitai

coderabbitai Bot commented Feb 18, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉


📝 Walkthrough

Walkthrough

This PR introduces an LLM provider fallback mechanism that allows configuration of named provider fallbacks. It adds a resolver component that chains through fallback providers (with a maximum depth limit of 10) to find a valid provider, updates prompt service to resolve provider names, and introduces a new exception for missing providers.

Changes

Cohort / File(s) Summary
Configuration & Exception
backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/machineTranslation/LlmProperties.kt, backend/data/src/main/kotlin/io/tolgee/exceptions/LlmProviderNotFoundException.kt
Added fallbacks: MutableMap<String, String> configuration property to LlmProperties with DocProperty annotation. Introduced new LlmProviderNotFoundException exception extending BadRequestException for missing provider scenarios.
Core Services
backend/data/src/main/kotlin/io/tolgee/service/LlmPropertiesService.kt, ee/backend/app/src/main/kotlin/io/tolgee/ee/service/LlmProviderResolver.kt, ee/backend/app/src/main/kotlin/io/tolgee/ee/service/LlmProviderService.kt
Added getFallbackProviderName() method to LlmPropertiesService. Introduced new LlmProviderResolver component with iterative fallback resolution (max depth 10) that checks both repository and global providers. Updated LlmProviderService to inject resolver, refactor callProvider() through callProviderInternal(), and throw LlmProviderNotFoundException instead of generic BadRequestException.
Prompt Service
ee/backend/app/src/main/kotlin/io/tolgee/ee/service/prompt/PromptServiceEeImpl.kt, ee/backend/app/src/main/kotlin/io/tolgee/ee/api/v2/controllers/PromptController.kt
Updated PromptServiceEeImpl to inject LlmProviderResolver and EntityManager, added findPromptWithResolvedProvider() method, and withResolvedProviderName() helper that resolves provider names with graceful fallback to original on exception and detaches entity to prevent OSIV side effects. Updated PromptController to use new findPromptWithResolvedProvider() method.
Tests
ee/backend/tests/src/test/kotlin/io/tolgee/ee/unit/LlmProviderFallbackTest.kt
Added comprehensive unit test class with 5 test scenarios: no fallback usage, single fallback resolution, chained fallbacks (length 2), missing fallback configuration error, and circular fallback detection. Includes helper methods for setup and parameter creation.

Sequence Diagram

sequenceDiagram
    actor Client
    participant PromptController
    participant PromptService
    participant LlmProviderResolver
    participant LlmProviderRepository
    participant LlmPropertiesService
    participant LlmProviderService

    Client->>PromptController: GET /prompt/{id}
    PromptController->>PromptService: findPromptWithResolvedProvider(projectId, promptId)
    PromptService->>PromptService: fetch prompt from DB
    PromptService->>PromptService: withResolvedProviderName(prompt)
    
    alt providerName is not empty
        PromptService->>LlmProviderResolver: resolveProviderName(orgId, providerName)
        
        loop Until provider found or max depth (10) reached
            LlmProviderResolver->>LlmProviderRepository: check if provider exists
            LlmProviderRepository-->>LlmProviderResolver: provider found?
            
            alt Provider exists
                LlmProviderResolver-->>PromptService: return resolved name
            else Provider not found
                LlmProviderResolver->>LlmPropertiesService: getFallbackProviderName(current)
                LlmPropertiesService-->>LlmProviderResolver: fallback name or null
                
                alt Fallback exists
                    Note over LlmProviderResolver: Record attempt, continue loop
                else No fallback
                    LlmProviderResolver-->>PromptService: throw LlmProviderNotFoundException
                end
            end
        end
        
        alt Exception thrown
            PromptService->>PromptService: catch, keep original providerName
        else Resolution successful
            PromptService->>PromptService: update prompt.providerName
        end
    end
    
    PromptService->>PromptService: detach entity from session
    PromptService-->>PromptController: return prompt with resolved provider
    PromptController-->>Client: HTTP 200 + prompt
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • PR #3370: Modifies LlmProviderService's provider resolution and retry/rate-limit handling logic—overlaps with provider selection changes in this PR.
  • PR #3124: Updates LlmProviderService provider resolution and error handling in the same file with related exception handling adjustments.

Poem

🐰 A rabbit hops through provider chains,
With fallbacks mapped to ease the strains,
When one provider can't be found,
Another waits to come around!
Ten hops max to find the right,
No circles spinning through the night! 🔄

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change—adding LLM provider fallback configuration—which is reflected throughout the changeset including the new configuration property, resolver component, and related logic.

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

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch jancizmar/llm-provider-fallback

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

@JanCizmar
JanCizmar requested a review from dkrizan February 18, 2026 18:19
@JanCizmar
JanCizmar marked this pull request as ready for review February 18, 2026 18:19

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ee/backend/app/src/main/kotlin/io/tolgee/ee/component/llm/AnthropicApiService.kt (1)

119-121: ⚠️ Potential issue | 🟡 Minor

Fix misleading comment referencing AzureCognitive.

The comment mentions "AzureCognitive JSON response objects" but this is the AnthropicApiService. This appears to be a copy-paste artifact.

📝 Proposed fix
   /**
-   * Data structure for mapping the AzureCognitive JSON response objects.
+   * Data structures for mapping the Anthropic API request and response objects.
    */
   companion object {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ee/backend/app/src/main/kotlin/io/tolgee/ee/component/llm/AnthropicApiService.kt`
around lines 119 - 121, Update the misleading KDoc comment in
AnthropicApiService (the comment block above the data structure mapping) to
reference Anthropic JSON response objects instead of "AzureCognitive"; locate
the comment in AnthropicApiService.kt and replace or reword the text to
accurately describe the data structure used for mapping Anthropic API JSON
responses (or remove the incorrect provider name entirely).
🧹 Nitpick comments (3)
ee/backend/app/src/main/kotlin/io/tolgee/ee/service/LlmProviderResolver.kt (1)

33-39: Consider caching provider list to avoid repeated database queries.

providerExists calls llmProviderRepository.getAll(organizationId) on every iteration of the fallback chain. With a max depth of 10, this could result in up to 10 database queries for a single resolution.

♻️ Proposed optimization
   fun resolveProviderName(
     organizationId: Long,
     provider: String,
   ): String {
     var current = provider
     val tried = mutableSetOf<String>()
+    val orgProviders = llmProviderRepository.getAll(organizationId).map { it.name }.toSet()
+    val globalProviders = llmPropertiesService.getProviders().map { it.name }.toSet()
     repeat(MAX_FALLBACK_DEPTH) {
-      if (providerExists(organizationId, current)) {
+      if (current in orgProviders || current in globalProviders) {
         return current
       }
       tried.add(current)
       val fallback = llmPropertiesService.getFallbackProviderName(current)
       if (fallback == null || fallback in tried) {
         throw LlmProviderNotFoundException(provider)
       }
       current = fallback
     }
     throw LlmProviderNotFoundException(provider)
   }
-
-  private fun providerExists(
-    organizationId: Long,
-    name: String,
-  ): Boolean {
-    if (llmProviderRepository.getAll(organizationId).any { it.name == name }) return true
-    return llmPropertiesService.getProviders().any { it.name == name }
-  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/backend/app/src/main/kotlin/io/tolgee/ee/service/LlmProviderResolver.kt`
around lines 33 - 39, providerExists currently calls
llmProviderRepository.getAll(organizationId) on every check which can trigger
many DB queries; change it to fetch and cache the provider lists once per
resolution (e.g. in LlmProviderResolver before the fallback loop) by calling
llmProviderRepository.getAll(organizationId) and
llmPropertiesService.getProviders() into local variables/collections and then
use those cached lists inside providerExists (or replace providerExists with a
lookup against the cached collection) so subsequent checks reuse the in-memory
data rather than re-querying the DB.
ee/backend/app/src/main/kotlin/io/tolgee/ee/service/prompt/PromptServiceEeImpl.kt (1)

299-312: Consider logging when provider resolution falls back to original name.

The caught LlmProviderNotFoundException is silently swallowed. While falling back to the original name is intentional (as documented), logging at DEBUG/WARN level would help diagnose configuration issues in production.

🔧 Proposed enhancement
+import io.tolgee.util.Logging
+import io.tolgee.util.logger

-class PromptServiceEeImpl(
+class PromptServiceEeImpl(
   ...
-) : PromptService {
+) : PromptService, Logging {

   private fun withResolvedProviderName(prompt: Prompt): Prompt {
     if (prompt.providerName.isEmpty()) return prompt
     val organizationId = prompt.project.organizationOwner.id
     val resolvedName =
       try {
         llmProviderResolver.resolveProviderName(organizationId, prompt.providerName)
       } catch (e: LlmProviderNotFoundException) {
+        logger.debug("Provider '${prompt.providerName}' not found, keeping original name", e)
         prompt.providerName
       }
     entityManager.detach(prompt)
     prompt.providerName = resolvedName
     return prompt
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ee/backend/app/src/main/kotlin/io/tolgee/ee/service/prompt/PromptServiceEeImpl.kt`
around lines 299 - 312, In withResolvedProviderName(prompt: Prompt) add a log
call inside the catch for LlmProviderNotFoundException so the fallback is
recorded (include organizationId and the original prompt.providerName and the
exception), e.g. use the class logger to emit a DEBUG or WARN entry, but
preserve the current behavior of returning the original name; update only the
catch block in withResolvedProviderName to log the failure before assigning
prompt.providerName = resolvedName.
ee/backend/tests/src/test/kotlin/io/tolgee/ee/unit/LlmProviderFallbackTest.kt (1)

89-135: Consider adding a test for the MAX_FALLBACK_DEPTH limit to verify deeply nested chains exceeding the limit are properly rejected.

The tests provide good coverage of essential fallback scenarios: direct hit, single fallback, chained fallbacks (depth 2), missing fallback, and circular chain detection. However, the MAX_FALLBACK_DEPTH constant (defined as 10 in LlmProviderResolver) has no corresponding test that validates the boundary—a chain longer than 10 hops should be rejected with LlmProviderNotFoundException.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ee/backend/tests/src/test/kotlin/io/tolgee/ee/unit/LlmProviderFallbackTest.kt`
around lines 89 - 135, Add a unit test in LlmProviderFallbackTest that verifies
LlmProviderResolver.MAX_FALLBACK_DEPTH is enforced by creating a fallback chain
longer than MAX_FALLBACK_DEPTH (e.g., generate providers "p0" -> "p1" -> ... ->
"pN" where N > LlmProviderResolver.MAX_FALLBACK_DEPTH), call
service.callProvider with the initial provider, and assert that it throws
LlmProviderNotFoundException; use the existing helpers setupFallbacks and
setupServerProviders to register the chain and keep the test name descriptive
like `exceeds_max_fallback_depth_throws`.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In
`@ee/backend/app/src/main/kotlin/io/tolgee/ee/component/llm/AnthropicApiService.kt`:
- Around line 119-121: Update the misleading KDoc comment in AnthropicApiService
(the comment block above the data structure mapping) to reference Anthropic JSON
response objects instead of "AzureCognitive"; locate the comment in
AnthropicApiService.kt and replace or reword the text to accurately describe the
data structure used for mapping Anthropic API JSON responses (or remove the
incorrect provider name entirely).

---

Nitpick comments:
In `@ee/backend/app/src/main/kotlin/io/tolgee/ee/service/LlmProviderResolver.kt`:
- Around line 33-39: providerExists currently calls
llmProviderRepository.getAll(organizationId) on every check which can trigger
many DB queries; change it to fetch and cache the provider lists once per
resolution (e.g. in LlmProviderResolver before the fallback loop) by calling
llmProviderRepository.getAll(organizationId) and
llmPropertiesService.getProviders() into local variables/collections and then
use those cached lists inside providerExists (or replace providerExists with a
lookup against the cached collection) so subsequent checks reuse the in-memory
data rather than re-querying the DB.

In
`@ee/backend/app/src/main/kotlin/io/tolgee/ee/service/prompt/PromptServiceEeImpl.kt`:
- Around line 299-312: In withResolvedProviderName(prompt: Prompt) add a log
call inside the catch for LlmProviderNotFoundException so the fallback is
recorded (include organizationId and the original prompt.providerName and the
exception), e.g. use the class logger to emit a DEBUG or WARN entry, but
preserve the current behavior of returning the original name; update only the
catch block in withResolvedProviderName to log the failure before assigning
prompt.providerName = resolvedName.

In
`@ee/backend/tests/src/test/kotlin/io/tolgee/ee/unit/LlmProviderFallbackTest.kt`:
- Around line 89-135: Add a unit test in LlmProviderFallbackTest that verifies
LlmProviderResolver.MAX_FALLBACK_DEPTH is enforced by creating a fallback chain
longer than MAX_FALLBACK_DEPTH (e.g., generate providers "p0" -> "p1" -> ... ->
"pN" where N > LlmProviderResolver.MAX_FALLBACK_DEPTH), call
service.callProvider with the initial provider, and assert that it throws
LlmProviderNotFoundException; use the existing helpers setupFallbacks and
setupServerProviders to register the chain and keep the test name descriptive
like `exceeds_max_fallback_depth_throws`.

JanCizmar and others added 2 commits February 19, 2026 09:41
When an LLM provider is not found, Tolgee now follows a configurable
fallback chain before failing. Admins can map provider names to
fallbacks via tolgee.llm.fallbacks config property.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a provider is delisted and replaced by a successor with a fallback
configured, the stored provider name becomes stale. Resolve it through
the fallback chain at the service level so the frontend sees a valid
provider name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@JanCizmar
JanCizmar force-pushed the jancizmar/llm-provider-fallback branch from fa48541 to d6772a7 Compare February 19, 2026 08:44
@JanCizmar
JanCizmar merged commit b56ad5d into main Feb 19, 2026
72 of 74 checks passed
@JanCizmar
JanCizmar deleted the jancizmar/llm-provider-fallback branch February 19, 2026 12:31
TolgeeMachine added a commit that referenced this pull request Feb 19, 2026
# [3.161.0](v3.160.0...v3.161.0) (2026-02-19)

### Features

* add LLM provider fallback configuration ([#3477](#3477)) ([b56ad5d](b56ad5d))
* add structured JSON output support for Anthropic provider ([#3475](#3475)) ([64fcaec](64fcaec))
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