| """Model routing for Claude-compatible requests.""" |
|
|
| from dataclasses import dataclass |
|
|
| from loguru import logger |
|
|
| from free_claude_code.application.errors import UnknownProviderError |
| from free_claude_code.config.model_refs import parse_model_name, parse_provider_type |
| from free_claude_code.config.provider_catalog import ( |
| OPENAI_COMPATIBLE_INSTANCE_RE, |
| PROVIDER_CATALOG, |
| SUPPORTED_PROVIDER_IDS, |
| ) |
| from free_claude_code.config.reasoning import ReasoningPreference |
| from free_claude_code.config.settings import OpenAICompatibleInstance, Settings |
| from free_claude_code.core.anthropic import MessagesRequest, TokenCountRequest |
| from free_claude_code.core.gateway_model_ids import decode_gateway_model_id |
| from free_claude_code.core.reasoning import ReasoningPolicy |
| from free_claude_code.providers.runtime.config import ( |
| mask_api_key, |
| openai_compatible_model_candidates, |
| split_api_key_pool, |
| ) |
|
|
| from .reasoning import resolve_reasoning_policy |
|
|
| _ROUTE_SETTINGS = ( |
| ("fable", "model_fable", "reasoning_fable"), |
| ("opus", "model_opus", "reasoning_opus"), |
| ("haiku", "model_haiku", "reasoning_haiku"), |
| ("sonnet", "model_sonnet", "reasoning_sonnet"), |
| ) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class ResolvedModel: |
| original_model: str |
| provider_id: str |
| provider_model: str |
| provider_model_ref: str |
| reasoning_preference: ReasoningPreference |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class RoutedMessagesRequest: |
| request: MessagesRequest |
| resolved: ResolvedModel |
| reasoning: ReasoningPolicy |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class RoutedTokenCountRequest: |
| request: TokenCountRequest |
| resolved: ResolvedModel |
|
|
|
|
| class ModelRouter: |
| """Resolve incoming Claude model names to configured provider/model pairs.""" |
|
|
| def __init__(self, settings: Settings): |
| self._settings = settings |
| |
| |
| |
| self._rotations: dict[str, int] = {} |
|
|
| def resolve(self, claude_model_name: str) -> ResolvedModel: |
| ( |
| direct_provider_id, |
| direct_provider_model, |
| force_reasoning_off, |
| ) = self._direct_provider_model(claude_model_name) |
| if direct_provider_id is not None and direct_provider_model is not None: |
| reasoning_preference = ( |
| ReasoningPreference.OFF |
| if force_reasoning_off |
| else self._settings.reasoning_policy |
| ) |
| logger.debug( |
| "MODEL DIRECT: '{}' -> provider='{}' model='{}' reasoning={}", |
| claude_model_name, |
| direct_provider_id, |
| direct_provider_model, |
| reasoning_preference.value, |
| ) |
| return ResolvedModel( |
| original_model=claude_model_name, |
| provider_id=direct_provider_id, |
| provider_model=direct_provider_model, |
| provider_model_ref=claude_model_name, |
| reasoning_preference=reasoning_preference, |
| ) |
|
|
| provider_model_ref = self._resolve_model_ref(claude_model_name) |
| |
| |
| if "/" not in provider_model_ref: |
| resolved = self._resolve_bare_endpoint_model( |
| provider_model_ref, claude_model_name |
| ) |
| if resolved is not None: |
| return resolved |
| reasoning_preference = self._resolve_reasoning_preference(claude_model_name) |
| provider_id = parse_provider_type(provider_model_ref) |
| self._validate_provider_id(provider_id) |
| provider_model = parse_model_name(provider_model_ref) |
| if provider_model != claude_model_name: |
| logger.debug( |
| "MODEL MAPPING: '{}' -> '{}'", claude_model_name, provider_model |
| ) |
| return ResolvedModel( |
| original_model=claude_model_name, |
| provider_id=provider_id, |
| provider_model=provider_model, |
| provider_model_ref=provider_model_ref, |
| reasoning_preference=reasoning_preference, |
| ) |
|
|
| def _resolve_bare_endpoint_model( |
| self, model_id: str, original_model: str |
| ) -> ResolvedModel | None: |
| """Resolve a bare model id to an OpenAI-compatible endpoint. |
| |
| Returns ``None`` when no configured endpoint advertises the id so the |
| caller falls back to the configured model refs. |
| """ |
| candidates = openai_compatible_model_candidates(self._settings, model_id) |
| if not candidates: |
| return None |
| provider_id, _instance = self._select_rotation_candidate(model_id, candidates) |
| self._log_rotation(model_id, candidates, provider_id) |
| return ResolvedModel( |
| original_model=original_model, |
| provider_id=provider_id, |
| provider_model=model_id, |
| provider_model_ref=f"{provider_id}/{model_id}", |
| reasoning_preference=self._resolve_reasoning_preference(original_model), |
| ) |
|
|
| def _select_rotation_candidate( |
| self, |
| model_id: str, |
| candidates: tuple[tuple[str, OpenAICompatibleInstance], ...], |
| ) -> tuple[str, OpenAICompatibleInstance]: |
| """Pick the endpoint for a request, rotating across duplicates.""" |
| if len(candidates) == 1: |
| return candidates[0] |
| counter = self._rotations.get(model_id, 0) |
| self._rotations[model_id] = counter + 1 |
| return candidates[counter % len(candidates)] |
|
|
| @staticmethod |
| def _log_rotation( |
| model_id: str, |
| candidates: tuple[tuple[str, OpenAICompatibleInstance], ...], |
| chosen_provider_id: str, |
| ) -> None: |
| """Log provider/key distribution when one model id spans endpoints.""" |
| entries = [] |
| for provider_id, instance in candidates: |
| key_count = len(split_api_key_pool(instance.api_keys)) |
| entries.append( |
| f"{provider_id} (key {mask_api_key(instance.api_keys)}, " |
| f"{key_count} key{'s' if key_count != 1 else ''})" |
| ) |
| if len(candidates) > 1: |
| logger.info( |
| "MODEL MULTI-PROVIDER: '{}' served by {}; rotating to {} " |
| "(provider + key pool round-robin)", |
| model_id, |
| ", ".join(entries), |
| chosen_provider_id, |
| ) |
| else: |
| logger.debug("MODEL ENDPOINT: '{}' served by {}", model_id, entries[0]) |
|
|
| def _validate_provider_id(self, provider_id: str) -> None: |
| if provider_id in PROVIDER_CATALOG: |
| return |
| |
| |
| |
| |
| if OPENAI_COMPATIBLE_INSTANCE_RE.fullmatch(provider_id): |
| return |
| raise UnknownProviderError.for_provider(provider_id, PROVIDER_CATALOG) |
|
|
| def _direct_provider_model( |
| self, model_name: str |
| ) -> tuple[str | None, str | None, bool]: |
| decoded = decode_gateway_model_id(model_name) |
| if decoded is not None: |
| if decoded.provider_id not in SUPPORTED_PROVIDER_IDS: |
| return None, None, False |
| return ( |
| decoded.provider_id, |
| decoded.provider_model, |
| decoded.force_reasoning_off, |
| ) |
|
|
| provider_id, separator, provider_model = model_name.partition("/") |
| if not separator: |
| return None, None, False |
| if provider_id not in SUPPORTED_PROVIDER_IDS: |
| return None, None, False |
| if not provider_model: |
| return None, None, False |
| return provider_id, provider_model, False |
|
|
| def _resolve_model_ref(self, claude_model_name: str) -> str: |
| """Resolve a Claude model name to the configured provider/model ref.""" |
|
|
| route = self._matched_route(claude_model_name) |
| if route is not None: |
| model = getattr(self._settings, route[1]) |
| if isinstance(model, str): |
| return model |
| return self._settings.model |
|
|
| def _resolve_reasoning_preference( |
| self, claude_model_name: str |
| ) -> ReasoningPreference: |
| """Resolve a route override without inspecting the provider model.""" |
|
|
| route = self._matched_route(claude_model_name) |
| if route is not None: |
| preference = getattr(self._settings, route[2]) |
| if preference is not ReasoningPreference.INHERIT: |
| return preference |
| return self._settings.reasoning_policy |
|
|
| @staticmethod |
| def _matched_route(model_name: str) -> tuple[str, str, str] | None: |
| normalized = model_name.lower() |
| return next( |
| (route for route in _ROUTE_SETTINGS if route[0] in normalized), |
| None, |
| ) |
|
|
| def resolve_messages_request( |
| self, request: MessagesRequest |
| ) -> RoutedMessagesRequest: |
| """Return an internal routed request context.""" |
| resolved = self.resolve(request.model) |
| routed = request.model_copy(deep=True) |
| routed.model = resolved.provider_model |
| return RoutedMessagesRequest( |
| request=routed, |
| resolved=resolved, |
| reasoning=resolve_reasoning_policy( |
| routed, |
| resolved.reasoning_preference, |
| ), |
| ) |
|
|
| def resolve_token_count_request( |
| self, request: TokenCountRequest |
| ) -> RoutedTokenCountRequest: |
| """Return an internal token-count request context.""" |
| resolved = self.resolve(request.model) |
| routed = request.model_copy( |
| update={"model": resolved.provider_model}, deep=True |
| ) |
| return RoutedTokenCountRequest(request=routed, resolved=resolved) |
|
|