List validated chat model providers
This commit is contained in:
+78
-29
@@ -49,6 +49,22 @@ from src.token_budget import calculate_token_budget
|
|||||||
|
|
||||||
STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static'
|
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 的快速入口只展示可以通过对话输入框安全执行的 slash command。
|
||||||
# 这些命令仍然保留在终端 `/` 列表里,但不适合作为 WebUI 快捷项。
|
# 这些命令仍然保留在终端 `/` 列表里,但不适合作为 WebUI 快捷项。
|
||||||
WEBUI_HIDDEN_SLASH_COMMANDS = {
|
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'),
|
_join_url(base_url, '/models'),
|
||||||
headers={
|
headers={
|
||||||
'Authorization': f'Bearer {api_key}',
|
'Authorization': f'Bearer {api_key}',
|
||||||
|
'api-key': api_key,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
method='GET',
|
method='GET',
|
||||||
@@ -1496,60 +1513,92 @@ def _list_backend_models(base_url: str, api_key: str) -> dict[str, Any]:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
models = _normalize_model_list(payload)
|
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]]:
|
def _normalize_model_list(payload: Any) -> list[dict[str, str]]:
|
||||||
data = payload.get('data') if isinstance(payload, dict) else payload
|
data = payload.get('data') if isinstance(payload, dict) else payload
|
||||||
if not isinstance(data, list):
|
if not isinstance(data, list):
|
||||||
return []
|
return []
|
||||||
model_ids: dict[str, str] = {}
|
models_by_key: dict[str, dict[str, str]] = {}
|
||||||
for item in data:
|
for item in data:
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
normalized = _normalize_model_option(item)
|
normalized = _normalize_model_id(item)
|
||||||
if normalized is not None:
|
if normalized is not None:
|
||||||
model_ids.setdefault(normalized.lower(), normalized)
|
models_by_key.setdefault(normalized.lower(), {'id': normalized})
|
||||||
continue
|
continue
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
model_id = item.get('id') or item.get('model') or item.get('name')
|
model_id = item.get('id') or item.get('model') or item.get('name')
|
||||||
if not isinstance(model_id, str) or not model_id:
|
if not isinstance(model_id, str) or not model_id:
|
||||||
continue
|
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:
|
if normalized is not None:
|
||||||
model_ids.setdefault(normalized.lower(), normalized)
|
entry = {'id': normalized}
|
||||||
return [{'id': model_id} for model_id in sorted(model_ids.values(), key=str.lower)]
|
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:
|
def _raw_model_count(payload: Any) -> int:
|
||||||
normalized = _normalize_chat_model_name(model_id)
|
data = payload.get('data') if isinstance(payload, dict) else payload
|
||||||
lower = normalized.lower()
|
return len(data) if isinstance(data, list) else 0
|
||||||
if lower.startswith('xiaomi/mimo-v2') and not any(
|
|
||||||
blocked in lower for blocked in ('audio', 'asr', 'tts', 'voiceclone', 'voicedesign')
|
|
||||||
):
|
def _optional_model_string(value: Any) -> str | None:
|
||||||
return normalized
|
if not isinstance(value, str):
|
||||||
known_chat_models = {
|
|
||||||
'xiaomi/deepseek-r1-0528',
|
|
||||||
'xiaomi/minimax-m2.5',
|
|
||||||
'xiaomi/qwen3-32b',
|
|
||||||
}
|
|
||||||
if lower in known_chat_models:
|
|
||||||
return normalized
|
|
||||||
return None
|
return None
|
||||||
|
stripped = value.strip()
|
||||||
|
return stripped or None
|
||||||
|
|
||||||
|
|
||||||
def _normalize_chat_model_name(model: str) -> str:
|
def _normalize_model_id(model: str, *, provider: str | None = None) -> str | None:
|
||||||
value = model.strip()
|
value = model.strip()
|
||||||
lower = value.lower()
|
if not value:
|
||||||
if lower.startswith('mimo-v2') or lower in {
|
return None
|
||||||
'deepseek-r1-0528',
|
if provider and not value.lower().startswith(f'{provider.lower()}/'):
|
||||||
'minimax-m2.5',
|
return f'{provider}/{value}'
|
||||||
'qwen3-32b',
|
|
||||||
}:
|
|
||||||
return f'xiaomi/{value}'
|
|
||||||
return 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:
|
def _join_url(base_url: str, suffix: str) -> str:
|
||||||
return f'{base_url.rstrip("/")}/{suffix.lstrip("/")}'
|
return f'{base_url.rstrip("/")}/{suffix.lstrip("/")}'
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -18,6 +18,14 @@ class OpenAICompatError(RuntimeError):
|
|||||||
"""Raised when the local OpenAI-compatible backend returns an invalid response."""
|
"""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:
|
def _join_url(base_url: str, suffix: str) -> str:
|
||||||
base = base_url.rstrip('/')
|
base = base_url.rstrip('/')
|
||||||
return f'{base}/{suffix.lstrip("/")}'
|
return f'{base}/{suffix.lstrip("/")}'
|
||||||
@@ -88,6 +96,14 @@ def _optional_int(value: Any) -> int:
|
|||||||
return 0
|
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:
|
def _parse_usage(payload: Any) -> UsageStats:
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return UsageStats()
|
return UsageStats()
|
||||||
@@ -258,11 +274,12 @@ class OpenAICompatClient:
|
|||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
'model': self.config.model,
|
'model': self.config.model,
|
||||||
'messages': messages,
|
'messages': messages,
|
||||||
'tools': tools,
|
'temperature': _temperature_for_model(self.config.model, self.config.temperature),
|
||||||
'tool_choice': 'auto',
|
|
||||||
'temperature': self.config.temperature,
|
|
||||||
'stream': stream,
|
'stream': stream,
|
||||||
}
|
}
|
||||||
|
if tools:
|
||||||
|
payload['tools'] = tools
|
||||||
|
payload['tool_choice'] = 'auto'
|
||||||
if stream:
|
if stream:
|
||||||
payload['stream_options'] = {'include_usage': True}
|
payload['stream_options'] = {'include_usage': True}
|
||||||
response_format = _build_response_format(output_schema)
|
response_format = _build_response_format(output_schema)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from src.openai_compat import (
|
from src.openai_compat import (
|
||||||
|
OpenAICompatClient,
|
||||||
OpenAICompatError,
|
OpenAICompatError,
|
||||||
_build_response_format,
|
_build_response_format,
|
||||||
_join_url,
|
_join_url,
|
||||||
@@ -10,8 +11,9 @@ from src.openai_compat import (
|
|||||||
_optional_int,
|
_optional_int,
|
||||||
_parse_tool_arguments,
|
_parse_tool_arguments,
|
||||||
_parse_usage,
|
_parse_usage,
|
||||||
|
_temperature_for_model,
|
||||||
)
|
)
|
||||||
from src.agent_types import OutputSchemaConfig, UsageStats
|
from src.agent_types import ModelConfig, OutputSchemaConfig, UsageStats
|
||||||
|
|
||||||
|
|
||||||
class TestJoinUrl(unittest.TestCase):
|
class TestJoinUrl(unittest.TestCase):
|
||||||
@@ -163,5 +165,50 @@ class TestOptionalInt(unittest.TestCase):
|
|||||||
self.assertEqual(_optional_int('abc'), 0)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user