Add provider model catalogs to news risk settings
This commit is contained in:
1 parent
b54863f40a
commit
376d07250e
7 files changed
+235
-4
No files matched your search
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user