diff --git a/src/langbot/pkg/provider/modelmgr/modelmgr.py b/src/langbot/pkg/provider/modelmgr/modelmgr.py index a48a6eac..ee7f4d3b 100644 --- a/src/langbot/pkg/provider/modelmgr/modelmgr.py +++ b/src/langbot/pkg/provider/modelmgr/modelmgr.py @@ -4,7 +4,6 @@ import sqlalchemy import traceback from . import requester -from .requesters import litellmchat from ...core import app from ...discover import engine from . import token @@ -310,6 +309,8 @@ class ModelManager: # Check if requester manifest specifies litellm_provider if requester_manifest and requester_manifest.spec.get('litellm_provider'): + from .requesters import litellmchat + # Use unified LiteLLMRequester with provider prefix # Map litellm_provider (YAML spec) to custom_llm_provider (config) config['custom_llm_provider'] = requester_manifest.spec['litellm_provider'] diff --git a/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.py b/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.py index 0c7a940f..84c59b74 100644 --- a/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.py +++ b/src/langbot/pkg/provider/modelmgr/requesters/qiniuchatcmpl.py @@ -2,19 +2,16 @@ from __future__ import annotations import typing -import openai - -from . import chatcmpl +from . import litellmchat -class QiniuChatCompletions(chatcmpl.OpenAIChatCompletions): +class QiniuChatCompletions(litellmchat.LiteLLMRequester): """七牛云 ChatCompletion API 请求器""" - client: openai.AsyncClient - default_config: dict[str, typing.Any] = { 'base_url': 'https://api.qnaigc.com/v1', 'timeout': 120, + 'custom_llm_provider': 'openai', } async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]: diff --git a/tests/unit_tests/provider/requesters/test_anthropic_requester.py b/tests/unit_tests/provider/requesters/test_anthropic_requester.py deleted file mode 100644 index 54abb615..00000000 --- a/tests/unit_tests/provider/requesters/test_anthropic_requester.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/tests/unit_tests/provider/requesters/test_chatcmpl_errors_direct.py b/tests/unit_tests/provider/requesters/test_chatcmpl_errors_direct.py deleted file mode 100644 index c51476c2..00000000 --- a/tests/unit_tests/provider/requesters/test_chatcmpl_errors_direct.py +++ /dev/null @@ -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 diff --git a/tests/unit_tests/provider/requesters/test_chatcmpl_utils.py b/tests/unit_tests/provider/requesters/test_chatcmpl_utils.py deleted file mode 100644 index 38d9df1c..00000000 --- a/tests/unit_tests/provider/requesters/test_chatcmpl_utils.py +++ /dev/null @@ -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 diff --git a/tests/unit_tests/provider/requesters/test_ollama_requester.py b/tests/unit_tests/provider/requesters/test_ollama_requester.py deleted file mode 100644 index 993115ab..00000000 --- a/tests/unit_tests/provider/requesters/test_ollama_requester.py +++ /dev/null @@ -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' diff --git a/tests/unit_tests/provider/test_model_service.py b/tests/unit_tests/provider/test_model_service.py index 60ac658e..ba1657cd 100644 --- a/tests/unit_tests/provider/test_model_service.py +++ b/tests/unit_tests/provider/test_model_service.py @@ -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 diff --git a/uv.lock b/uv.lock index ec66e0d2..ee109641 100644 --- a/uv.lock +++ b/uv.lock @@ -1168,6 +1168,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/ee/aa015c5de8b0dc42a8e507eae8c2de5d1c0e068c896858fec6d502402ed6/ebooklib-0.20-py3-none-any.whl", hash = "sha256:fff5322517a37e31c972d27be7d982cc3928c16b3dcc5fd7e8f7c0f5d7bcf42b", size = 40995, upload-time = "2025-10-26T20:56:19.104Z" }, ] +[[package]] +name = "fastuuid" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f3/12481bda4e5b6d3e698fbf525df4443cc7dce746f246b86b6fcb2fba1844/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34", size = 516386, upload-time = "2025-10-19T22:42:40.176Z" }, + { url = "https://files.pythonhosted.org/packages/59/19/2fc58a1446e4d72b655648eb0879b04e88ed6fa70d474efcf550f640f6ec/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7", size = 264569, upload-time = "2025-10-19T22:25:50.977Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/3c74756e5b02c40cfcc8b1d8b5bac4edbd532b55917a6bcc9113550e99d1/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1", size = 254366, upload-time = "2025-10-19T22:29:49.166Z" }, + { url = "https://files.pythonhosted.org/packages/52/96/d761da3fccfa84f0f353ce6e3eb8b7f76b3aa21fd25e1b00a19f9c80a063/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc", size = 278978, upload-time = "2025-10-19T22:35:41.306Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/f84c90167cc7765cb82b3ff7808057608b21c14a38531845d933a4637307/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8", size = 279692, upload-time = "2025-10-19T22:25:36.997Z" }, + { url = "https://files.pythonhosted.org/packages/af/7b/4bacd03897b88c12348e7bd77943bac32ccf80ff98100598fcff74f75f2e/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7", size = 303384, upload-time = "2025-10-19T22:29:46.578Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a2/584f2c29641df8bd810d00c1f21d408c12e9ad0c0dafdb8b7b29e5ddf787/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73", size = 460921, upload-time = "2025-10-19T22:36:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/c6b77443bb7764c760e211002c8638c0c7cce11cb584927e723215ba1398/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36", size = 480575, upload-time = "2025-10-19T22:28:18.975Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/93f553111b33f9bb83145be12868c3c475bf8ea87c107063d01377cc0e8e/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94", size = 452317, upload-time = "2025-10-19T22:25:32.75Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8c/a04d486ca55b5abb7eaa65b39df8d891b7b1635b22db2163734dc273579a/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24", size = 154804, upload-time = "2025-10-19T22:24:15.615Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b2/2d40bf00820de94b9280366a122cbaa60090c8cf59e89ac3938cf5d75895/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa", size = 156099, upload-time = "2025-10-19T22:24:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/ae12dd39b9a39b55d7f90abb8971f1a5f3c321fd72d5aa83f90dc67fe9ed/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021", size = 510720, upload-time = "2025-10-19T22:42:34.633Z" }, + { url = "https://files.pythonhosted.org/packages/53/b0/a4b03ff5d00f563cc7546b933c28cb3f2a07344b2aec5834e874f7d44143/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc", size = 262024, upload-time = "2025-10-19T22:30:25.482Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/64aee0a0f6a58eeabadd582e55d0d7d70258ffdd01d093b30c53d668303b/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5", size = 251679, upload-time = "2025-10-19T22:36:14.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/a7e9cda8369e4f7919d36552db9b2ae21db7915083bc6336f1b0082c8b2e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f", size = 277862, upload-time = "2025-10-19T22:36:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/8ce11827c783affffd5bd4d6378b28eb6cc6d2ddf41474006b8d62e7448e/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87", size = 278278, upload-time = "2025-10-19T22:29:43.809Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/680fb6352d0bbade04036da46264a8001f74b7484e2fd1f4da9e3db1c666/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b", size = 301788, upload-time = "2025-10-19T22:36:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7c/2014b5785bd8ebdab04ec857635ebd84d5ee4950186a577db9eff0fb8ff6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022", size = 459819, upload-time = "2025-10-19T22:35:31.623Z" }, + { url = "https://files.pythonhosted.org/packages/01/d2/524d4ceeba9160e7a9bc2ea3e8f4ccf1ad78f3bde34090ca0c51f09a5e91/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995", size = 478546, upload-time = "2025-10-19T22:26:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/354d04951ce114bf4afc78e27a18cfbd6ee319ab1829c2d5fb5e94063ac6/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab", size = 450921, upload-time = "2025-10-19T22:31:02.151Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/d7be8670151d16d88f15bb121c5b66cdb5ea6a0c2a362d0dcf30276ade53/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad", size = 154559, upload-time = "2025-10-19T22:36:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/8c7660d1fe3862e3f8acabd9be7fc9ad71eb270f1c65cce9a2b7a31329ab/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad", size = 510600, upload-time = "2025-10-19T22:43:44.17Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f4/a989c82f9a90d0ad995aa957b3e572ebef163c5299823b4027986f133dfb/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b", size = 262069, upload-time = "2025-10-19T22:43:38.38Z" }, + { url = "https://files.pythonhosted.org/packages/da/6c/a1a24f73574ac995482b1326cf7ab41301af0fabaa3e37eeb6b3df00e6e2/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714", size = 251543, upload-time = "2025-10-19T22:32:22.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/20/2a9b59185ba7a6c7b37808431477c2d739fcbdabbf63e00243e37bd6bf49/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f", size = 277798, upload-time = "2025-10-19T22:33:53.821Z" }, + { url = "https://files.pythonhosted.org/packages/ef/33/4105ca574f6ded0af6a797d39add041bcfb468a1255fbbe82fcb6f592da2/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f", size = 278283, upload-time = "2025-10-19T22:29:02.812Z" }, + { url = "https://files.pythonhosted.org/packages/fe/8c/fca59f8e21c4deb013f574eae05723737ddb1d2937ce87cb2a5d20992dc3/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75", size = 301627, upload-time = "2025-10-19T22:35:54.985Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e2/f78c271b909c034d429218f2798ca4e89eeda7983f4257d7865976ddbb6c/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4", size = 459778, upload-time = "2025-10-19T22:28:00.999Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f0/5ff209d865897667a2ff3e7a572267a9ced8f7313919f6d6043aed8b1caa/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad", size = 478605, upload-time = "2025-10-19T22:36:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c8/2ce1c78f983a2c4987ea865d9516dbdfb141a120fd3abb977ae6f02ba7ca/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8", size = 450837, upload-time = "2025-10-19T22:34:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/df/60/dad662ec9a33b4a5fe44f60699258da64172c39bd041da2994422cdc40fe/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06", size = 154532, upload-time = "2025-10-19T22:35:18.217Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/da4db31001e854025ffd26bc9ba0740a9cbba2c3259695f7c5834908b336/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a", size = 156457, upload-time = "2025-10-19T22:33:44.579Z" }, +] + [[package]] name = "filelock" version = "3.20.3" @@ -1933,6 +1985,7 @@ dependencies = [ { name = "langsmith" }, { name = "lark-oapi" }, { name = "line-bot-sdk" }, + { name = "litellm" }, { name = "mako" }, { name = "markdown" }, { name = "matrix-nio" }, @@ -2020,6 +2073,7 @@ requires-dist = [ { name = "langsmith", specifier = ">=0.7.31" }, { name = "lark-oapi", specifier = ">=1.5.5" }, { name = "line-bot-sdk", specifier = ">=3.19.0" }, + { name = "litellm", specifier = ">=1.0.0" }, { name = "mako", specifier = ">=1.3.11" }, { name = "markdown", specifier = ">=3.6" }, { name = "matrix-nio", specifier = ">=0.25.2" }, @@ -2345,6 +2399,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, ] +[[package]] +name = "litellm" +version = "1.87.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "fastuuid" }, + { name = "httpx" }, + { name = "importlib-metadata" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "tiktoken" }, + { name = "tokenizers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/e5/d0ac1c8f55e2c8d8799589e831bef0d450e69e02ecb511901ffc8de054d9/litellm-1.87.1.tar.gz", hash = "sha256:70ac9d6b25f56ad30de6ff95d26fac3b3fc697a95da582b6072d25d8dc73d493", size = 15455709, upload-time = "2026-06-04T16:23:23.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/18/8275c95ef09e81ab0c01a162c7b780ce3fbc49066b5d532c6b6ab3dc0118/litellm-1.87.1-py3-none-any.whl", hash = "sha256:dd4e00278cdb846d52e99a09d732575a897273540b54eb044247ecbc0d98f67c", size = 17105482, upload-time = "2026-06-04T16:23:20.769Z" }, +] + [[package]] name = "logbook" version = "1.9.2" @@ -3284,7 +3361,7 @@ wheels = [ [[package]] name = "openai" -version = "2.16.0" +version = "2.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3296,9 +3373,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/6c/e4c964fcf1d527fdf4739e7cc940c60075a4114d50d03871d5d5b1e13a88/openai-2.16.0.tar.gz", hash = "sha256:42eaa22ca0d8ded4367a77374104d7a2feafee5bd60a107c3c11b5243a11cd12", size = 629649, upload-time = "2026-01-27T23:28:02.579Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/a6/5815fe2e2aca74b36c650d1bd43b69827cee568073d0d2d9b6fc5aaac80c/openai-2.41.0.tar.gz", hash = "sha256:db5c362acd6604b84f076abbefa66826ea4b46ecba2954ed866e6a149a1352c0", size = 783525, upload-time = "2026-06-03T22:39:40.719Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/83/0315bf2cfd75a2ce8a7e54188e9456c60cec6c0cf66728ed07bd9859ff26/openai-2.16.0-py3-none-any.whl", hash = "sha256:5f46643a8f42899a84e80c38838135d7038e7718333ce61396994f887b09a59b", size = 1068612, upload-time = "2026-01-27T23:28:00.356Z" }, + { url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload-time = "2026-06-03T22:39:38.964Z" }, ] [[package]] diff --git a/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx b/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx index 91dac280..59173517 100644 --- a/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx +++ b/web/src/app/home/components/models-dialog/component/provider-form/ProviderForm.tsx @@ -54,6 +54,7 @@ export default function ProviderForm({ api_key: '', }, }); + const { setValue } = form; const [requesterList, setRequesterList] = useState< { @@ -69,6 +70,37 @@ export default function ProviderForm({ const dropdownRef = useRef(null); const searchInputRef = useRef(null); + const loadRequesters = useCallback(async () => { + const resp = await httpClient.getProviderRequesters(); + setRequesterList( + resp.requesters + .filter((item) => item.name !== 'space-chat-completions') + .map((item) => ({ + label: extractI18nObject(item.label), + value: item.name, + category: item.spec.provider_category || 'manufacturer', + defaultUrl: + item.spec.config + .find((c) => c.name === 'base_url') + ?.default?.toString() || '', + description: extractI18nObject(item.description), + })), + ); + }, []); + + const loadProvider = useCallback( + async (id: string) => { + const resp = await httpClient.getModelProvider(id); + const provider = resp.provider; + + setValue('name', provider.name); + setValue('requester', provider.requester); + setValue('base_url', provider.base_url); + setValue('api_key', provider.api_keys?.[0] || ''); + }, + [setValue], + ); + useEffect(() => { async function init() { await loadRequesters(); @@ -77,7 +109,7 @@ export default function ProviderForm({ } } init(); - }, [providerId]); + }, [providerId, loadProvider, loadRequesters]); // Close dropdown when clicking outside useEffect(() => { @@ -127,34 +159,6 @@ export default function ProviderForm({ 'self-hosted': t('models.selfDeployed'), }; - async function loadRequesters() { - const resp = await httpClient.getProviderRequesters(); - setRequesterList( - resp.requesters - .filter((item) => item.name !== 'space-chat-completions') - .map((item) => ({ - label: extractI18nObject(item.label), - value: item.name, - category: item.spec.provider_category || 'manufacturer', - defaultUrl: - item.spec.config - .find((c) => c.name === 'base_url') - ?.default?.toString() || '', - description: extractI18nObject(item.description), - })), - ); - } - - async function loadProvider(id: string) { - const resp = await httpClient.getModelProvider(id); - const provider = resp.provider; - - form.setValue('name', provider.name); - form.setValue('requester', provider.requester); - form.setValue('base_url', provider.base_url); - form.setValue('api_key', provider.api_keys?.[0] || ''); - } - async function handleFormSubmit(values: z.infer) { const data = { name: values.name,