"""``/tools/*`` — read-only registry view. Two endpoints: - ``GET /tools?category=`` — list, optionally filtered to one category. - ``GET /tools/{name}`` — single tool detail. Both wrap :func:`src.lib.tools.repo.load_registry`. No paid calls. """ from __future__ import annotations from fastapi import APIRouter, Depends, Path, Query from src.api.deps import forbid_readers from src.api.dto.tools import ( ToolCategoryEnum, ToolDTO, ToolPricingDTO, ToolStatusEnum, ) from src.api.errors import ApiError from src.lib.auth.models import User from src.lib.llm_keys.store import KNOWN_PROVIDERS, resolve_provider_key from src.lib.tools.models import Tool from src.lib.tools.repo import get_tool, load_registry router = APIRouter(prefix="/tools", tags=["tools"]) class ToolNotFound(ApiError): """404 for ``GET /tools/{name}`` when the registry has no such tool. Local to this router — every other 404 is on the canonical list in ``src/api/errors.py``. We don't add this to the central module because the tools registry is a single YAML file and an "unknown tool" lookup is more of a programming error than a real user-facing state. """ code = "TOOL_NOT_FOUND" status_code = 404 def _to_dto(tool: Tool) -> ToolDTO: pricing = None if tool.pricing is not None: # tools_registry.yaml uses YAML date literals; PyYAML parses them to # ``datetime.date`` objects. The wire shape is a string (ISO date) — # the FE renders it as-is in the "last verified" column. last_updated_raw = tool.pricing.last_updated last_updated = str(last_updated_raw) if last_updated_raw is not None else None pricing = ToolPricingDTO( input_per_million_usd=tool.pricing.input_per_million_usd, output_per_million_usd=tool.pricing.output_per_million_usd, image_input_tokens_per_image=tool.pricing.image_input_tokens_per_image, per_request_usd=tool.pricing.per_request_usd, last_updated=last_updated, verified=tool.pricing.verified, ) # Task A1b: for llm tools, surface whether the provider's key is # resolvable (store or env) so the FE picker can disable unconfigured # providers. Only meaningful for the 5 managed LLM providers; None for # everything else. Never exposes the key value. active: bool | None = None if tool.category == "llm" and tool.provider in KNOWN_PROVIDERS: active = resolve_provider_key(tool.provider) is not None return ToolDTO( name=tool.name, category=ToolCategoryEnum(tool.category), status=ToolStatusEnum(tool.status), used_by=list(tool.used_by), compatible_with=list(tool.compatible_with), source_file=tool.source_file, provider=tool.provider, pricing=pricing, notes=tool.notes, active=active, ) @router.get("", response_model=list[ToolDTO]) def list_tools( _: User = Depends(forbid_readers), category: ToolCategoryEnum | None = Query(default=None), ) -> list[ToolDTO]: """All tools, optionally filtered to one category. Active tools sort first, then stubbed, then planned (matches the Operations Tools page's column order). """ rank = {"active": 0, "stubbed": 1, "planned": 2} tools = list(load_registry()) if category is not None: tools = [t for t in tools if t.category == category.value] tools.sort(key=lambda t: (rank[t.status], t.name)) return [_to_dto(t) for t in tools] @router.get("/{name}", response_model=ToolDTO) def get_tool_endpoint( name: str = Path(...), _: User = Depends(forbid_readers), ) -> ToolDTO: tool = get_tool(name) if tool is None: raise ToolNotFound(f"No tool named {name!r} in tools_registry.yaml.") return _to_dto(tool)