diff --git a/backend/api/server.py b/backend/api/server.py index ea14086..642d0ae 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -49,6 +49,22 @@ from src.token_budget import calculate_token_budget STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static' +VALIDATED_CHAT_MODEL_PROVIDERS = { + # 这些 provider 已用 owned_by/id 形式实际请求 /chat/completions 验证通过。 + 'azure_openai', + 'hunyuan', + 'minimax', + 'moonshot', + 'ppio', + 'siliconflow', + 'tongyi', + 'vertex_ai', + 'volcengine_maas', + 'wenxin', + 'xiaomi', + 'zhipuai', +} + # WebUI 的快速入口只展示可以通过对话输入框安全执行的 slash command。 # 这些命令仍然保留在终端 `/` 列表里,但不适合作为 WebUI 快捷项。 WEBUI_HIDDEN_SLASH_COMMANDS = { @@ -1476,6 +1492,7 @@ def _list_backend_models(base_url: str, api_key: str) -> dict[str, Any]: _join_url(base_url, '/models'), headers={ 'Authorization': f'Bearer {api_key}', + 'api-key': api_key, 'Content-Type': 'application/json', }, method='GET', @@ -1496,60 +1513,92 @@ def _list_backend_models(base_url: str, api_key: str) -> dict[str, Any]: ) from exc models = _normalize_model_list(payload) - return {'models': models, 'raw_count': len(models)} + return { + 'models': models, + 'raw_count': _raw_model_count(payload), + 'filtered_count': len(models), + } def _normalize_model_list(payload: Any) -> list[dict[str, str]]: data = payload.get('data') if isinstance(payload, dict) else payload if not isinstance(data, list): return [] - model_ids: dict[str, str] = {} + models_by_key: dict[str, dict[str, str]] = {} for item in data: if isinstance(item, str): - normalized = _normalize_model_option(item) + normalized = _normalize_model_id(item) if normalized is not None: - model_ids.setdefault(normalized.lower(), normalized) + models_by_key.setdefault(normalized.lower(), {'id': normalized}) continue if not isinstance(item, dict): continue model_id = item.get('id') or item.get('model') or item.get('name') if not isinstance(model_id, str) or not model_id: continue - normalized = _normalize_model_option(model_id) + model_type = _optional_model_string(item.get('model_type') or item.get('type')) + if not _is_llm_model(model_id, model_type): + continue + provider = _optional_model_string( + item.get('owned_by') or item.get('provider') or item.get('owner') + ) + if provider and provider not in VALIDATED_CHAT_MODEL_PROVIDERS: + continue + normalized = _normalize_model_id(model_id, provider=provider) if normalized is not None: - model_ids.setdefault(normalized.lower(), normalized) - return [{'id': model_id} for model_id in sorted(model_ids.values(), key=str.lower)] + entry = {'id': normalized} + if provider: + entry['provider'] = provider + if model_type: + entry['model_type'] = model_type + models_by_key.setdefault(normalized.lower(), entry) + return sorted(models_by_key.values(), key=lambda item: item['id'].lower()) -def _normalize_model_option(model_id: str) -> str | None: - normalized = _normalize_chat_model_name(model_id) - lower = normalized.lower() - if lower.startswith('xiaomi/mimo-v2') and not any( - blocked in lower for blocked in ('audio', 'asr', 'tts', 'voiceclone', 'voicedesign') - ): - return normalized - known_chat_models = { - 'xiaomi/deepseek-r1-0528', - 'xiaomi/minimax-m2.5', - 'xiaomi/qwen3-32b', - } - if lower in known_chat_models: - return normalized - return None +def _raw_model_count(payload: Any) -> int: + data = payload.get('data') if isinstance(payload, dict) else payload + return len(data) if isinstance(data, list) else 0 -def _normalize_chat_model_name(model: str) -> str: +def _optional_model_string(value: Any) -> str | None: + if not isinstance(value, str): + return None + stripped = value.strip() + return stripped or None + + +def _normalize_model_id(model: str, *, provider: str | None = None) -> str | None: value = model.strip() - lower = value.lower() - if lower.startswith('mimo-v2') or lower in { - 'deepseek-r1-0528', - 'minimax-m2.5', - 'qwen3-32b', - }: - return f'xiaomi/{value}' + if not value: + return None + if provider and not value.lower().startswith(f'{provider.lower()}/'): + return f'{provider}/{value}' return value +def _is_llm_model(model_id: str, model_type: str | None) -> bool: + lower = model_id.strip().lower() + blocked_fragments = ( + 'asr', + 'audio', + 'embedding', + 'image_generation', + 'rerank', + 'speech', + 'text2image', + 'transcribe', + 'translation', + 'tts', + 'voiceclone', + 'voicedesign', + ) + if any(fragment in lower for fragment in blocked_fragments): + return False + if model_type: + return model_type.strip().lower() == 'llm' + return True + + def _join_url(base_url: str, suffix: str) -> str: return f'{base_url.rstrip("/")}/{suffix.lstrip("/")}' diff --git a/src/openai_compat.py b/src/openai_compat.py index c30981f..9371a4f 100644 --- a/src/openai_compat.py +++ b/src/openai_compat.py @@ -18,6 +18,14 @@ class OpenAICompatError(RuntimeError): """Raised when the local OpenAI-compatible backend returns an invalid response.""" +PROVIDER_MIN_TEMPERATURES = { + # 实测这些 provider 会拒绝 temperature=0;这里做最小值兜底, + # 保持默认确定性取向的同时避免模型切换后请求直接 400。 + 'minimax': 0.01, + 'wenxin': 0.1, +} + + def _join_url(base_url: str, suffix: str) -> str: base = base_url.rstrip('/') return f'{base}/{suffix.lstrip("/")}' @@ -88,6 +96,14 @@ def _optional_int(value: Any) -> int: return 0 +def _temperature_for_model(model: str, configured: float) -> float: + provider = model.split('/', 1)[0].strip().lower() if '/' in model else '' + minimum = PROVIDER_MIN_TEMPERATURES.get(provider) + if minimum is None: + return configured + return max(configured, minimum) + + def _parse_usage(payload: Any) -> UsageStats: if not isinstance(payload, dict): return UsageStats() @@ -258,11 +274,12 @@ class OpenAICompatClient: payload: dict[str, Any] = { 'model': self.config.model, 'messages': messages, - 'tools': tools, - 'tool_choice': 'auto', - 'temperature': self.config.temperature, + 'temperature': _temperature_for_model(self.config.model, self.config.temperature), 'stream': stream, } + if tools: + payload['tools'] = tools + payload['tool_choice'] = 'auto' if stream: payload['stream_options'] = {'include_usage': True} response_format = _build_response_format(output_schema) diff --git a/tests/test_model_list.py b/tests/test_model_list.py new file mode 100644 index 0000000..fe6677c --- /dev/null +++ b/tests/test_model_list.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from backend.api.server import AgentState, _normalize_model_list, create_app + + +class ModelListTests(unittest.TestCase): + def test_normalize_model_list_uses_provider_prefixed_llm_ids(self) -> None: + payload = { + 'data': [ + { + 'id': 'ASR_NonStreaming', + 'object': 'model', + 'owned_by': 'xiaomi', + 'model_type': 'speech2text', + }, + { + 'id': 'DeepSeek-R1-0528', + 'object': 'model', + 'owned_by': 'xiaomi', + 'model_type': 'llm', + }, + { + 'id': 'gpt-5', + 'object': 'model', + 'owned_by': 'azure_openai', + 'model_type': 'llm', + }, + { + 'id': 'gpt-4o-audio-preview', + 'object': 'model', + 'owned_by': 'azure_openai', + 'model_type': 'llm', + }, + { + 'id': 'embedding-v1', + 'object': 'model', + 'owned_by': 'example', + 'model_type': 'text-embedding', + }, + { + 'id': 'ernie-4.0-turbo-128k', + 'object': 'model', + 'owned_by': 'baidu_qianfan', + 'model_type': 'llm', + }, + { + 'id': 'Pro/deepseek-ai/DeepSeek-V3', + 'object': 'model', + 'owned_by': 'siliconflow', + 'model_type': 'llm', + }, + ] + } + + models = _normalize_model_list(payload) + + self.assertEqual( + [model['id'] for model in models], + [ + 'azure_openai/gpt-5', + 'siliconflow/Pro/deepseek-ai/DeepSeek-V3', + 'xiaomi/DeepSeek-R1-0528', + ], + ) + self.assertEqual(models[0]['provider'], 'azure_openai') + self.assertEqual(models[0]['model_type'], 'llm') + + def test_models_endpoint_returns_provider_prefixed_llm_models(self) -> None: + class FakeResponse: + def __enter__(self) -> 'FakeResponse': + return self + + def __exit__(self, *args: object) -> None: + return None + + def read(self) -> bytes: + return ( + b'{"object":"list","data":[' + b'{"id":"mimo-v2-flash","object":"model","owned_by":"xiaomi","model_type":"llm"},' + b'{"id":"ASR_Streaming","object":"model","owned_by":"xiaomi","model_type":"speech2text"},' + b'{"id":"gpt-5","object":"model","owned_by":"azure_openai","model_type":"llm"}' + b']}' + ) + + with TemporaryDirectory() as tmp_dir: + state = AgentState( + cwd=Path(tmp_dir), + model='xiaomi/mimo-v2-flash', + base_url='http://model.example/v1', + api_key='token', + allow_shell=False, + allow_write=False, + session_directory=Path(tmp_dir) / 'sessions', + ) + client = TestClient(create_app(state)) + with patch('backend.api.server.request.urlopen', return_value=FakeResponse()): + response = client.get('/api/models') + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload['raw_count'], 3) + self.assertEqual(payload['filtered_count'], 2) + self.assertEqual( + [model['id'] for model in payload['models']], + ['azure_openai/gpt-5', 'xiaomi/mimo-v2-flash'], + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_openai_compat.py b/tests/test_openai_compat.py index 3a13870..7d998b6 100644 --- a/tests/test_openai_compat.py +++ b/tests/test_openai_compat.py @@ -3,6 +3,7 @@ from __future__ import annotations import unittest from src.openai_compat import ( + OpenAICompatClient, OpenAICompatError, _build_response_format, _join_url, @@ -10,8 +11,9 @@ from src.openai_compat import ( _optional_int, _parse_tool_arguments, _parse_usage, + _temperature_for_model, ) -from src.agent_types import OutputSchemaConfig, UsageStats +from src.agent_types import ModelConfig, OutputSchemaConfig, UsageStats class TestJoinUrl(unittest.TestCase): @@ -163,5 +165,50 @@ class TestOptionalInt(unittest.TestCase): self.assertEqual(_optional_int('abc'), 0) +class TestProviderTemperatureFloor(unittest.TestCase): + def test_minimax_temperature_is_clamped_to_provider_minimum(self): + self.assertEqual(_temperature_for_model('minimax/MiniMax-M2.5', 0.0), 0.01) + + def test_wenxin_temperature_is_clamped_to_provider_minimum(self): + self.assertEqual(_temperature_for_model('wenxin/ernie-4.0-turbo-128k', 0.0), 0.1) + + def test_regular_models_keep_configured_temperature(self): + self.assertEqual(_temperature_for_model('xiaomi/mimo-v2-flash', 0.0), 0.0) + + def test_payload_uses_temperature_floor(self): + client = OpenAICompatClient( + ModelConfig(model='minimax/MiniMax-M2.5', temperature=0.0) + ) + payload = client._build_payload( # noqa: SLF001 - verify payload compatibility. + messages=[{'role': 'user', 'content': 'hi'}], + tools=[], + stream=False, + output_schema=None, + ) + self.assertEqual(payload['temperature'], 0.01) + + def test_payload_omits_tool_choice_when_no_tools_are_supplied(self): + client = OpenAICompatClient(ModelConfig(model='azure_openai/gpt-4o-mini')) + payload = client._build_payload( # noqa: SLF001 - verify provider compatibility. + messages=[{'role': 'user', 'content': 'hi'}], + tools=[], + stream=False, + output_schema=None, + ) + self.assertNotIn('tools', payload) + self.assertNotIn('tool_choice', payload) + + def test_payload_includes_tool_choice_when_tools_are_supplied(self): + client = OpenAICompatClient(ModelConfig(model='azure_openai/gpt-4o-mini')) + payload = client._build_payload( # noqa: SLF001 - verify provider compatibility. + messages=[{'role': 'user', 'content': 'hi'}], + tools=[{'type': 'function', 'function': {'name': 'noop', 'parameters': {}}}], + stream=False, + output_schema=None, + ) + self.assertIn('tools', payload) + self.assertEqual(payload['tool_choice'], 'auto') + + if __name__ == '__main__': unittest.main()