Files
writer-work-flow/apps/api/ww_api/routers/settings_providers.py
Yaojia Wang 345cc73965 fix(txn+security): 仓储改 flush + 启动校验/兜底 + job.error 脱敏 + SSE 异常硬化
P0-1 SqlCredentialStore/save_draft 由自提交改 flush,端点/服务统一 commit
  (新增 CredentialStore.commit() 统一提交点;token 刷新落库显式提交);
  补多凭据一请求中途失败整体回滚集成测试。
P0-2 启动校验 _fernet(enc_key) 快速失败 + catch-all Exception → ErrorEnvelope;
  credential_enc_key 改 SecretStr。
P0-3 run_job 异常分类:AppError 存 code+message,其余存通用文案不泄 str(exc)。
P0-4 评审/正文 SSE 失败先发 error 事件,尾部 commit 包 try/except。
P1-4 max_version 加 FOR UPDATE 行锁消除 TOCTOU。
P1-5 scan_overdue 谓词下推 + 批量 UPDATE RETURNING。
P1-10 移除 OAuth user_code 日志。
P2 provider_deps 改调网关 build_adapter;accept_service Committable Protocol;
  CORS 白名单收窄;request_id 安全字符集白名单;stdlib 日志接管;读端点 404 校验;
  httpx timeout;测试用合法 Fernet key;类型化响应模型(JobResponse/DimensionEntry/
  ReviewConflictView/selling_points)+路由 ErrorEnvelope responses(供 codegen)。
2026-06-21 19:32:24 +02:00

134 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""提供商凭据与档位路由端点C3 / ARCH §4.7, §7.2UX §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),
)