"""提供商凭据与档位路由端点(C3 / ARCH §4.7, §7.2;UX §6.10)。 - GET /settings/providers 列出已配置提供商(掩码)+ 档位路由。 - PUT /settings/providers 幂等 upsert 凭据/档位路由,回掩码视图。 - POST /settings/providers/test 最小探测验 Key + 拉能力矩阵。 不变量:响应/日志绝不含明文 Key(加密入库、仅掩码出站)。 """ from __future__ import annotations from typing import Annotated from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from ww_config import get_settings from ww_db import get_session from ww_llm_gateway.adapters.base import Capabilities from ww_api.logging_config import get_logger from ww_api.schemas.providers import ( CapabilitiesView, ProvidersResponse, ProvidersUpsertRequest, ProviderView, TestConnectionRequest, TestConnectionResponse, TierRoutingView, ) from ww_api.security.credentials import encrypt_api_key, mask_api_key from ww_api.services.credentials import ( STUB_OWNER_ID, CredentialStore, ProviderProbe, StoredCredential, StoredRouting, ) from ww_api.services.provider_deps import ( get_credential_store, get_provider_probe, ) log = get_logger("ww.api.providers") router = APIRouter(prefix="/settings/providers", tags=["settings"]) StoreDep = Annotated[CredentialStore, Depends(get_credential_store)] ProbeDep = Annotated[ProviderProbe, Depends(get_provider_probe)] SessionDep = Annotated[AsyncSession, Depends(get_session)] def _mask_credential(cred: StoredCredential, plaintext: str | None) -> ProviderView: # 存储层只有密文;掩码需明文末四位——若没有则全隐占位。 masked = mask_api_key(plaintext) if plaintext is not None else mask_api_key("") return ProviderView(provider=cred.provider, masked_key=masked) def _routing_view(r: StoredRouting) -> TierRoutingView: return TierRoutingView(tier=r.tier, provider=r.provider, model=r.model, fallback=r.fallback) async def _build_response(store: CredentialStore) -> ProvidersResponse: creds = await store.list_credentials(STUB_OWNER_ID) routing = await store.list_routing() # 列表视图无明文,掩码占位(不解密历史密文以免无谓 IO/泄露面)。 providers = [ProviderView(provider=c.provider, masked_key=mask_api_key("")) for c in creds] return ProvidersResponse( providers=providers, tier_routing=[_routing_view(r) for r in routing], ) @router.get("") async def list_providers(store: StoreDep) -> ProvidersResponse: return await _build_response(store) @router.put("") async def upsert_providers( body: ProvidersUpsertRequest, store: StoreDep, session: SessionDep, ) -> ProvidersResponse: enc_key = get_settings().credential_enc_key.get_secret_value() for cred in body.credentials: api_key_enc = encrypt_api_key(cred.api_key, key=enc_key) await store.upsert_credential(STUB_OWNER_ID, cred.provider, api_key_enc) # 仅记 provider + 末四位掩码,绝不记明文 log.info( "provider_credential_upserted", provider=cred.provider, masked_key=mask_api_key(cred.api_key), ) for routing in body.tier_routing: await store.upsert_routing( StoredRouting( tier=routing.tier, provider=routing.provider, model=routing.model, fallback=routing.fallback, ) ) log.info( "tier_routing_upserted", tier=routing.tier, provider=routing.provider, model=routing.model, ) # 所有凭据/路由写入只 flush;此处统一一次提交——任一步失败则整体回滚(原子性)。 await session.commit() return await _build_response(store) def _capabilities_view(caps: Capabilities) -> CapabilitiesView: return CapabilitiesView( structured_output=caps.structured_output, prefix_cache=caps.prefix_cache, thinking=caps.thinking, ) @router.post("/test") async def test_connection( body: TestConnectionRequest, probe: ProbeDep, ) -> TestConnectionResponse: caps = await probe.probe(STUB_OWNER_ID, body.provider) log.info("provider_probe_ok", provider=body.provider) return TestConnectionResponse( provider=body.provider, ok=True, capabilities=_capabilities_view(caps), )