Add provider model catalogs to news risk settings
Development CI / check-and-build (push) Successful in 1m30s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / deploy-development (push) Successful in 2m28s

This commit is contained in:
eezd committed 2026-10-30 19:11:44 +08:00
1 parent b54863f40a
commit 376d07250e
7 files changed
+235 -4

No files matched your search

+25
View File
@@ -75,6 +75,31 @@ def api_root(base_url: str) -> str:
return root.removesuffix("/v1")
async def list_models(provider: AiProvider) -> list[str]:
"""Read the provider's model catalog without generating a completion."""
options = {'api_key': decrypt(provider.api_key_encrypted), 'timeout': 10, 'max_retries': 0}
root = api_root(provider.base_url)
client = (openai.AsyncOpenAI(base_url=f'{root}/v1', **options) if provider.protocol == 'openai'
else anthropic.AsyncAnthropic(base_url=root, **options))
try:
async with asyncio.timeout(30), client:
models = await client.models.list()
identifiers = set()
async for model in models:
identifier = getattr(model, 'id', None)
if not isinstance(identifier, str) or not identifier.strip():
raise LlmError('模型列表响应缺少有效的模型 ID;请手动填写模型名称')
identifiers.add(identifier)
return sorted(identifiers)
except (openai.APIStatusError, anthropic.APIStatusError) as error:
raise LlmError(f'模型列表接口返回 HTTP {error.status_code};请检查接入权限,或手动填写模型名称') from error
except (TimeoutError, openai.APITimeoutError, anthropic.APITimeoutError,
httpx.TimeoutException, httpx2.TimeoutException) as error:
raise LlmError('读取模型列表超时;可重试或手动填写模型名称') from error
except (openai.APIError, anthropic.APIError, httpx.TransportError, httpx2.TransportError) as error:
raise LlmError('无法读取模型列表;请检查接入配置,或手动填写模型名称') from error
async def stream(
provider: AiProvider, system: str, messages: list[dict], tools: list[ToolSpec], effort: Effort | None = None,
) -> AsyncIterator[TextDelta | ThinkingStarted | Completion]:
+9
View File
@@ -119,6 +119,15 @@ async def create_provider(body: ProviderIn, session: Annotated[AsyncSession, Dep
return success(ProviderOut.model_validate(provider, from_attributes=True))
@router.get('/providers/{provider_id}/models')
async def list_provider_models(provider_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[list[str]]:
provider = await get_provider(session, provider_id)
try:
return success(await llm.list_models(provider))
except llm.LlmError as error:
raise HTTPException(status.HTTP_502_BAD_GATEWAY, str(error)) from error
@router.patch("/providers/{provider_id}")
async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ProviderOut]:
provider = await get_provider(session, provider_id)
+81
View File
@@ -73,3 +73,84 @@ def test_delete_provider_handles_news_risk_reference(trading, monkeypatch, enabl
assert reference.provider_id is None
session.delete.assert_awaited_once_with(provider)
session.commit.assert_awaited_once()
@pytest.mark.parametrize('protocol,base_url', [('openai', 'https://example.invalid/v1/'), ('anthropic', 'https://example.invalid/')])
def test_model_catalog_uses_selected_protocol_and_reads_all_pages(trading, monkeypatch, protocol, base_url):
from alphapulse.ai import llm
options = []
class Page:
async def __aiter__(self):
for identifier in ['model-b', 'model-a', 'model-b']:
yield SimpleNamespace(id=identifier)
class Client:
def __init__(self, **kwargs):
options.append(kwargs)
self.models = SimpleNamespace(list=AsyncMock(return_value=Page()))
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
monkeypatch.setattr(llm, 'decrypt', lambda value: 'test-only-key')
monkeypatch.setattr(llm.openai if protocol == 'openai' else llm.anthropic,
'AsyncOpenAI' if protocol == 'openai' else 'AsyncAnthropic', Client)
provider = SimpleNamespace(protocol=protocol, base_url=base_url, api_key_encrypted=b'test')
assert asyncio.run(llm.list_models(provider)) == ['model-a', 'model-b']
assert options == [{'base_url': 'https://example.invalid/v1' if protocol == 'openai' else 'https://example.invalid',
'api_key': 'test-only-key', 'timeout': 10, 'max_retries': 0}]
@pytest.mark.parametrize('failure', ['http', 'timeout', 'invalid_id'])
def test_model_catalog_failures_remain_explicit_without_exposing_upstream_details(trading, monkeypatch, failure):
from alphapulse.ai import llm
class Page:
async def __aiter__(self):
yield SimpleNamespace(id=None)
class Client:
def __init__(self, **kwargs):
self.models = SimpleNamespace(list=AsyncMock(return_value=Page()))
if failure == 'timeout': self.models.list.side_effect = TimeoutError()
if failure == 'http':
response = httpx.Response(401, request=httpx.Request('GET', 'https://example.invalid/v1/models'))
self.models.list.side_effect = llm.openai.APIStatusError('private-upstream-details', response=response, body=None)
async def __aenter__(self): return self
async def __aexit__(self, *args): pass
monkeypatch.setattr(llm, 'decrypt', lambda value: 'test-only-key')
monkeypatch.setattr(llm.openai, 'AsyncOpenAI', Client)
provider = SimpleNamespace(protocol='openai', base_url='https://example.invalid', api_key_encrypted=b'test')
with pytest.raises(llm.LlmError) as error:
asyncio.run(llm.list_models(provider))
assert 'private-upstream-details' not in str(error.value)
assert '手动填写' in str(error.value)
@pytest.mark.parametrize('failure', [None, 'unavailable', 'missing'])
def test_provider_model_catalog_http_contract(trading, monkeypatch, failure):
from fastapi import FastAPI, HTTPException
from alphapulse.api import settings
provider = SimpleNamespace(id=1)
async def get_provider(session, identifier):
assert identifier == 1
if failure == 'missing': raise HTTPException(404, '模型配置不存在')
return provider
catalog = AsyncMock(return_value=['model-a', 'model-b'])
if failure == 'unavailable': catalog.side_effect = settings.llm.LlmError('读取模型列表失败')
monkeypatch.setattr(settings, 'get_provider', get_provider)
monkeypatch.setattr(settings.llm, 'list_models', catalog)
app = FastAPI()
app.include_router(settings.router)
app.dependency_overrides[settings.require_admin] = lambda: None
app.dependency_overrides[settings.get_session] = lambda: None
async def check():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
response = await client.get('/api/settings/providers/1/models')
assert response.status_code == ({None: 200, 'unavailable': 502, 'missing': 404}[failure])
if failure is None: assert response.json()['data'] == ['model-a', 'model-b']
asyncio.run(check())