Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 96 additions & 14 deletions LocalMind-Backend/src/api/ai/core/AIProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,117 @@

import { AIProvider } from './AIProvider'
import { AICapability } from './types'
import { GeminiProvider } from '../providers/GeminiProvider'

type ProviderHealth = {
failures: number
lastFailureAt?: number
}

const MAX_FAILURES = 3

class AIProviderRegistry {
private providers = new Map<string, AIProvider>()
private health = new Map<string, ProviderHealth>()

register(provider: AIProvider) {
if (this.providers.has(provider.name)) {
throw new Error(
`A provider with the name "${provider.name}" is already registered.`
if (this.providers.has(provider.name)) {
throw new Error(
`A provider with the name "${provider.name}" is already registered.`
)
}

this.providers.set(provider.name, provider)
this.health.set(provider.name, { failures: 0 })
}

get(name: string): AIProvider | undefined {
return this.providers.get(name)
}

list(): AIProvider[] {
return Array.from(this.providers.values())
}

findByCapabilities(capabilities: AICapability[]): AIProvider[] {
return Array.from(this.providers.values()).filter(provider =>
capabilities.every(cap => provider.supports(cap))
)
}
this.providers.set(provider.name, provider)
}

// --------------------
// Health tracking
// --------------------

private isHealthy(providerName: string): boolean {
const info = this.health.get(providerName)
if (!info) return true
return info.failures < MAX_FAILURES
}

get(name: string): AIProvider | undefined {
return this.providers.get(name)
markFailure(providerName: string) {
const info = this.health.get(providerName)
if (!info) return

info.failures += 1
info.lastFailureAt = Date.now()
}

findByCapabilities(capabilities: AICapability[]): AIProvider[] {
return Array.from(this.providers.values()).filter(provider =>
capabilities.every(cap => provider.supports(cap))
)
}
markSuccess(providerName: string) {
const info = this.health.get(providerName)
if (!info) return

info.failures = 0
}

list(): AIProvider[] {
return Array.from(this.providers.values())
// --------------------
// Safe execution with proper fallback
// --------------------

async generateTextWithFallback(
capabilities: AICapability[],
input: { prompt: string; context?: string }
): Promise<string> {
const candidates = this.findByCapabilities(capabilities)
const healthyProviders = candidates.filter(p => this.isHealthy(p.name))
const degradedProviders = candidates.filter(p => !this.isHealthy(p.name))
let lastError: unknown

// 1️⃣ Try all healthy providers first
for (const provider of healthyProviders) {
try {
const result = await provider.generateText(input)
this.markSuccess(provider.name)
return result
} catch (err) {
lastError = err
this.markFailure(provider.name)
}
}

// 2️⃣ Fall back to degraded providers if all healthy ones failed
for (const provider of degradedProviders) {
try {
const result = await provider.generateText(input)
this.markSuccess(provider.name)
return result
} catch (err) {
lastError = err
this.markFailure(provider.name)
}
}

// 3️⃣ Throw the last error encountered (preserves stack trace and error details)
if (lastError) {
throw lastError
}

// 4️⃣ If no providers matched capabilities at all
throw new Error('No AI provider available for the specified capabilities')
}
Comment on lines +72 to 112
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.

high

The current implementation of generateTextWithFallback has a couple of issues that deviate from the goals outlined in the PR description:

  1. Incorrect Fallback Logic: The PR description mentions that the system should "fall back gracefully if all providers are degraded." However, the code only attempts to use providers that are currently healthy (if (!this.isHealthy(provider.name)) continue) and never tries degraded ones, even if they are the only option.
  2. Error Swallowing: The catch block is empty, which means the actual error from the provider is lost. This makes debugging failures nearly impossible, as the caller only receives a generic "No healthy AI provider available" message without any context on why the provider failed (e.g., API error, timeout, etc.).

I suggest a refactoring to correctly implement the fallback logic and preserve error information for better diagnostics.

  async generateTextWithFallback(
    capabilities: AICapability[],
    input: { prompt: string; context?: string }
  ): Promise<string> {
    const candidates = this.findByCapabilities(capabilities);
    const healthyProviders = candidates.filter(p => this.isHealthy(p.name));
    const degradedProviders = candidates.filter(p => !this.isHealthy(p.name));
    let lastError: unknown;

    // First, try all healthy providers
    for (const provider of healthyProviders) {
      try {
        const result = await provider.generateText(input);
        this.markSuccess(provider.name);
        return result;
      } catch (err) {
        lastError = err;
        this.markFailure(provider.name);
      }
    }

    // If all healthy providers failed, fall back to degraded ones
    for (const provider of degradedProviders) {
      try {
        const result = await provider.generateText(input);
        this.markSuccess(provider.name);
        return result;
      } catch (err) {
        lastError = err;
        this.markFailure(provider.name);
      }
    }

    if (lastError) {
      throw lastError;
    }

    throw new Error('No AI provider available for the specified capabilities.');
  }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the detailed review 👍
Good catch — I’ve refactored generateTextWithFallback to:

  • Attempt all healthy providers first
  • Gracefully fall back to degraded providers if needed
  • Preserve and rethrow the last provider error for better diagnostics

This aligns the implementation with the PR description while keeping the behavior non-breaking. Please let me know if you’d like further adjustments.

}

export const aiProviderRegistry = new AIProviderRegistry()

// Register providers
aiProviderRegistry.register(new GeminiProvider())
25 changes: 25 additions & 0 deletions LocalMind-Backend/src/api/ai/providers/GeminiProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// src/api/ai/providers/GeminiProvider.ts

import { AIProvider } from '../core/AIProvider'
import { AICapability } from '../core/types'

export class GeminiProvider implements AIProvider {
readonly name = 'gemini'

readonly capabilities = new Set<AICapability>([
'cloud',
'multimodal',
])

supports(capability: AICapability): boolean {
return this.capabilities.has(capability)
}

async generateText(input: {
prompt: string
context?: string
}): Promise<string> {
// TEMP mock response (safe, non-breaking)
return `[Gemini] ${input.prompt}`
}
}