diff --git a/routstr/algorithm.py b/routstr/algorithm.py index 7dffcae1..845bca57 100644 --- a/routstr/algorithm.py +++ b/routstr/algorithm.py @@ -118,6 +118,13 @@ def create_model_mappings( candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {} unique_models: dict[str, "Model"] = {} + seen_model_provider: set[tuple[str, str]] = set() + + providers_by_db_id: dict[int, "BaseUpstreamProvider"] = {} + for upstream in upstreams: + db_id = getattr(upstream, "db_id", None) + if isinstance(db_id, int): + providers_by_db_id[db_id] = upstream # Separate OpenRouter from other providers openrouter: "BaseUpstreamProvider" | None = None @@ -148,6 +155,9 @@ def create_model_mappings( ) -> None: """Process all models from a given provider.""" upstream_prefix = getattr(upstream, "upstream_name", None) + provider_key = getattr(upstream, "provider_type", "") or getattr( + upstream, "base_url", "" + ) for model in upstream.get_cached_models(): if not model.enabled or model.id in disabled_model_ids: @@ -189,6 +199,7 @@ def create_model_mappings( # Try to set each alias for alias in aliases: _add_candidate(alias, model_to_use, upstream) + seen_model_provider.add((model_to_use.id.lower(), provider_key.lower())) # Process non-OpenRouter providers first for upstream in other_upstreams: @@ -198,6 +209,65 @@ def create_model_mappings( if openrouter: process_provider_models(openrouter, is_openrouter=True) + # Include enabled DB overrides even when provider discovery misses models. + # This is important for deployment-based providers like Azure. + for model_id, override_data in overrides_by_id.items(): + if model_id in disabled_model_ids: + continue + try: + override_row, provider_fee = override_data + upstream_provider_id = getattr(override_row, "upstream_provider_id", None) + if not isinstance(upstream_provider_id, int): + continue + + upstream = providers_by_db_id.get(upstream_provider_id) + if upstream is None: + continue + + provider_key = getattr(upstream, "provider_type", "") or getattr( + upstream, "base_url", "" + ) + dedupe_key = (model_id.lower(), provider_key.lower()) + if dedupe_key in seen_model_provider: + continue + + model_to_use = _row_to_model( + override_row, apply_provider_fee=True, provider_fee=provider_fee + ) + if not model_to_use.enabled: + continue + + base_id = get_base_model_id(model_to_use.id) + is_openrouter = ( + getattr(upstream, "base_url", "") == "https://openrouter.ai/api/v1" + ) + if not is_openrouter or base_id not in unique_models: + unique_model = model_to_use.copy( + update={ + "id": base_id, + "upstream_provider_id": upstream.provider_type, + } + ) + unique_models[base_id] = unique_model + + aliases = resolve_model_alias( + model_to_use.id, + model_to_use.canonical_slug, + alias_ids=model_to_use.alias_ids, + ) + upstream_prefix = getattr(upstream, "upstream_name", None) + if upstream_prefix and "/" not in model_to_use.id: + prefixed_id = f"{upstream_prefix}/{model_to_use.id}" + if prefixed_id not in aliases: + aliases.append(prefixed_id) + + for alias in aliases: + _add_candidate(alias, model_to_use, upstream) + seen_model_provider.add(dedupe_key) + except Exception: + # Keep model map creation resilient to malformed overrides. + continue + # Sort candidates and build final maps model_instances: dict[str, "Model"] = {} provider_map: dict[str, list["BaseUpstreamProvider"]] = {} diff --git a/routstr/upstream/azure.py b/routstr/upstream/azure.py index b6240fbd..b9293503 100644 --- a/routstr/upstream/azure.py +++ b/routstr/upstream/azure.py @@ -1,9 +1,14 @@ from typing import TYPE_CHECKING, Mapping +from fastapi import Request +from fastapi.responses import Response, StreamingResponse + from .base import BaseUpstreamProvider if TYPE_CHECKING: - from ..core.db import UpstreamProviderRow + from ..auth import ApiKey + from ..core.db import AsyncSession, UpstreamProviderRow + from ..payment.models import Model class AzureUpstreamProvider(BaseUpstreamProvider): @@ -58,19 +63,103 @@ class AzureUpstreamProvider(BaseUpstreamProvider): "platform_url": cls.platform_url, } + def prepare_headers(self, request_headers: dict) -> dict: + """Prepare headers for Azure OpenAI, adding api-key.""" + headers = super().prepare_headers(request_headers) + if self.api_key: + headers["api-key"] = self.api_key + headers.pop("Authorization", None) + headers.pop("authorization", None) + return headers + def prepare_params( self, path: str, query_params: Mapping[str, str] | None ) -> Mapping[str, str]: - """Prepare query parameters for Azure OpenAI, adding API version. - - Args: - path: Request path - query_params: Original query parameters from the client - - Returns: - Query parameters dict with Azure API version added for chat completions - """ + """Prepare query parameters for Azure OpenAI, adding API version.""" params = dict(query_params or {}) - if path.endswith("chat/completions"): - params["api-version"] = self.api_version + # Ensure we use a valid Azure API version format + # Strip any hidden characters like Byte Order Marks (BOM) or whitespace + version = self.api_version.strip().replace("\ufeff", "") + if version == "v1": + version = "2024-02-15-preview" + params["api-version"] = version return params + + async def forward_request( + self, + request: Request, + path: str, + headers: dict, + request_body: bytes | None, + key: "ApiKey", + max_cost_for_model: int, + session: "AsyncSession", + model_obj: "Model", + ) -> Response | StreamingResponse: + """Forward request to Azure OpenAI.""" + # Fix: If base_url contains /openai/v1, remove it + actual_base_url = self.base_url + if "/openai/v1" in actual_base_url: + actual_base_url = actual_base_url.split("/openai/v1")[0] + + # Use canonical_slug as it often stores the deployment name in Azure setups + # otherwise fallback to transform_model_name + deployment_id = getattr( + model_obj, "canonical_slug", None + ) or self.transform_model_name(model_obj.id) + + # Ensure deployment_id doesn't contain a provider prefix (e.g., 'openai/' or 'azure/') + if "/" in deployment_id: + deployment_id = deployment_id.split("/")[-1] + + # Azure format: openai/deployments/{deployment-id}/chat/completions + clean_path = path.lstrip("/") + if clean_path.startswith("v1/"): + clean_path = clean_path[3:] + azure_path = f"openai/deployments/{deployment_id}/{clean_path}" + + # Temporary backup and restore base_url to use cleaned version + original_base = self.base_url + self.base_url = actual_base_url + + # The query params are handled by super().forward_request via prepare_params + # We don't need to manually append them to full_url for the print if we want to be accurate + params = self.prepare_params(path, {}) + full_url = ( + f"{actual_base_url}/{azure_path}?api-version={params.get('api-version')}" + ) + print(f"\n[DEBUG] Azure Forwarding URL: {full_url}") + print(f"[DEBUG] Deployment ID: {deployment_id}") + + try: + response = await super().forward_request( + request, + azure_path, + headers, + request_body, + key, + max_cost_for_model, + session, + model_obj, + ) + + # Check if it's an error response to print details + if hasattr(response, "status_code") and response.status_code != 200: + print(f"[DEBUG] Azure Error Status: {response.status_code}") + if hasattr(response, "body"): + print( + f"[DEBUG] Azure Error Body: {response.body.decode() if isinstance(response.body, bytes) else response.body}" + ) + + return response + except Exception as e: + print(f"[DEBUG] Azure Exception: {str(e)}") + raise + finally: + self.base_url = original_base + + def transform_model_name(self, model_id: str) -> str: + """Extract deployment name from model ID.""" + if "/" in model_id: + return model_id.split("/")[-1] + return model_id diff --git a/routstr/upstream/helpers.py b/routstr/upstream/helpers.py index 95b6ca84..92c3a46d 100644 --- a/routstr/upstream/helpers.py +++ b/routstr/upstream/helpers.py @@ -205,6 +205,9 @@ async def init_upstreams() -> list[BaseUpstreamProvider]: provider = _instantiate_provider(provider_row) if provider: + # Keep provider DB id on runtime instance so model mapping can + # bind DB overrides to the correct upstream. + setattr(provider, "db_id", provider_row.id) await provider.refresh_models_cache() logger.debug( f"Initialized {provider_row.provider_type} provider",