Files
writer-work-flow/apps/api/ww_api/routers/settings_providers.py
Yaojia Wang b523b4fd21 feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider
- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由
- 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本)
- LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error)
- API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接)
- 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页
- M1 E2E:真实 DB + mock 网关零 token 走通闭环
2026-06-18 11:38:28 +02:00

128 lines
4.1 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 ww_config import get_settings
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)]
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,
) -> ProvidersResponse:
enc_key = get_settings().credential_enc_key
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,
)
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),
)