M4b:ai_provider 删除手术①-⑥——神识全面接管模型出口

- ① 后端删除 app/ai_provider/(38 文件,含 anthropic/openai 格式、billing、audio、embedding)、services/ai_provider_admin.py、core/config/model_config.py、tests/ai_provider/(21 文件)
- ② 连带退役 application/agent/(旧 Agent 循环/steer/follow-up/确认注册表)、application/skill_executor.py、app/skills/(python skills 孤儿)、endpoints/audio.py、application/imports.py(ProviderManager 消费方);settings 端点收敛为生成偏好
- ③ runs.py 修剪:AgentRunRegistry/steer/follow-up/confirmations 端点退役,保留 list/get/events/cancel/stream;safety 确认改为直接落库裁决(pending→approved/denied)
- ④ 前端删除:models 页、ModelGatewayView、gateway.ts、GatewayPanel(玄关)、ModelGatewayPanel/ModelStrategyForm、audio.ts/useVoiceInput、网关类型与 mock;设置页收敛为外观/语言/通知
- ⑤ 配置收敛:config/models|providers|pricing.example.yaml 归档 shenshi/config/;.env.example 删 provider keys/MODEL_ENDPOINT_POOL/MODEL_CONFIG_PATH,收敛 SHENSHI_*
- ⑥ 验收:rg 审计灵台 Provider SDK import 与 ai_provider 引用归零;后端 528 passed(2 个预存失败经 HEAD 复跑验证与本手术无关)、前端 type-check+201 passed、神识 go vet+test、BFF 4 passed
This commit is contained in:
chenxin committed 2026-08-27 10:02:44 +08:00
1 parent 0d383725b5
commit 462d570751
129 files changed
+81 -15867

No files matched your search

-275
View File
@@ -1,275 +0,0 @@
"""音频相关 API 端点 — TTS(文本转语音)、ASR(语音转文本)等。
音频端点使用神识的模型注册表查找可用的音频模型,并调用对应音频适配器合成 /
转写。ASR 与 TTS 按 ``tags`` 中的 ``asr`` / ``tts`` 能力区分,ASR 内部再按
``provider`` 区分协议:``yuanqi`` 走 multipart ``/v1/audio/transcriptions``,
其他走 MiMo 风格的 ``/chat/completions`` + ``input_audio``。
"""
from __future__ import annotations
from fastapi import APIRouter, File, Form, HTTPException, Response, UploadFile
from pydantic import BaseModel, Field
from app.ai_provider.audio import AudioService, AudioServiceError
from app.ai_provider.manager import ProviderManagerFactory
from app.core.logging import logger
router = APIRouter(tags=["audio"])
_MIME_MAP = {
"wav": "audio/wav",
"mp3": "audio/mpeg",
"opus": "audio/opus",
"flac": "audio/flac",
}
class _AudioModelFactory:
"""Adapter: wraps ProviderManager to provide ModelFactory interface for AudioService.
``create_audio(name)`` 按 ``tags`` 判断 ``asr`` / ``tts`` 能力:
- ``tts`` → ``HunjingTTSOpenAI``(``/chat/completions`` + ``audio``)
- ``asr`` → 按 ``provider`` 分派:
- ``yuanqi`` → ``YuanqiASRWhisper``(multipart ``/v1/audio/transcriptions``)
- 其他 → ``HunjingASROpenAI``(``/chat/completions`` + ``input_audio``)
"""
def __init__(self, manager) -> None:
self._manager = manager
@property
def registry(self):
return self._manager.model_registry
def create_audio(self, name: str):
model = self._manager.get_model_config(name)
is_asr = "asr" in model.tags
if is_asr:
return self._create_asr(model)
return self._create_tts(model)
def _provider_or_none(self, model):
"""安全获取 provider 配置(provider 未配置时返回 None,走纯端点解析)。"""
try:
return self._manager.get_provider_config(model.provider)
except Exception:
return None
# ── ASR ─────────────────────────────────────────────────────────────
def _create_asr(self, model):
"""按 provider 选择 ASR 协议适配器。"""
# yuanqi 本地引擎走 multipart /v1/audio/transcriptions
if model.provider == "yuanqi":
from app.ai_provider.audio.yuanqi_asr import YuanqiASRWhisper
if model.endpoints:
return YuanqiASRWhisper.from_model_config(
model, provider=self._provider_or_none(model)
)
provider = self._manager.get_provider_config(model.provider)
api_key, base_url = self._resolve_credentials(provider)
if not base_url:
raise AudioServiceError(
status_code=500,
code="audio_asr_no_base_url",
message=f"Provider '{model.provider}' has no base_url configured.",
)
return YuanqiASRWhisper(
base_url=base_url,
api_key=api_key or "",
model=model.model,
request_timeout_seconds=model.timeout_seconds or 300,
)
# MiMo 等走 /chat/completions + input_audio
from app.ai_provider.audio.asr import HunjingASROpenAI
if model.endpoints:
return HunjingASROpenAI.from_model_config(
model, provider=self._provider_or_none(model)
)
provider = self._manager.get_provider_config(model.provider)
api_key, base_url = self._resolve_credentials(provider)
if not base_url:
raise AudioServiceError(
status_code=500,
code="audio_asr_no_base_url",
message=f"Provider '{model.provider}' has no base_url configured.",
)
return HunjingASROpenAI(
base_url=base_url,
api_key=api_key or "",
model=model.model,
request_timeout_seconds=model.timeout_seconds or 300,
)
# ── TTS ─────────────────────────────────────────────────────────────
def _create_tts(self, model):
from app.ai_provider.audio.tts import HunjingTTSOpenAI
# 有 endpoints 时交给 from_model_config(按端点的 status/model/超时)
if model.endpoints:
return HunjingTTSOpenAI.from_model_config(
model, provider=self._provider_or_none(model)
)
# 无 endpoint 时回退到 provider 级别凭证
provider = self._manager.get_provider_config(model.provider)
api_key, base_url = self._resolve_credentials(provider)
if not base_url:
raise AudioServiceError(
status_code=500,
code="audio_tts_no_base_url",
message=f"Provider '{model.provider}' has no base_url configured.",
)
return HunjingTTSOpenAI(
base_url=base_url,
api_key=api_key or "",
model=model.model,
request_timeout_seconds=model.timeout_seconds or 120,
)
def _resolve_credentials(self, provider):
"""从 settings 解析 provider 级别的 API key 和 base URL。"""
from app.core.config import settings
api_key = (
settings.__getattribute__(provider.api_key_env.lower())
if hasattr(settings, provider.api_key_env.lower())
else ""
)
base_url = (
settings.__getattribute__(provider.base_url_env.lower())
if hasattr(settings, provider.base_url_env.lower())
else None
)
return api_key, base_url
class TTSRequest(BaseModel):
"""TTS 合成请求。"""
text: str = Field(..., min_length=1, max_length=4096, description="要合成语音的文本。")
voice: str = Field("default", description="语音角色标识(取决于模型,如 default 或声纹 ID)。")
format: str = Field("wav", description="音频格式:wav / mp3 / opus / flac。")
@router.post("/audio/tts")
async def synthesize_speech(body: TTSRequest) -> Response:
"""合成文本为语音,返回音频二进制流。
使用神识注册表中第一个活跃的 TTS 模型(capability: tts)。
未配置 TTS 模型时返回 503。
"""
if not body.text.strip():
raise HTTPException(status_code=400, detail="text 不能为空。")
mime = _MIME_MAP.get(body.format, "audio/wav")
try:
manager = ProviderManagerFactory.create()
service = AudioService(_AudioModelFactory(manager))
async with service:
result = await service.synthesize(
text=body.text,
voice=body.voice,
response_format=body.format,
)
except AudioServiceError as exc:
logger.error(
"audio.tts.api_error",
voice=body.voice,
format=body.format,
error_code=exc.code,
error_message=exc.message,
)
raise HTTPException(
status_code=exc.status_code or 502,
detail=exc.message,
) from exc
logger.info(
"audio.tts.success",
voice=body.voice,
format=body.format,
audio_bytes=len(result.audio_data),
)
return Response(
content=result.audio_data,
media_type=mime,
headers={
"Content-Disposition": f'inline; filename="speech.{body.format}"',
"X-Audio-Format": body.format,
"Cache-Control": "public, max-age=3600",
},
)
# ── ASR(语音转文本)───────────────────────────────────────────────────────
class TranscriptionResponse(BaseModel):
"""OpenAI Whisper 兼容的转写响应。"""
text: str = Field(description="转写后的文本")
@router.post("/audio/transcriptions", response_model=TranscriptionResponse)
async def transcribe_audio(
file: UploadFile = File(..., description="音频文件(wav/mp3/m4a/flac/ogg/opus/webm 等)"),
language: str | None = Form(default=None, description="语言提示(ISO-639-1:zh/en/ja/ko 等),省略则自动检测"),
model: str | None = Form(default=None, description="可选,指定使用的 ASR 模型逻辑名(如 funasr-sensevoice / mimo-v2.5-asr),省略则用注册表中第一个"),
) -> TranscriptionResponse:
"""转写音频为文本,返回 OpenAI Whisper 兼容的 JSON。
不指定 ``model`` 时用神识注册表中第一个活跃的 ASR 模型(capability: asr),
按其 ``provider`` 自动选择协议适配器:``yuanqi`` 走本地 multipart 端点,
其他走 MiMo 风格的 chat completions。未配置 ASR 模型时返回 503。
"""
audio_bytes = await file.read()
if not audio_bytes:
raise HTTPException(status_code=400, detail="音频文件为空。")
fmt = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower() if "." in (file.filename or "") else "wav"
try:
manager = ProviderManagerFactory.create()
service = AudioService(_AudioModelFactory(manager))
async with service:
result = await service.transcribe(
audio_data=audio_bytes,
format=fmt,
language=language,
model_name=model,
)
except AudioServiceError as exc:
logger.error(
"audio.asr.api_error",
filename=file.filename,
format=fmt,
language=language,
error_code=exc.code,
error_message=exc.message,
)
raise HTTPException(
status_code=exc.status_code or 502,
detail=exc.message,
) from exc
logger.info(
"audio.asr.success",
filename=file.filename,
format=fmt,
language=language,
text_length=len(result.text),
text=result.text,
)
return TranscriptionResponse(text=result.text)
+4 -109
View File
@@ -1,32 +1,23 @@
"""Run 与 SSE API。"""
"""Run 与 SSE API(Run 的启动契约见 kernel_runs.py)。"""
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import Any
from fastapi import APIRouter, Header, Query, Request
from fastapi import APIRouter, Header, Query
from sse_starlette.sse import EventSourceResponse
from app.application.agent.confirmation import ConfirmationRegistry
from app.application.agent.run_registry import AgentRunRegistry
from app.core.config import settings
from app.core.errors import AppError
from app.core.time import to_iso, utc_now
from app.db.session import AsyncSessionLocal
from app.repositories import runtime as runtime_repo
from app.schemas.common import Page
from app.schemas.runtime import (
AgentControlRequest,
AgentControlResponse,
ConfirmationResolutionResponse,
EventResponse,
RunResponse,
)
from app.schemas.runtime import EventResponse, RunResponse
from app.services.event_bus import bus
from app.services.runtime import (
cancel_run,
confirmation_exists_in_run,
get_run_response,
list_run_event_responses,
list_runs_response,
@@ -66,107 +57,11 @@ async def list_run_events(
return await list_run_event_responses(run_id, limit=limit, offset=offset)
def _get_agent_run_registry(request: Request) -> AgentRunRegistry:
registry: AgentRunRegistry | None = getattr(request.app.state, "agent_run_registry", None)
if registry is None:
raise AppError("service_unavailable", "Agent 运行控制未启用。", status_code=503)
return registry
@router.post("/{run_id}/cancel", response_model=RunResponse)
async def cancel_run_api(run_id: str, request: Request) -> RunResponse:
registry = getattr(request.app.state, "agent_run_registry", None)
if registry is not None:
await registry.abort(run_id)
async def cancel_run_api(run_id: str) -> RunResponse:
return await cancel_run(run_id)
@router.post("/{run_id}/steer", response_model=AgentControlResponse)
async def steer_run(
run_id: str,
payload: AgentControlRequest,
request: Request,
) -> AgentControlResponse:
if not _get_agent_run_registry(request).steer(run_id, payload.content):
raise AppError("conflict", "Run 当前不在本进程执行,无法 steer。", status_code=409)
return AgentControlResponse()
@router.post("/{run_id}/follow-up", response_model=AgentControlResponse)
async def follow_up_run(
run_id: str,
payload: AgentControlRequest,
request: Request,
) -> AgentControlResponse:
if not _get_agent_run_registry(request).follow_up(run_id, payload.content):
raise AppError("conflict", "Run 当前不在本进程执行,无法追加 follow-up。", status_code=409)
return AgentControlResponse()
def _get_confirmation_registry(request: Request) -> ConfirmationRegistry:
"""从 app.state 取 ConfirmationRegistry;未挂载时 503。"""
registry: ConfirmationRegistry | None = getattr(
request.app.state, "confirmation_registry", None
)
if registry is None:
raise AppError(
"service_unavailable",
"确认机制未启用(进程内 Future 注册表未挂载)",
status_code=503,
)
return registry
async def _resolve_confirmation(
request: Request,
run_id: str,
confirmation_id: str,
approved: bool,
) -> ConfirmationResolutionResponse:
"""approve/deny 共用逻辑:通过 ConfirmationRegistry 更新确认状态。"""
registry = _get_confirmation_registry(request)
if await registry.resolve(run_id, approved, confirmation_id=confirmation_id):
return ConfirmationResolutionResponse(approved=approved)
# 记录已处理/不存在——查 events 表区分 410 与 404
event_store = getattr(request.app.state, "event_store", None)
if event_store is not None and await confirmation_exists_in_run(
event_store, run_id, confirmation_id
):
raise AppError(
"confirmation_expired",
"确认已失效(服务可能已重启或已处理),请重新发起对话",
status_code=410,
)
raise AppError("confirmation_not_found", "确认项不存在", status_code=404)
@router.post(
"/{run_id}/confirmations/{confirmation_id}/approve",
response_model=ConfirmationResolutionResponse,
)
async def approve_confirmation(
run_id: str,
confirmation_id: str,
request: Request,
) -> ConfirmationResolutionResponse:
"""批准高风险工具执行:触发 Agent 循环恢复(confirmed=True 重试)。"""
return await _resolve_confirmation(request, run_id, confirmation_id, approved=True)
@router.post(
"/{run_id}/confirmations/{confirmation_id}/deny",
response_model=ConfirmationResolutionResponse,
)
async def deny_confirmation(
run_id: str,
confirmation_id: str,
request: Request,
) -> ConfirmationResolutionResponse:
"""拒绝高风险工具执行:Agent 循环回填拒绝 ToolMessage。"""
return await _resolve_confirmation(request, run_id, confirmation_id, approved=False)
@router.get("/{run_id}/stream")
async def stream_run(
run_id: str,
+36 -49
View File
@@ -1,16 +1,20 @@
"""安全 API(api-spec §16)。
audit-events 按人物/枢过滤查询审计时间线;confirmations 的 approve/deny 负责
高风险工具确认:通过 ConfirmationRegistry 更新工具确认状态唤醒 Agent 循环。
audit-events 按人物/枢过滤查询审计时间线;confirmations 的 approve/deny 落库裁决
高风险工具确认(pending → approved/denied),客户端随后以 confirmed=True 重新调用。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from datetime import UTC, datetime
from app.api.v1.deps import get_confirmation_registry, get_optional_tool_runtime
from app.application.agent.confirmation import ConfirmationRegistry
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select, update
from app.api.v1.deps import get_optional_tool_runtime
from app.application.tool_runtime.runtime import ToolRuntime
from app.db.session import AsyncSessionLocal
from app.models.safety import PendingConfirmationRecord
from app.schemas.admin import AuditEntry
from app.schemas.common import Page
from app.schemas.safety import (
@@ -102,47 +106,22 @@ def _cancelled(confirmation_id: str) -> dict[str, str]:
@router.post("/confirmations/{confirmation_id}/approve")
async def approve(
confirmation_id: str,
registry: ConfirmationRegistry = Depends(get_confirmation_registry),
) -> dict[str, bool | str]:
"""批准工具执行。通过 ConfirmationRegistry 唤醒等待的 Agent 循环。"""
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.safety import PendingConfirmationRecord
async with AsyncSessionLocal() as session:
result = await session.execute(
select(PendingConfirmationRecord).where(PendingConfirmationRecord.id == confirmation_id)
)
record = result.scalar_one_or_none()
if record is None or record.run_id is None:
raise _not_found(confirmation_id)
# 更新 resolution 并唤醒进程内 Event
ok = await registry.resolve(
record.run_id,
approved=True,
confirmation_id=confirmation_id,
)
if not ok:
raise _not_found(confirmation_id)
async def approve(confirmation_id: str) -> dict[str, bool | str]:
"""批准工具执行:pending 记录落库为 approved,客户端以 confirmed=True 重新调用。"""
record = await _resolve_confirmation_record(confirmation_id, approved=True)
return {"status": "resolved", "approved": True, "run_id": record.run_id}
@router.post("/confirmations/{confirmation_id}/deny")
async def deny(
confirmation_id: str,
registry: ConfirmationRegistry = Depends(get_confirmation_registry),
) -> dict[str, str]:
"""拒绝工具执行。通过 ConfirmationRegistry 通知 Agent 循环。"""
from sqlalchemy import select
async def deny(confirmation_id: str) -> dict[str, str]:
"""拒绝工具执行:pending 记录落库为 denied。"""
await _resolve_confirmation_record(confirmation_id, approved=False)
return {"status": "cancelled", "confirmation_id": confirmation_id}
from app.db.session import AsyncSessionLocal
from app.models.safety import PendingConfirmationRecord
async def _resolve_confirmation_record(
confirmation_id: str, *, approved: bool
) -> PendingConfirmationRecord:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(PendingConfirmationRecord).where(PendingConfirmationRecord.id == confirmation_id)
@@ -151,12 +130,20 @@ async def deny(
if record is None or record.run_id is None:
raise _not_found(confirmation_id)
ok = await registry.resolve(
record.run_id,
approved=False,
confirmation_id=confirmation_id,
)
if not ok:
raise _not_found(confirmation_id)
return {"status": "cancelled", "confirmation_id": confirmation_id}
async with AsyncSessionLocal() as session:
stmt = (
update(PendingConfirmationRecord)
.where(
PendingConfirmationRecord.id == confirmation_id,
PendingConfirmationRecord.status == "pending",
)
.values(
status="approved" if approved else "denied",
resolved_at=datetime.now(UTC),
)
)
updated = await session.execute(stmt)
await session.commit()
if updated.rowcount == 0:
raise _not_found(confirmation_id)
return record
+3 -38
View File
@@ -1,60 +1,25 @@
"""设置 API(模型网关状态 + 生成偏好)。
"""设置 API(生成偏好)。
对应前端 settingsApi。模型网关为只读总览,生成偏好可读可写;
真实密钥不进入响应。业务逻辑在 services/preferences(与系统配置 core/config/settings 区分)。
生成偏好可读可写;模型网关状态由神识承接(用户面见神识阁)。
业务逻辑在 services/preferences(与系统配置 core/config/settings 区分)。
"""
from fastapi import APIRouter, Depends
from app.api.v1.deps import get_current_user
from app.core.errors import AppError
from app.schemas.auth import CurrentUser
from app.schemas.preferences import (
GenerationPreferencesResponse,
ModelEndpointStatus,
ModelGatewayStatus,
UpdateEndpointOverrideRequest,
UpdateGenerationPreferencesRequest,
)
from app.services.ai_provider_admin import update_endpoint_override
from app.services.preferences import (
get_generation_preferences,
get_provider_gateway,
update_generation_preferences,
)
router = APIRouter(prefix="/settings", tags=["settings"])
@router.get("/models", response_model=ModelGatewayStatus)
async def read_provider_gateway() -> ModelGatewayStatus:
return get_provider_gateway()
@router.patch(
"/models/{model}/endpoints/{endpoint}",
response_model=ModelEndpointStatus,
)
async def update_model_endpoint(
model: str,
endpoint: str,
payload: UpdateEndpointOverrideRequest,
) -> ModelEndpointStatus:
"""更新 endpoint 覆盖(base_url / api_key / status / weight / priority,DB 优先 env)。
写后热重载 registry(reset_model_router)。响应脱敏,不回 base_url / api_key 原值。
"""
await update_endpoint_override(model, endpoint, payload)
gateway = get_provider_gateway()
for m in gateway.models:
if m.name != model:
continue
for ep in m.endpoints or []:
if ep.name == endpoint:
return ep
raise AppError("not_found", "endpoint 不存在。")
@router.get("/preferences", response_model=GenerationPreferencesResponse)
async def read_generation_preferences(
current_user: CurrentUser = Depends(get_current_user),