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 走通闭环
This commit is contained in:
127
apps/api/ww_api/routers/settings_providers.py
Normal file
127
apps/api/ww_api/routers/settings_providers.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""提供商凭据与档位路由端点(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 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),
|
||||
)
|
||||
Reference in New Issue
Block a user