From 832ab7dfa1f54e340fb9099552c50b44f85979e5 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 8 Jul 2026 13:00:50 +0200 Subject: [PATCH] =?UTF-8?q?refactor(gateway):=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=E6=9A=B4=E9=9C=B2=20probe=5Fconnection=20=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E6=8E=A2=E6=B5=8B=E2=80=94=E2=80=94provider=5Fdeps=20?= =?UTF-8?q?=E5=BC=83=E7=A7=81=E6=9C=89=20=5Fclient=20=E5=8F=8D=E6=89=8B?= =?UTF-8?q?=EF=BC=88CR-M1.4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/ww_api/services/provider_deps.py | 14 ++++++-- .../llm_gateway/tests/test_openai_adapter.py | 34 +++++++++++++++++++ .../ww_llm_gateway/adapters/openai_compat.py | 8 +++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/apps/api/ww_api/services/provider_deps.py b/apps/api/ww_api/services/provider_deps.py index 5fe6c2c..af2bbf2 100644 --- a/apps/api/ww_api/services/provider_deps.py +++ b/apps/api/ww_api/services/provider_deps.py @@ -14,6 +14,7 @@ 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_llm_gateway.adapters.openai_compat import OpenAICompatAdapter from ww_llm_gateway.factory import build_adapter from ww_shared import AppError, ErrorCode @@ -85,10 +86,17 @@ class GatewayProviderProbe: # 经网关工厂构造适配器(base_url 单点归网关 build_adapter,不在此本地构造 AsyncOpenAI)。 adapter = build_adapter(provider, api_key=api_key, base_url=base_url) + # `_PROVIDER_BASE_URLS` 里的 api_key 型 provider 均映射到 OpenAI 兼容适配器(含 + # kimi-code-key 子类);据此收窄类型,用公开 `probe_connection` 探测(不反手私有客户端)。 + if not isinstance(adapter, OpenAICompatAdapter): + raise AppError( + ErrorCode.VALIDATION, + f"provider {provider} 不支持 api_key 连接测试", + {"provider": provider}, + ) try: - # 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。底层 AsyncOpenAI 由适配器持有 - # (已知 provider 为 OpenAI 兼容,见 _PROVIDER_BASE_URLS);读私有客户端属同仓既有约定。 - await adapter._client.models.list() # type: ignore[attr-defined] # noqa: SLF001 + # 最小探测:列模型即可验证 Key 有效(不消耗生成额度)。 + await adapter.probe_connection() except Exception as exc: # noqa: BLE001 — 任一失败都映射为 LLM 不可用 raise AppError( ErrorCode.LLM_UNAVAILABLE, diff --git a/packages/llm_gateway/tests/test_openai_adapter.py b/packages/llm_gateway/tests/test_openai_adapter.py index 5b935ba..a7d74c8 100644 --- a/packages/llm_gateway/tests/test_openai_adapter.py +++ b/packages/llm_gateway/tests/test_openai_adapter.py @@ -7,6 +7,7 @@ from collections.abc import AsyncIterator from types import SimpleNamespace from typing import Any, cast +import pytest from openai import AsyncOpenAI from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter, _messages from ww_llm_gateway.types import Block, LlmRequest, Scope @@ -100,3 +101,36 @@ async def test_stream_yields_text_then_final_usage() -> None: assert "".join(texts) == "他说" assert usage_seen is not None assert usage_seen.output_tokens == 2 + + +class _FakeModels: + def __init__(self, *, error: Exception | None = None) -> None: + self._error = error + self.calls = 0 + + async def list(self) -> Any: + self.calls += 1 + if self._error is not None: + raise self._error + return SimpleNamespace(data=[]) + + +async def test_probe_connection_lists_models() -> None: + # CR-M1.4:连通性探测经公开 `probe_connection`(不再让调用方反手私有 `_client`)。 + models = _FakeModels() + client = SimpleNamespace(models=models) + adapter = OpenAICompatAdapter("deepseek", cast(AsyncOpenAI, client)) + + await adapter.probe_connection() + + assert models.calls == 1 + + +async def test_probe_connection_propagates_failure() -> None: + # 探测失败原样上抛(调用方据此映射 LLM 不可用)。 + models = _FakeModels(error=RuntimeError("bad key")) + client = SimpleNamespace(models=models) + adapter = OpenAICompatAdapter("deepseek", cast(AsyncOpenAI, client)) + + with pytest.raises(RuntimeError): + await adapter.probe_connection() diff --git a/packages/llm_gateway/ww_llm_gateway/adapters/openai_compat.py b/packages/llm_gateway/ww_llm_gateway/adapters/openai_compat.py index 3735094..fe47453 100644 --- a/packages/llm_gateway/ww_llm_gateway/adapters/openai_compat.py +++ b/packages/llm_gateway/ww_llm_gateway/adapters/openai_compat.py @@ -103,6 +103,14 @@ class OpenAICompatAdapter: def capabilities(self) -> Capabilities: return Capabilities(structured_output=True, prefix_cache=True, thinking=False) + async def probe_connection(self) -> None: + """最小连通性探测:列模型即验 Key 有效(不消耗生成额度),失败原样上抛(CR-M1.4)。 + + 凭据连接测试的**公开入口**——调用方(apps/api provider_deps)经此验 Key,无需 + 反手适配器私有客户端。任一异常上抛,由调用方映射为 LLM 不可用。 + """ + await self._client.models.list() + def _structured(self) -> StructuredClient: if self._structured_client is None: # 懒构建:从同一 AsyncOpenAI client patch 出 instructor 客户端。