mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-20 06:19:17 +08:00
* feat: harden backup/restore UX, chat tool dock, SSO presets, and friendly provider errors Make backup ops resumable across refresh, dock plugin tool UIs beside chat, polish admin SSO setup, fix mobile table scrolling, and map balance/auth probe failures to localized guidance. Co-authored-by: Cursor <cursoragent@cursor.com> * test: accept locale kwarg in provider probe endpoint mock Align integration fake_probe with probe_provider_row locale parameter added for localized error messages. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): close init DB pool and isolate OCTOP_HOME in init tests Release SQLite before --force rmtree so repeated in-process init (CliRunner) cannot flake, and pin OCTOP_HOME plus clear leaked OCTOP_DATABASE_* in init CLI tests. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: jubaoliang <jubaoliang@tencent.com> Co-authored-by: Cursor <cursoragent@cursor.com>
177 lines
6.6 KiB
Python
177 lines
6.6 KiB
Python
"""Unit tests for OpenAI-compatible remote model listing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from octop.infra.agents.providers.probe import fetch_openai_compatible_models
|
|
|
|
|
|
def _mock_response(status: int, payload: Any) -> httpx.Response:
|
|
request = httpx.Request("GET", "https://api.example.com/v1/models")
|
|
return httpx.Response(status, json=payload, request=request)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_models_parses_openai_list() -> None:
|
|
response = _mock_response(
|
|
200,
|
|
{"data": [{"id": "gpt-4o"}, {"id": "gpt-4o-mini", "owned_by": "system"}]},
|
|
)
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(return_value=response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client):
|
|
result = await fetch_openai_compatible_models(
|
|
base_url="https://api.example.com/v1",
|
|
api_key="sk-test",
|
|
)
|
|
|
|
assert result["ok"] is True
|
|
assert result["models"] == [
|
|
{"id": "gpt-4o", "name": "gpt-4o"},
|
|
{"id": "gpt-4o-mini", "name": "gpt-4o-mini"},
|
|
]
|
|
mock_client.get.assert_awaited_once()
|
|
args, kwargs = mock_client.get.await_args
|
|
assert args[0] == "https://api.example.com/v1/models"
|
|
assert kwargs["headers"]["Authorization"] == "Bearer sk-test"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_models_defaults_openai_base_url() -> None:
|
|
response = _mock_response(200, {"data": []})
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(return_value=response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client):
|
|
result = await fetch_openai_compatible_models(base_url=None, api_key="sk-test")
|
|
|
|
assert result["ok"] is True
|
|
assert result["models"] == []
|
|
assert mock_client.get.await_args.args[0] == "https://api.openai.com/v1/models"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_models_rejects_non_openai_payload() -> None:
|
|
response = _mock_response(200, {"models": ["gpt-4o"]})
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(return_value=response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client):
|
|
result = await fetch_openai_compatible_models(
|
|
base_url="https://api.example.com/v1",
|
|
api_key="sk-test",
|
|
)
|
|
|
|
assert result["ok"] is False
|
|
assert "OpenAI" in result["error"] or "compatible" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_models_auth_error() -> None:
|
|
response = _mock_response(401, {"error": {"message": "invalid key"}})
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(return_value=response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client):
|
|
result = await fetch_openai_compatible_models(
|
|
base_url="https://api.example.com/v1",
|
|
api_key="bad",
|
|
)
|
|
|
|
assert result["ok"] is False
|
|
assert "API key" in result["error"]
|
|
assert "401" not in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_models_insufficient_balance_zh() -> None:
|
|
response = _mock_response(402, {"error": {"message": "Insufficient Balance"}})
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(return_value=response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client):
|
|
result = await fetch_openai_compatible_models(
|
|
base_url="https://api.example.com/v1",
|
|
api_key="sk-test",
|
|
locale="zh",
|
|
)
|
|
|
|
assert result["ok"] is False
|
|
assert "余额" in result["error"] or "额度" in result["error"]
|
|
assert "402" not in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_models_merges_extra_headers() -> None:
|
|
response = _mock_response(200, {"data": [{"id": "m1"}]})
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(return_value=response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client):
|
|
await fetch_openai_compatible_models(
|
|
base_url="https://api.example.com/v1/",
|
|
api_key="sk-test",
|
|
extra_headers={"X-Custom": "1"},
|
|
)
|
|
|
|
headers = mock_client.get.await_args.kwargs["headers"]
|
|
assert headers["Authorization"] == "Bearer sk-test"
|
|
assert headers["X-Custom"] == "1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_models_connection_error() -> None:
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("boom"))
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client):
|
|
result = await fetch_openai_compatible_models(
|
|
base_url="https://api.example.com/v1",
|
|
api_key="sk-test",
|
|
)
|
|
|
|
assert result["ok"] is False
|
|
assert result["error"]
|
|
# ConnectError string often includes "connection" → timeout_network guidance
|
|
assert "401" not in result["error"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_models_server_error_friendly() -> None:
|
|
response = _mock_response(503, {"error": {"message": "overloaded"}})
|
|
mock_client = AsyncMock()
|
|
mock_client.get = AsyncMock(return_value=response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client):
|
|
result = await fetch_openai_compatible_models(
|
|
base_url="https://api.example.com/v1",
|
|
api_key="sk-test",
|
|
locale="en",
|
|
)
|
|
|
|
assert result["ok"] is False
|
|
assert "temporarily unavailable" in result["error"].lower()
|
|
assert "503" not in result["error"]
|