Skip to content
Merged
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
20 changes: 19 additions & 1 deletion apps/backend/app/ai/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
from app.ai.providers.capabilities import capability_for
from app.ai.providers.config_store import ProviderConfigStore
from app.ai.providers.factory import ProviderFactory
from app.ai.providers.models import list_models as list_provider_models
from app.ai.providers.models import preferred_model
from app.ai.repository_context import RepositoryContextBuilder
from app.ai.types import PromptBundle
from app.ai.types import DEFAULT_MODELS, PromptBundle
from app.core.exceptions import NotFoundError, ValidationServiceError
from app.repositories.ai_conversation_repository import AiConversationRepository
from app.repositories.repository_repository import RepositoryRepository
from app.schemas.ai import (
AiMessage,
AiProviderConfig,
AiProviderModelsResponse,
AiProviderPublicConfig,
AiProviderTestRequest,
AiProviderTestResponse,
Expand Down Expand Up @@ -54,6 +57,21 @@ async def test_connection(self, request: AiProviderTestRequest) -> AiProviderTes
ok=True, message=f"{config.provider} connection succeeded.", checked_at=datetime.now(UTC)
)

async def list_models(self, request: AiProviderTestRequest) -> AiProviderModelsResponse:
"""Ask the provider what this key can use, rather than making the user guess.

Deliberately shares `config_for_test`, so an unsaved key typed into the
form and a key already stored both resolve the same way -- the list can
be fetched before anything is saved, which is the moment it is needed.
"""

config = self.config_store.config_for_test(request)
models = await list_provider_models(config)
return AiProviderModelsResponse(
models=models,
recommended=preferred_model(models, DEFAULT_MODELS[config.provider]),
)

def list_conversation(self, repository_id: str) -> list[AiMessage]:
# Same owner-scoping as query(): a non-owned repository id is
# indistinguishable from a missing one.
Expand Down
66 changes: 63 additions & 3 deletions apps/backend/app/ai/providers/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ async def post(
) -> httpx.Response:
raise NotImplementedError

async def send(
self,
method: str,
config: AiProviderConfig,
url: str,
*,
timeout: httpx.Timeout | float | None = None,
**kwargs: object,
) -> httpx.Response:
raise NotImplementedError


class RedirectDeniedError(Exception):
"""Signals a redirect without ever evaluating its Location header."""
Expand Down Expand Up @@ -50,6 +61,24 @@ async def post(
timeout: httpx.Timeout | float | None = None,
**kwargs: object,
) -> httpx.Response:
return await self.send("POST", config, url, timeout=timeout, **kwargs)

async def send(
self,
method: str,
config: AiProviderConfig,
url: str,
*,
timeout: httpx.Timeout | float | None = None,
**kwargs: object,
) -> httpx.Response:
"""One outbound request through the policy, pinned to a validated IP.

``method`` is the only thing that varies: model discovery is a GET
against the same host and base path a completion POSTs to, so it goes
through exactly this validation rather than a second, looser path.
"""

# Policy preparation performs DNS resolution. Keep that blocking call
# off the event loop used by the async AI routes.
pinned = await anyio.to_thread.run_sync(self.policy.prepare_request, config, url)
Expand All @@ -74,7 +103,7 @@ async def post(
follow_redirects=False,
transport=transport,
) as client:
request = client.build_request("POST", pinned.connection_url, headers=request_headers, **kwargs)
request = client.build_request(method, pinned.connection_url, headers=request_headers, **kwargs)
# httpcore's documented request extension preserves TLS SNI (and
# therefore hostname verification) when connecting to a literal IP.
request.extensions["sni_hostname"] = pinned.destination.host
Expand Down Expand Up @@ -118,12 +147,25 @@ def _plain_language_status_error(config: AiProviderConfig, exc: httpx.HTTPStatus
if status in (400, 404, 422):
return ValidationServiceError(
"AI provider rejected the request, most likely because of an unsupported model ID. "
"Confirm the model ID and try again.",
"Use Fetch models to see what this key can use, then pick one.",
{"provider": config.provider},
)
return ExternalServiceError("AI provider request failed.", {"provider": config.provider})


async def get(
config: AiProviderConfig,
url: str,
*,
sender: ProviderHttpSender | None = None,
timeout: httpx.Timeout | float | None = None,
**kwargs: object,
) -> httpx.Response:
"""A GET with the same policy, pinning and error translation as ``post``."""

return await _request("GET", config, url, sender=sender, timeout=timeout, **kwargs)


async def post(
config: AiProviderConfig,
url: str,
Expand All @@ -139,9 +181,27 @@ async def post(
far longer than any hosted API call.
"""

return await _request("POST", config, url, sender=sender, timeout=timeout, **kwargs)


async def _request(
method: str,
config: AiProviderConfig,
url: str,
*,
sender: ProviderHttpSender | None = None,
timeout: httpx.Timeout | float | None = None,
**kwargs: object,
) -> httpx.Response:
active_sender = sender or _default_sender()
try:
response = await active_sender.post(config, url, timeout=timeout, **kwargs)
# A POST still goes through `post`: that is the method every existing
# sender -- including the fakes tests inject -- already implements, and
# routing it through `send` instead would silently bypass any of them.
if method == "POST":
response = await active_sender.post(config, url, timeout=timeout, **kwargs)
else:
response = await active_sender.send(method, config, url, timeout=timeout, **kwargs)
response.raise_for_status()
return response
except DestinationPolicyError as exc:
Expand Down
128 changes: 128 additions & 0 deletions apps/backend/app/ai/providers/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Ask each provider which models the caller's key can actually use (#291).

Typing a model ID by hand is where provider setup fails. A default that was
correct when it was written stops being correct -- `gemini-1.5-flash` is not
available to a Google AI Studio project created today -- and the only signal
the user gets is a 400 from the provider, with no way to discover what they
should have typed instead.

Every supported provider publishes its own model list, so nothing here is a
guess: the list is fetched with the user's own key, over the same egress
policy and pinned connection a completion uses, and the caller picks from
what came back.
"""

from __future__ import annotations

from app.ai.providers.http import ProviderHttpSender, get, require_api_key
from app.ai.types import AiProviderConfig
from app.core.exceptions import ExternalServiceError, ValidationServiceError

#: Tier hints, cheapest-and-fastest first. Used only to preselect a sensible
#: entry from a list the provider itself returned -- never to name a model
#: that was not in it.
_PREFERRED_SUBSTRINGS = ("flash-lite", "flash", "mini", "haiku", "small", "turbo")


async def list_models(config: AiProviderConfig, *, sender: ProviderHttpSender | None = None) -> list[str]:
"""Model IDs this configuration can use, sorted, or a plain-language error."""

lister = _LISTERS.get(config.provider)
if lister is None:
raise ValidationServiceError("Unsupported AI provider.", {"provider": config.provider})
models = await lister(config, sender)
if not models:
raise ExternalServiceError(
"The AI provider returned no usable models for this key.",
{"provider": config.provider},
)
return sorted(set(models))


def preferred_model(models: list[str], default: str) -> str:
"""The entry a first-time user should start on.

The saved default wins when the provider still offers it, so an existing
configuration is never quietly moved. Otherwise the cheapest tier whose
name the provider itself published, and failing that the first entry --
the point is to land on something that works, not to rank models.
"""

if default in models:
return default
for hint in _PREFERRED_SUBSTRINGS:
for model in models:
if hint in model:
return model
return models[0]


async def _openai_models(config: AiProviderConfig, sender: ProviderHttpSender | None) -> list[str]:
require_api_key(config)
response = await get(
config,
"https://api.openai.com/v1/models",
sender=sender,
headers={"Authorization": f"Bearer {config.api_key or ''}"},
)
return [str(item["id"]) for item in response.json().get("data", []) if item.get("id")]


async def _anthropic_models(config: AiProviderConfig, sender: ProviderHttpSender | None) -> list[str]:
require_api_key(config)
response = await get(
config,
"https://api.anthropic.com/v1/models?limit=100",
sender=sender,
headers={"x-api-key": config.api_key or "", "anthropic-version": "2023-06-01"},
)
return [str(item["id"]) for item in response.json().get("data", []) if item.get("id")]


async def _gemini_models(config: AiProviderConfig, sender: ProviderHttpSender | None) -> list[str]:
require_api_key(config)
response = await get(
config,
"https://generativelanguage.googleapis.com/v1beta/models?pageSize=200",
sender=sender,
headers={"x-goog-api-key": config.api_key or ""},
)
models: list[str] = []
for item in response.json().get("models", []):
name = str(item.get("name", ""))
# Gemini returns "models/<id>" and lists every model the key can see,
# including embedding-only ones that cannot answer a prompt at all.
if not name.startswith("models/"):
continue
if "generateContent" not in (item.get("supportedGenerationMethods") or []):
continue
models.append(name.removeprefix("models/"))
return models


async def _openrouter_models(config: AiProviderConfig, sender: ProviderHttpSender | None) -> list[str]:
require_api_key(config)
response = await get(
config,
"https://openrouter.ai/api/v1/models",
sender=sender,
headers={"Authorization": f"Bearer {config.api_key or ''}"},
)
return [str(item["id"]) for item in response.json().get("data", []) if item.get("id")]


async def _ollama_models(config: AiProviderConfig, sender: ProviderHttpSender | None) -> list[str]:
base = (config.base_url or "").rstrip("/")
if not base:
raise ValidationServiceError("Base URL is required for the selected AI provider.")
response = await get(config, f"{base}/api/tags", sender=sender)
return [str(item["name"]) for item in response.json().get("models", []) if item.get("name")]


_LISTERS = {
"openai": _openai_models,
"anthropic": _anthropic_models,
"gemini": _gemini_models,
"openrouter": _openrouter_models,
"ollama": _ollama_models,
}
17 changes: 13 additions & 4 deletions apps/backend/app/ai/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@

from app.schemas.ai import AiCitation, AiProvider, AiProviderConfig

#: The model a provider starts on before anyone has fetched a list.
#:
#: A hardcoded default is a fact with a shelf life: `gemini-1.5-flash` was
#: correct when it was written and is not offered at all to a Google AI Studio
#: project created today, so every new user met "AI provider rejected the
#: request" with no way to discover what to type instead. These are kept
#: current, but the real answer to that problem is `providers/models.py`, which
#: asks the provider what this key can use -- a default is only ever the
#: starting point, never the only route to a working configuration.
DEFAULT_MODELS: dict[AiProvider, str] = {
"openai": "gpt-4o-mini",
"anthropic": "claude-3-5-haiku-latest",
"gemini": "gemini-1.5-flash",
"openrouter": "openai/gpt-4o-mini",
"openai": "gpt-4.1-mini",
"anthropic": "claude-haiku-4-5-20251001",
"gemini": "gemini-2.5-flash",
"openrouter": "openai/gpt-4.1-mini",
"ollama": "llama3.2",
}

Expand Down
26 changes: 26 additions & 0 deletions apps/backend/app/api/routes/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
AiConversationResponse,
AiProviderCapabilitiesResponse,
AiProviderConfig,
AiProviderModelsResponse,
AiProviderPublicConfig,
AiProviderTestRequest,
AiProviderTestResponse,
Expand Down Expand Up @@ -37,6 +38,10 @@
"summary": "Test the saved provider configuration",
"value": {"provider": "openai", "model": "gpt-4.1-mini"},
}
_MODELS_REQUEST_EXAMPLE = {
"summary": "List the models a key can use, before saving it",
"value": {"provider": "gemini", "apiKey": "AIza-example-not-a-real-key"},
}
_CAPABILITIES_EXAMPLE = {
"providers": [
{
Expand Down Expand Up @@ -176,6 +181,27 @@ async def test_ai_config(
return await service.test_connection(request)


@router.post(
"/models",
response_model=AiProviderModelsResponse,
responses=documented_responses(
200,
"Model IDs the provider reports for this key, with the one a first-time setup should start on.",
{"models": ["gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"], "recommended": "gemini-2.0-flash"},
401,
422,
429,
502,
500,
),
)
async def list_ai_models(
request: Annotated[AiProviderTestRequest, Body(openapi_examples={"models": _MODELS_REQUEST_EXAMPLE})],
service: AiService = Depends(get_ai_service),
) -> AiProviderModelsResponse:
return await service.list_models(request)


@router.post(
"/query",
response_model=AiQueryResponse,
Expand Down
12 changes: 12 additions & 0 deletions apps/backend/app/schemas/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ class AiProviderTestResponse(CamelModel):
checked_at: datetime = Field(default_factory=lambda: datetime.now(UTC))


class AiProviderModelsResponse(CamelModel):
"""The model IDs this provider reports for the caller's own key (#291).

Every entry came back from the provider, so the list is what the key can
actually use rather than what was true when a default was last written.
``recommended`` is one of ``models``, never a value invented here.
"""

models: list[str]
recommended: str


class AiProviderCapability(CamelModel):
"""Safe, non-secret setup metadata for one provider (#291).

Expand Down
4 changes: 4 additions & 0 deletions apps/backend/app/services/ai_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
AiProviderCapability,
AiProviderPublicConfig,
AiProviderTestRequest,
AiProviderModelsResponse,
AiProviderTestResponse,
AiQueryRequest,
AiQueryResponse,
Expand Down Expand Up @@ -43,6 +44,9 @@ def save_config(self, config: AiProviderConfig) -> AiProviderPublicConfig:
async def test_connection(self, request: AiProviderTestRequest) -> AiProviderTestResponse:
return await self.orchestrator.test_connection(request)

async def list_models(self, request: AiProviderTestRequest) -> AiProviderModelsResponse:
return await self.orchestrator.list_models(request)

async def query(self, request: AiQueryRequest) -> AiQueryResponse:
return await self.orchestrator.query(request)

Expand Down
Loading
Loading