mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-27 16:04:21 +00:00
fix(provider): align litellm rebase with master
This commit is contained in:
@@ -1,32 +0,0 @@
|
||||
"""Tests for AnthropicMessages requester.
|
||||
|
||||
Tests config and pure utility methods.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
class TestAnthropicMessagesConfig:
|
||||
"""Tests for default config."""
|
||||
|
||||
def test_default_config_values(self):
|
||||
"""Check default_config."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.anthropicmsgs import AnthropicMessages
|
||||
|
||||
assert AnthropicMessages.default_config['base_url'] == 'https://api.anthropic.com'
|
||||
assert AnthropicMessages.default_config['timeout'] == 120
|
||||
|
||||
def test_config_override(self):
|
||||
"""Config can override defaults."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.anthropicmsgs import AnthropicMessages
|
||||
|
||||
mock_app = MagicMock()
|
||||
req = AnthropicMessages(mock_app, {
|
||||
'base_url': 'https://custom.anthropic.com',
|
||||
'timeout': 60,
|
||||
})
|
||||
|
||||
assert req.requester_cfg['base_url'] == 'https://custom.anthropic.com'
|
||||
assert req.requester_cfg['timeout'] == 60
|
||||
@@ -1,247 +0,0 @@
|
||||
"""Tests for requester error handling - direct import version.
|
||||
|
||||
Tests error handling branches by importing real packages and mocking
|
||||
only the necessary dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
import openai # Import real openai package
|
||||
|
||||
from langbot.pkg.provider.modelmgr.errors import RequesterError
|
||||
|
||||
|
||||
class TestInvokeLLMErrorHandling:
|
||||
"""Tests for invoke_llm error handling branches."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Create mock Application."""
|
||||
app = MagicMock()
|
||||
app.tool_mgr = MagicMock()
|
||||
app.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=[])
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def mock_model(self):
|
||||
"""Create mock RuntimeLLMModel."""
|
||||
model = MagicMock()
|
||||
model.model_entity = MagicMock()
|
||||
model.model_entity.name = 'gpt-4'
|
||||
model.provider = MagicMock()
|
||||
model.provider.token_mgr = MagicMock()
|
||||
model.provider.token_mgr.get_token = MagicMock(return_value='test-key')
|
||||
return model
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message(self):
|
||||
"""Create mock provider message."""
|
||||
msg = MagicMock()
|
||||
msg.dict = MagicMock(return_value={'role': 'user', 'content': 'test'})
|
||||
return msg
|
||||
|
||||
@pytest.fixture
|
||||
def requester_with_mocked_client(self, mock_app):
|
||||
"""Create requester with mocked OpenAI client."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
req = OpenAIChatCompletions(mock_app, {
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
'timeout': 120,
|
||||
})
|
||||
|
||||
# Replace client with mock
|
||||
req.client = MagicMock()
|
||||
req.client.chat = MagicMock()
|
||||
req.client.chat.completions = MagicMock()
|
||||
req.client.chat.completions.create = AsyncMock()
|
||||
|
||||
return req
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_error(self, requester_with_mocked_client, mock_model, mock_message):
|
||||
"""TimeoutError is wrapped as RequesterError."""
|
||||
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
|
||||
side_effect=asyncio.TimeoutError()
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
messages=[mock_message],
|
||||
)
|
||||
|
||||
assert '超时' in str(exc.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_request_context_length(self, requester_with_mocked_client, mock_model, mock_message):
|
||||
"""BadRequestError with context_length_exceeded has special message."""
|
||||
error = openai.BadRequestError(
|
||||
message='context_length_exceeded: max 4096',
|
||||
response=MagicMock(status_code=400),
|
||||
body={}
|
||||
)
|
||||
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
messages=[mock_message],
|
||||
)
|
||||
|
||||
assert '上文过长' in str(exc.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authentication_error(self, requester_with_mocked_client, mock_model, mock_message):
|
||||
"""AuthenticationError shows invalid api-key message."""
|
||||
error = openai.AuthenticationError(
|
||||
message='Invalid API key',
|
||||
response=MagicMock(status_code=401),
|
||||
body={}
|
||||
)
|
||||
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
messages=[mock_message],
|
||||
)
|
||||
|
||||
assert 'api-key' in str(exc.value).lower() or '无效' in str(exc.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_error(self, requester_with_mocked_client, mock_model, mock_message):
|
||||
"""RateLimitError shows rate limit message."""
|
||||
error = openai.RateLimitError(
|
||||
message='Rate limit exceeded',
|
||||
response=MagicMock(status_code=429),
|
||||
body={}
|
||||
)
|
||||
requester_with_mocked_client.client.chat.completions.create = AsyncMock(
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
messages=[mock_message],
|
||||
)
|
||||
|
||||
assert '频繁' in str(exc.value) or '余额' in str(exc.value)
|
||||
|
||||
|
||||
class TestInvokeEmbeddingErrorHandling:
|
||||
"""Tests for invoke_embedding error handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embedding_model(self):
|
||||
model = MagicMock()
|
||||
model.model_entity = MagicMock()
|
||||
model.model_entity.name = 'text-embedding-ada-002'
|
||||
model.model_entity.extra_args = {}
|
||||
model.provider = MagicMock()
|
||||
model.provider.token_mgr = MagicMock()
|
||||
model.provider.token_mgr.get_token = MagicMock(return_value='test-key')
|
||||
return model
|
||||
|
||||
@pytest.fixture
|
||||
def requester_with_mocked_client(self, mock_app):
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
req = OpenAIChatCompletions(mock_app, {})
|
||||
req.client = MagicMock()
|
||||
req.client.embeddings = MagicMock()
|
||||
req.client.embeddings.create = AsyncMock()
|
||||
|
||||
return req
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_timeout_error(self, requester_with_mocked_client, mock_embedding_model):
|
||||
"""TimeoutError in embedding request."""
|
||||
requester_with_mocked_client.client.embeddings.create = AsyncMock(
|
||||
side_effect=asyncio.TimeoutError()
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_embedding(
|
||||
model=mock_embedding_model,
|
||||
input_text=['test'],
|
||||
)
|
||||
|
||||
assert '超时' in str(exc.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedding_bad_request_error(self, requester_with_mocked_client, mock_embedding_model):
|
||||
"""BadRequestError in embedding request."""
|
||||
error = openai.BadRequestError(
|
||||
message='Invalid model',
|
||||
response=MagicMock(status_code=400),
|
||||
body={}
|
||||
)
|
||||
requester_with_mocked_client.client.embeddings.create = AsyncMock(
|
||||
side_effect=error
|
||||
)
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_embedding(
|
||||
model=mock_embedding_model,
|
||||
input_text=['test'],
|
||||
)
|
||||
|
||||
assert '参数' in str(exc.value)
|
||||
|
||||
|
||||
class TestRequesterErrorClass:
|
||||
"""Tests for RequesterError."""
|
||||
|
||||
def test_error_message_prefix(self):
|
||||
"""RequesterError has '模型请求失败' prefix."""
|
||||
from langbot.pkg.provider.modelmgr.errors import RequesterError
|
||||
|
||||
error = RequesterError('test error')
|
||||
assert '模型请求失败' in str(error)
|
||||
|
||||
def test_error_is_exception(self):
|
||||
"""RequesterError inherits Exception."""
|
||||
from langbot.pkg.provider.modelmgr.errors import RequesterError
|
||||
|
||||
error = RequesterError('test')
|
||||
assert isinstance(error, Exception)
|
||||
|
||||
|
||||
class TestDefaultConfig:
|
||||
"""Tests for requester default config."""
|
||||
|
||||
def test_default_config(self):
|
||||
"""Check default_config values."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
assert OpenAIChatCompletions.default_config['base_url'] == 'https://api.openai.com/v1'
|
||||
assert OpenAIChatCompletions.default_config['timeout'] == 120
|
||||
|
||||
def test_config_override(self):
|
||||
"""Config overrides defaults."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
req = OpenAIChatCompletions(MagicMock(), {
|
||||
'base_url': 'https://custom.com/v1',
|
||||
'timeout': 60,
|
||||
})
|
||||
|
||||
assert req.requester_cfg['base_url'] == 'https://custom.com/v1'
|
||||
assert req.requester_cfg['timeout'] == 60
|
||||
@@ -1,340 +0,0 @@
|
||||
"""Tests for requester pure utility functions.
|
||||
|
||||
Tests the helper methods in OpenAIChatCompletions that don't require network calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tests.utils.import_isolation import isolated_sys_modules
|
||||
|
||||
|
||||
class TestMaskApiKey:
|
||||
"""Tests for _mask_api_key method."""
|
||||
|
||||
def _create_requester_with_mocks(self):
|
||||
"""Create requester instance with mocked dependencies."""
|
||||
mocks = {
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
|
||||
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
|
||||
}
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
mock_app = MagicMock()
|
||||
requester = OpenAIChatCompletions(mock_app, {})
|
||||
return requester
|
||||
|
||||
def test_mask_api_key_full(self):
|
||||
"""Mask a full API key."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._mask_api_key('sk-1234567890abcdef')
|
||||
assert result == 'sk-1...cdef'
|
||||
|
||||
def test_mask_api_key_short(self):
|
||||
"""Mask a short API key (<=8 chars)."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._mask_api_key('short')
|
||||
assert result == '****'
|
||||
|
||||
def test_mask_api_key_empty(self):
|
||||
"""Empty API key returns empty string."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._mask_api_key('')
|
||||
assert result == ''
|
||||
|
||||
def test_mask_api_key_none(self):
|
||||
"""None API key returns empty string."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._mask_api_key(None)
|
||||
assert result == ''
|
||||
|
||||
def test_mask_api_key_exact_8_chars(self):
|
||||
"""API key with exactly 8 chars is masked as **** (<=8 threshold)."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._mask_api_key('12345678')
|
||||
assert result == '****' # <= 8 chars gets masked
|
||||
|
||||
|
||||
class TestInferModelType:
|
||||
"""Tests for _infer_model_type method."""
|
||||
|
||||
def _create_requester_with_mocks(self):
|
||||
mocks = {
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
|
||||
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
|
||||
}
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
mock_app = MagicMock()
|
||||
requester = OpenAIChatCompletions(mock_app, {})
|
||||
return requester
|
||||
|
||||
def test_infer_embedding_from_name(self):
|
||||
"""Infer embedding type from model name."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
assert requester._infer_model_type('text-embedding-ada-002') == 'embedding'
|
||||
assert requester._infer_model_type('bge-large-en') == 'embedding'
|
||||
assert requester._infer_model_type('e5-base') == 'embedding'
|
||||
assert requester._infer_model_type('m3e-base') == 'embedding'
|
||||
|
||||
def test_infer_llm_from_name(self):
|
||||
"""Infer LLM type from model name."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
assert requester._infer_model_type('gpt-4') == 'llm'
|
||||
assert requester._infer_model_type('claude-3-opus') == 'llm'
|
||||
assert requester._infer_model_type('llama-2-70b') == 'llm'
|
||||
|
||||
def test_infer_model_type_none_id(self):
|
||||
"""Handle None model_id."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._infer_model_type(None)
|
||||
assert result == 'llm' # Default
|
||||
|
||||
def test_infer_model_type_empty_id(self):
|
||||
"""Handle empty model_id."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._infer_model_type('')
|
||||
assert result == 'llm' # Default
|
||||
|
||||
|
||||
class TestNormalizeModalities:
|
||||
"""Tests for _normalize_modalities method."""
|
||||
|
||||
def _create_requester_with_mocks(self):
|
||||
mocks = {
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
|
||||
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
|
||||
}
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
mock_app = MagicMock()
|
||||
requester = OpenAIChatCompletions(mock_app, {})
|
||||
return requester
|
||||
|
||||
def test_normalize_string_modality(self):
|
||||
"""Normalize single string modality."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities('text,image')
|
||||
assert result == ['text', 'image']
|
||||
|
||||
def test_normalize_list_modalities(self):
|
||||
"""Normalize list of modalities."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities(['text', 'image', 'audio'])
|
||||
assert result == ['text', 'image', 'audio']
|
||||
|
||||
def test_normalize_dict_modalities(self):
|
||||
"""Normalize dict with nested modalities."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities({'input': ['text'], 'output': ['text', 'image']})
|
||||
assert result == ['text', 'image']
|
||||
|
||||
def test_normalize_none(self):
|
||||
"""Handle None input."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities(None)
|
||||
assert result == []
|
||||
|
||||
def test_normalize_arrow_separator(self):
|
||||
"""Handle arrow separator in modality string."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
result = requester._normalize_modalities('text->image')
|
||||
assert result == ['text', 'image']
|
||||
|
||||
|
||||
class TestParseRerankResponse:
|
||||
"""Tests for _parse_rerank_response static method."""
|
||||
|
||||
def test_parse_cohere_jina_format(self):
|
||||
"""Parse Cohere/Jina/SiliconFlow format."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
data = {
|
||||
'results': [
|
||||
{'index': 0, 'relevance_score': 0.95},
|
||||
{'index': 1, 'relevance_score': 0.80},
|
||||
]
|
||||
}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert result == [
|
||||
{'index': 0, 'relevance_score': 0.95},
|
||||
{'index': 1, 'relevance_score': 0.80},
|
||||
]
|
||||
|
||||
def test_parse_voyage_format(self):
|
||||
"""Parse Voyage AI format."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
data = {
|
||||
'data': [
|
||||
{'index': 0, 'relevance_score': 0.90},
|
||||
{'index': 2, 'relevance_score': 0.75},
|
||||
]
|
||||
}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert result == [
|
||||
{'index': 0, 'relevance_score': 0.90},
|
||||
{'index': 2, 'relevance_score': 0.75},
|
||||
]
|
||||
|
||||
def test_parse_dashscope_format(self):
|
||||
"""Parse DashScope format."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
data = {
|
||||
'output': {
|
||||
'results': [
|
||||
{'index': 0, 'relevance_score': 0.85},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert result == [{'index': 0, 'relevance_score': 0.85}]
|
||||
|
||||
def test_parse_unknown_format(self):
|
||||
"""Handle unknown format returns empty list."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
data = {'unknown_key': 'value'}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert result == []
|
||||
|
||||
def test_parse_empty_results(self):
|
||||
"""Handle empty results."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
data = {'results': []}
|
||||
|
||||
result = OpenAIChatCompletions._parse_rerank_response(data)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestExtractScanMetadata:
|
||||
"""Tests for _extract_scan_metadata method."""
|
||||
|
||||
def _create_requester_with_mocks(self):
|
||||
mocks = {
|
||||
'langbot.pkg.core.app': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.resource.tool': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.pipeline.query': MagicMock(),
|
||||
'langbot_plugin.api.entities.builtin.provider.message': MagicMock(),
|
||||
'langbot.pkg.provider.modelmgr.errors': MagicMock(),
|
||||
}
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
|
||||
mock_app = MagicMock()
|
||||
requester = OpenAIChatCompletions(mock_app, {})
|
||||
return requester
|
||||
|
||||
def test_extract_basic_metadata(self):
|
||||
"""Extract basic model metadata."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
item = {
|
||||
'id': 'gpt-4',
|
||||
'name': 'GPT-4 Turbo',
|
||||
'description': 'Most capable GPT-4 model',
|
||||
'context_length': 128000,
|
||||
'owned_by': 'openai',
|
||||
}
|
||||
|
||||
result = requester._extract_scan_metadata(item, 'gpt-4')
|
||||
|
||||
assert result['display_name'] == 'GPT-4 Turbo'
|
||||
assert result['description'] == 'Most capable GPT-4 model'
|
||||
assert result['context_length'] == 128000
|
||||
assert result['owned_by'] == 'openai'
|
||||
|
||||
def test_extract_metadata_missing_fields(self):
|
||||
"""Handle missing metadata fields."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
item = {'id': 'unknown-model'}
|
||||
|
||||
result = requester._extract_scan_metadata(item, 'unknown-model')
|
||||
|
||||
assert result['display_name'] is None
|
||||
assert result['description'] is None
|
||||
assert result['context_length'] is None
|
||||
assert result['owned_by'] is None
|
||||
|
||||
def test_extract_metadata_top_provider_context(self):
|
||||
"""Extract context_length from top_provider."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
item = {
|
||||
'id': 'model',
|
||||
'top_provider': {
|
||||
'context_length': 4096,
|
||||
},
|
||||
}
|
||||
|
||||
result = requester._extract_scan_metadata(item, 'model')
|
||||
|
||||
assert result['context_length'] == 4096
|
||||
|
||||
def test_extract_metadata_empty_strings(self):
|
||||
"""Handle empty string values."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
item = {
|
||||
'id': 'model',
|
||||
'name': '', # Empty name
|
||||
'description': ' ', # Whitespace only
|
||||
'owned_by': '',
|
||||
}
|
||||
|
||||
result = requester._extract_scan_metadata(item, 'model')
|
||||
|
||||
assert result['display_name'] is None
|
||||
assert result['description'] is None
|
||||
assert result['owned_by'] is None
|
||||
|
||||
def test_extract_metadata_name_matches_id(self):
|
||||
"""When name equals id, display_name is None."""
|
||||
requester = self._create_requester_with_mocks()
|
||||
|
||||
item = {
|
||||
'id': 'gpt-4',
|
||||
'name': 'gpt-4', # Same as id
|
||||
}
|
||||
|
||||
result = requester._extract_scan_metadata(item, 'gpt-4')
|
||||
|
||||
assert result['display_name'] is None
|
||||
@@ -1,264 +0,0 @@
|
||||
"""Tests for OllamaChatCompletions requester.
|
||||
|
||||
Tests model inference, payload construction, and error handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.provider.modelmgr.errors import RequesterError
|
||||
|
||||
|
||||
class TestOllamaRequesterConfig:
|
||||
"""Tests for default config."""
|
||||
|
||||
def test_default_config_values(self):
|
||||
"""Check default_config."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
assert OllamaChatCompletions.default_config['base_url'] == 'http://127.0.0.1:11434'
|
||||
assert OllamaChatCompletions.default_config['timeout'] == 120
|
||||
|
||||
def test_config_override(self):
|
||||
"""Config can override defaults."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
mock_app = MagicMock()
|
||||
req = OllamaChatCompletions(mock_app, {
|
||||
'base_url': 'http://custom.ollama:11434',
|
||||
'timeout': 300,
|
||||
})
|
||||
|
||||
assert req.requester_cfg['base_url'] == 'http://custom.ollama:11434'
|
||||
assert req.requester_cfg['timeout'] == 300
|
||||
|
||||
|
||||
class TestOllamaInferModelType:
|
||||
"""Tests for _infer_model_type pure function."""
|
||||
|
||||
@pytest.fixture
|
||||
def requester(self):
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
return OllamaChatCompletions(MagicMock(), {})
|
||||
|
||||
def test_infer_embedding_from_name(self, requester):
|
||||
"""Embedding keywords return 'embedding'."""
|
||||
assert requester._infer_model_type('nomic-embed-text') == 'embedding'
|
||||
assert requester._infer_model_type('bge-large') == 'embedding'
|
||||
assert requester._infer_model_type('text-embedding') == 'embedding'
|
||||
|
||||
def test_infer_llm_from_name(self, requester):
|
||||
"""Non-embedding keywords return 'llm'."""
|
||||
assert requester._infer_model_type('llama2') == 'llm'
|
||||
assert requester._infer_model_type('mistral') == 'llm'
|
||||
assert requester._infer_model_type('codellama') == 'llm'
|
||||
|
||||
def test_infer_model_type_none(self, requester):
|
||||
"""None model_id returns 'llm'."""
|
||||
assert requester._infer_model_type(None) == 'llm'
|
||||
|
||||
def test_infer_model_type_empty(self, requester):
|
||||
"""Empty model_id returns 'llm'."""
|
||||
assert requester._infer_model_type('') == 'llm'
|
||||
|
||||
|
||||
class TestOllamaInferModelAbilities:
|
||||
"""Tests for _infer_model_abilities pure function."""
|
||||
|
||||
@pytest.fixture
|
||||
def requester(self):
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
return OllamaChatCompletions(MagicMock(), {})
|
||||
|
||||
def test_infer_vision_ability(self, requester):
|
||||
"""Vision keywords add 'vision' ability."""
|
||||
item = {
|
||||
'details': {
|
||||
'family': 'llava',
|
||||
}
|
||||
}
|
||||
|
||||
abilities = requester._infer_model_abilities(item, 'llava-v1.5')
|
||||
assert 'vision' in abilities
|
||||
|
||||
def test_infer_vision_from_model_id(self, requester):
|
||||
"""Vision keywords in model_id add 'vision' ability."""
|
||||
item = {}
|
||||
abilities = requester._infer_model_abilities(item, 'llava-7b')
|
||||
assert 'vision' in abilities
|
||||
|
||||
def test_infer_func_call_ability(self, requester):
|
||||
"""Tool/function keywords add 'func_call' ability."""
|
||||
item = {
|
||||
'details': {
|
||||
'families': ['tools'],
|
||||
}
|
||||
}
|
||||
|
||||
abilities = requester._infer_model_abilities(item, 'model')
|
||||
assert 'func_call' in abilities
|
||||
|
||||
def test_infer_no_abilities(self, requester):
|
||||
"""No matching keywords returns empty abilities."""
|
||||
item = {
|
||||
'details': {
|
||||
'family': 'llama',
|
||||
}
|
||||
}
|
||||
|
||||
abilities = requester._infer_model_abilities(item, 'llama-2')
|
||||
assert len(abilities) == 0
|
||||
|
||||
def test_infer_multiple_abilities(self, requester):
|
||||
"""Multiple keywords can add multiple abilities."""
|
||||
item = {
|
||||
'details': {
|
||||
'family': 'vision',
|
||||
'families': ['tools'],
|
||||
}
|
||||
}
|
||||
|
||||
abilities = requester._infer_model_abilities(item, 'vision-tool-model')
|
||||
assert 'vision' in abilities
|
||||
assert 'func_call' in abilities
|
||||
|
||||
|
||||
class TestOllamaMakeMessage:
|
||||
"""Tests for _make_msg response parsing."""
|
||||
|
||||
@pytest.fixture
|
||||
def requester(self):
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
return OllamaChatCompletions(MagicMock(), {})
|
||||
|
||||
def _create_ollama_response(self, content, tool_calls=None):
|
||||
"""Helper to create mock ollama response."""
|
||||
import ollama
|
||||
|
||||
mock_response = MagicMock(spec=ollama.ChatResponse)
|
||||
mock_message = MagicMock(spec=ollama.Message)
|
||||
mock_message.content = content
|
||||
mock_message.tool_calls = tool_calls
|
||||
mock_response.message = mock_message
|
||||
|
||||
return mock_response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_msg_text_content(self, requester):
|
||||
"""Text content is extracted."""
|
||||
mock_response = self._create_ollama_response('Hello world')
|
||||
|
||||
result = await requester._make_msg(mock_response)
|
||||
|
||||
assert result.content == 'Hello world'
|
||||
assert result.role == 'assistant'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_msg_with_tool_calls(self, requester):
|
||||
"""Tool calls are parsed."""
|
||||
mock_tool_call = MagicMock()
|
||||
mock_tool_call.function = MagicMock()
|
||||
mock_tool_call.function.name = 'get_weather'
|
||||
mock_tool_call.function.arguments = {'location': 'Beijing'}
|
||||
|
||||
mock_response = self._create_ollama_response('', tool_calls=[mock_tool_call])
|
||||
|
||||
result = await requester._make_msg(mock_response)
|
||||
|
||||
assert result.tool_calls is not None
|
||||
assert len(result.tool_calls) == 1
|
||||
assert result.tool_calls[0].function.name == 'get_weather'
|
||||
# Arguments should be JSON string
|
||||
assert isinstance(result.tool_calls[0].function.arguments, str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_make_msg_empty_message_raises(self, requester):
|
||||
"""Empty message raises ValueError."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.message = None
|
||||
|
||||
with pytest.raises(ValueError, match='message'):
|
||||
await requester._make_msg(mock_response)
|
||||
|
||||
|
||||
class TestOllamaErrorHandling:
|
||||
"""Tests for error handling branches."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
app = MagicMock()
|
||||
app.tool_mgr = MagicMock()
|
||||
app.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=[])
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def requester_with_mocked_client(self, mock_app):
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
req = OllamaChatCompletions(mock_app, {})
|
||||
req.client = MagicMock()
|
||||
req.client.chat = AsyncMock()
|
||||
|
||||
return req
|
||||
|
||||
@pytest.fixture
|
||||
def mock_model(self):
|
||||
model = MagicMock()
|
||||
model.model_entity = MagicMock()
|
||||
model.model_entity.name = 'llama2'
|
||||
model.provider = MagicMock()
|
||||
model.provider.token_mgr = MagicMock()
|
||||
model.provider.token_mgr.get_token = MagicMock(return_value='')
|
||||
return model
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message(self):
|
||||
msg = MagicMock()
|
||||
msg.role = 'user'
|
||||
msg.content = 'test'
|
||||
msg.dict = MagicMock(return_value={'role': 'user', 'content': 'test'})
|
||||
return msg
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_error(self, requester_with_mocked_client, mock_model, mock_message):
|
||||
"""TimeoutError is converted to RequesterError."""
|
||||
requester_with_mocked_client.client.chat = AsyncMock(side_effect=asyncio.TimeoutError())
|
||||
|
||||
with pytest.raises(RequesterError) as exc:
|
||||
await requester_with_mocked_client.invoke_llm(
|
||||
query=None,
|
||||
model=mock_model,
|
||||
messages=[mock_message],
|
||||
)
|
||||
|
||||
assert '超时' in str(exc.value)
|
||||
|
||||
|
||||
class TestOllamaScanModels:
|
||||
"""Tests for scan_models method."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def requester(self, mock_app):
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import OllamaChatCompletions
|
||||
|
||||
req = OllamaChatCompletions(mock_app, {
|
||||
'base_url': 'http://127.0.0.1:11434',
|
||||
'timeout': 120,
|
||||
})
|
||||
return req
|
||||
|
||||
def test_requester_name_constant(self):
|
||||
"""REQUESTER_NAME constant exists."""
|
||||
from langbot.pkg.provider.modelmgr.requesters.ollamachat import REQUESTER_NAME
|
||||
|
||||
assert REQUESTER_NAME == 'ollama-chat'
|
||||
@@ -16,8 +16,6 @@ from langbot.pkg.entity.persistence import model as persistence_model
|
||||
from langbot.pkg.pipeline.preproc.preproc import PreProcessor
|
||||
from langbot.pkg.provider.modelmgr import requester
|
||||
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
||||
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
|
||||
from langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl import ModelScopeChatCompletions
|
||||
from langbot.pkg.provider.modelmgr.token import TokenManager
|
||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||
|
||||
@@ -90,74 +88,6 @@ def test_token_manager_next_token_ignores_empty_token_list():
|
||||
assert token_mgr.using_token_index == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_requester_initialize_uses_placeholder_api_key(monkeypatch):
|
||||
captured_kwargs = {}
|
||||
|
||||
def fake_client(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(**kwargs)
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.openai.AsyncClient', fake_client)
|
||||
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.httpx.AsyncClient', fake_client)
|
||||
|
||||
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
|
||||
await requester_inst.initialize()
|
||||
|
||||
assert captured_kwargs['api_key'] == OpenAIChatCompletions.init_api_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modelscope_requester_initialize_uses_placeholder_api_key(monkeypatch):
|
||||
captured_kwargs = {}
|
||||
|
||||
def fake_client(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return SimpleNamespace(**kwargs)
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.openai.AsyncClient', fake_client)
|
||||
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.httpx.AsyncClient', fake_client)
|
||||
|
||||
requester_inst = ModelScopeChatCompletions(ap=SimpleNamespace(), config={})
|
||||
await requester_inst.initialize()
|
||||
|
||||
assert captured_kwargs['api_key'] == ModelScopeChatCompletions.init_api_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_embedding_call_overrides_placeholder_api_key():
|
||||
captured_request = {}
|
||||
|
||||
async def fake_create(**kwargs):
|
||||
captured_request['api_key'] = fake_client.api_key
|
||||
captured_request['kwargs'] = kwargs
|
||||
return SimpleNamespace(
|
||||
data=[SimpleNamespace(embedding=[0.1, 0.2])],
|
||||
usage=SimpleNamespace(prompt_tokens=3, total_tokens=3),
|
||||
)
|
||||
|
||||
fake_client = SimpleNamespace(
|
||||
api_key=OpenAIChatCompletions.init_api_key,
|
||||
embeddings=SimpleNamespace(create=fake_create),
|
||||
)
|
||||
|
||||
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
|
||||
requester_inst.client = fake_client
|
||||
|
||||
embeddings, usage_info = await requester_inst.invoke_embedding(
|
||||
model=requester.RuntimeEmbeddingModel(
|
||||
model_entity=SimpleNamespace(name='text-embedding-3-small', extra_args={}),
|
||||
provider=SimpleNamespace(token_mgr=TokenManager('provider-uuid', [' runtime-key ', '', 'runtime-key'])),
|
||||
),
|
||||
input_text=['hello'],
|
||||
)
|
||||
|
||||
assert captured_request['api_key'] == 'runtime-key'
|
||||
assert captured_request['kwargs']['model'] == 'text-embedding-3-small'
|
||||
assert embeddings == [[0.1, 0.2]]
|
||||
assert usage_info == {'prompt_tokens': 3, 'total_tokens': 3}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
|
||||
from langbot.pkg.api.http.service.model import LLMModelsService
|
||||
|
||||
Reference in New Issue
Block a user