Skip to main content

flutter_llm_router 1.1.0 copy "flutter_llm_router: ^1.1.0" to clipboard
flutter_llm_router: ^1.1.0 copied to clipboard

A resilient multi-provider LLM router for Flutter AI Toolkit featuring transparent failover, token cost tracking, and attempt logging.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart';
import 'package:flutter_llm_router/flutter_llm_router.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(const DemoApp());
}

class DemoApp extends StatelessWidget {
  const DemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'flutter_llm_router',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF1F6FEB)),
      ),
      home: const DemoPage(),
    );
  }
}

class DemoPage extends StatefulWidget {
  const DemoPage({super.key});

  @override
  State<DemoPage> createState() => _DemoPageState();
}

class _DemoPageState extends State<DemoPage> {
  final TextEditingController _openAiKey = TextEditingController(
    text: const String.fromEnvironment('OPENAI_API_KEY'),
  );
  final TextEditingController _geminiKey = TextEditingController(
    text: const String.fromEnvironment('GEMINI_API_KEY'),
  );
  final TextEditingController _anthropicKey = TextEditingController(
    text: const String.fromEnvironment('ANTHROPIC_API_KEY'),
  );
  final CostTracker _costTracker = CostTracker();

  late final _FailableAdapter _openAiAdapter;
  late final _FailableAdapter _geminiAdapter;
  late final _FailableAdapter _anthropicAdapter;
  late final RouterLlmProvider _router;

  bool _failOpenAi = false;
  bool _dropOpenAi = false;
  bool _failGemini = false;

  @override
  void initState() {
    super.initState();
    _openAiAdapter = _FailableAdapter(
      delegate: OpenAiAdapter(),
      apiKey: _openAiKey,
      simulate: () => _dropOpenAi
          ? _SimulatedFailure.network
          : _failOpenAi
              ? _SimulatedFailure.rateLimit
              : null,
    );
    _geminiAdapter = _FailableAdapter(
      delegate: GeminiAdapter(),
      apiKey: _geminiKey,
      simulate: () => _failGemini ? _SimulatedFailure.rateLimit : null,
    );
    _anthropicAdapter = _FailableAdapter(
      delegate: AnthropicAdapter(),
      apiKey: _anthropicKey,
      simulate: () => null,
    );

    _router = RouterLlmProvider(
      providers: const [
        ProviderConfig(
          name: 'openai',
          apiKey: '',
          model: 'gpt-4o-mini',
          pricePerInputToken: 0.00000015,
          pricePerOutputToken: 0.0000006,
        ),
        ProviderConfig(
          name: 'gemini',
          apiKey: '',
          model: 'gemini-2.0-flash',
          pricePerInputToken: 0.0000001,
          pricePerOutputToken: 0.0000004,
        ),
        ProviderConfig(
          name: 'anthropic',
          apiKey: '',
          model: 'claude-3-5-haiku-20241022',
          pricePerInputToken: 0.0000008,
          pricePerOutputToken: 0.000004,
        ),
      ],
      costTracker: _costTracker,
      customAdapters: <String, BaseLlmAdapter>{
        'openai': _openAiAdapter,
        'gemini': _geminiAdapter,
        'anthropic': _anthropicAdapter,
      },
    );
  }

  @override
  void dispose() {
    _router.dispose();
    _openAiAdapter.close();
    _geminiAdapter.close();
    _anthropicAdapter.close();
    _costTracker.dispose();
    _openAiKey.dispose();
    _geminiKey.dispose();
    _anthropicKey.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: ValueListenableBuilder<double>(
          valueListenable: _costTracker.totalCostNotifier,
          builder: (context, totalCost, _) {
            return Tooltip(
              message: 'Conversation spend in USD',
              child: Text('\$${totalCost.toStringAsFixed(8)}'),
            );
          },
        ),
        actions: [
          IconButton(
            tooltip: 'Provider API keys',
            onPressed: _editKeys,
            icon: const Icon(Icons.key),
          ),
          IconButton(
            tooltip: 'Attempt logs',
            onPressed: _showAttemptLogs,
            icon: const Icon(Icons.receipt_long),
          ),
        ],
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(160),
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 8),
            child: Column(
              children: [
                Row(
                  children: [
                    const Expanded(
                      child: Text('Simulate OpenAI outage (HTTP 429)'),
                    ),
                    Switch(
                      value: _failOpenAi,
                      onChanged: (value) => setState(() => _failOpenAi = value),
                    ),
                  ],
                ),
                Row(
                  children: [
                    const Expanded(
                      child: Text('Simulate OpenAI network failure'),
                    ),
                    Switch(
                      value: _dropOpenAi,
                      onChanged: (value) => setState(() => _dropOpenAi = value),
                    ),
                  ],
                ),
                Row(
                  children: [
                    const Expanded(
                      child: Text('Also simulate Gemini outage'),
                    ),
                    Switch(
                      value: _failGemini,
                      onChanged: (value) => setState(() => _failGemini = value),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
      body: LlmChatView(
        provider: _router,
        welcomeMessage: 'Separate Direct Providers:\n'
            '1. OpenAI (gpt-4o-mini)\n'
            '2. Google Gemini (gemini-2.0-flash)\n'
            '3. Anthropic (claude-3-5-haiku)\n\n'
            'Tap the key icon at top right to enter your API keys. '
            'Use the outage switches above to test live failover!',
        suggestions: const ['Hello', 'Count to three'],
        onErrorCallback: _showError,
      ),
    );
  }

  void _showError(BuildContext context, LlmException error) {
    if (error.message == NoInternetConnectionException.defaultMessage) {
      final colors = Theme.of(context).colorScheme;
      ScaffoldMessenger.of(context)
        ..hideCurrentSnackBar()
        ..showSnackBar(
          SnackBar(
            behavior: SnackBarBehavior.floating,
            backgroundColor: colors.errorContainer,
            content: Row(
              children: [
                Icon(Icons.wifi_off, color: colors.onErrorContainer),
                const SizedBox(width: 12),
                Expanded(
                  child: Text(
                    'No internet connection. Check your network and try again.',
                    style: TextStyle(color: colors.onErrorContainer),
                  ),
                ),
              ],
            ),
          ),
        );
      return;
    }
    showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        icon: const Icon(Icons.error_outline),
        title: const Text('Something went wrong'),
        content: Text(error.message),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('OK'),
          ),
        ],
      ),
    );
  }

  void _editKeys() {
    showDialog<void>(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: const Text('Direct Provider API Keys'),
          content: SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                TextField(
                  controller: _openAiKey,
                  obscureText: true,
                  decoration: const InputDecoration(
                    labelText: 'OpenAI API key (sk-...)',
                    helperText: 'Direct to api.openai.com',
                  ),
                ),
                const SizedBox(height: 12),
                TextField(
                  controller: _geminiKey,
                  obscureText: true,
                  decoration: const InputDecoration(
                    labelText: 'Gemini API key (AIza...)',
                    helperText: 'Direct to generativelanguage.googleapis.com',
                  ),
                ),
                const SizedBox(height: 12),
                TextField(
                  controller: _anthropicKey,
                  obscureText: true,
                  decoration: const InputDecoration(
                    labelText: 'Anthropic API key (sk-ant-...)',
                    helperText: 'Direct to api.anthropic.com',
                  ),
                ),
              ],
            ),
          ),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(context),
              child: const Text('Done'),
            ),
          ],
        );
      },
    );
  }

  void _showAttemptLogs() {
    final logs = _router.lastAttemptLogs;
    showModalBottomSheet<void>(
      context: context,
      showDragHandle: true,
      builder: (context) {
        return SafeArea(
          child: SingleChildScrollView(
            padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Text(
                  'Attempt logs',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                const SizedBox(height: 8),
                if (logs.isEmpty)
                  const Text('No attempts yet. Send a message to record one.')
                else
                  for (final log in logs) _AttemptTile(log: log),
              ],
            ),
          ),
        );
      },
    );
  }
}

class _AttemptTile extends StatelessWidget {
  const _AttemptTile({required this.log});

  final AttemptLog log;

  @override
  Widget build(BuildContext context) {
    final status = log.isSuccess ? 'success' : _failureStatus(log);
    return ListTile(
      contentPadding: EdgeInsets.zero,
      leading: Icon(
        log.isSuccess ? Icons.check_circle_outline : Icons.error_outline,
      ),
      title: Text(log.model),
      subtitle: Text('${log.providerName} · ${log.latencyMs} ms · $status'),
    );
  }
}

String _failureStatus(AttemptLog log) {
  final code = log.statusCode;
  final error = log.error;
  if (code != null && error != null && error.isNotEmpty) {
    return 'HTTP $code · $error';
  }
  if (error != null && error.isNotEmpty) return error;
  if (code != null) return 'HTTP $code';
  return 'failed';
}

enum _SimulatedFailure { rateLimit, network }

class _FailableAdapter extends BaseLlmAdapter {
  _FailableAdapter({
    required this.delegate,
    required this.apiKey,
    required this.simulate,
  });

  final BaseLlmAdapter delegate;
  final TextEditingController apiKey;
  final _SimulatedFailure? Function() simulate;

  @override
  Stream<AdapterChunk> streamCompletion({
    required ProviderConfig config,
    required List<ChatTurn> messages,
  }) {
    switch (simulate()) {
      case _SimulatedFailure.rateLimit:
        return Stream<AdapterChunk>.error(
          RetryableLlmException(
            'Simulated HTTP 429 for ${config.name} (${config.model})',
            statusCode: 429,
          ),
        );
      case _SimulatedFailure.network:
        // A connection-level failure: the router probes connectivity and,
        // when the device is online, fails over to the next provider.
        return Stream<AdapterChunk>.error(
          RetryableLlmException(
            'Simulated connection failure for ${config.name} '
            '(${config.model})',
            cause: http.ClientException('Simulated host unreachable'),
          ),
        );
      case null:
        break;
    }
    return delegate.streamCompletion(
      config: config.copyWith(apiKey: apiKey.text.trim()),
      messages: messages,
    );
  }

  @override
  void close() {
    delegate.close();
    super.close();
  }
}
5
likes
150
points
681
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A resilient multi-provider LLM router for Flutter AI Toolkit featuring transparent failover, token cost tracking, and attempt logging.

Repository (GitHub)
View/report issues

Topics

#ai #llm #flutter-ai-toolkit #cost-tracking #failover

License

MIT (license)

Dependencies

flutter, flutter_ai_toolkit, http

More

Packages that depend on flutter_llm_router