933 lines
30 KiB
Python
933 lines
30 KiB
Python
# -*- coding: utf-8 -*-
|
|
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
import pineagents.providers.provider_manager as provider_manager_module
|
|
from pineagents.config.config import ModelSlotConfig
|
|
from pineagents.exceptions import ModelNotFoundException, ProviderError
|
|
from pineagents.local_models.llamacpp import LlamaCppServerSetupResult
|
|
from pineagents.providers.anthropic_provider import AnthropicProvider
|
|
from pineagents.providers.capping_formatter import (
|
|
_CappingAnthropicFormatter,
|
|
_CappingGeminiFormatter,
|
|
_CappingOpenAIFormatter,
|
|
)
|
|
from pineagents.providers.context_windows import DEFAULT_CONTEXT_WINDOW
|
|
from pineagents.providers.openai_provider import (
|
|
GitHubModelsProvider,
|
|
OpenAIProvider,
|
|
)
|
|
from pineagents.providers.provider import ModelInfo, ProviderInfo
|
|
from pineagents.providers.provider_manager import ProviderManager
|
|
|
|
LEGACY_PROVIDER = {
|
|
"providers": {
|
|
"modelscope": {
|
|
"base_url": "https://api-inference.modelscope.cn/v1",
|
|
"api_key": "",
|
|
"extra_models": [],
|
|
"chat_model": "",
|
|
},
|
|
"dashscope": {
|
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
"api_key": "sk-test-legacy-secret",
|
|
"extra_models": [{"id": "qwen-plus", "name": "Qwen Plus"}],
|
|
"chat_model": "",
|
|
},
|
|
"aliyun-codingplan": {
|
|
"base_url": "https://coding.dashscope.aliyuncs.com/v1",
|
|
"api_key": "",
|
|
"extra_models": [],
|
|
"chat_model": "",
|
|
},
|
|
"openai": {
|
|
"base_url": "https://api.openai.com/v1",
|
|
"api_key": "",
|
|
"extra_models": [],
|
|
"chat_model": "",
|
|
},
|
|
"azure-openai": {
|
|
"base_url": "",
|
|
"api_key": "",
|
|
"extra_models": [],
|
|
"chat_model": "",
|
|
},
|
|
"anthropic": {
|
|
"base_url": "https://api.anthropic.com/v1",
|
|
"api_key": "",
|
|
"extra_models": [],
|
|
"chat_model": "",
|
|
},
|
|
"ollama": {
|
|
"base_url": "http://myhost:11434/v1",
|
|
"api_key": "",
|
|
"extra_models": [],
|
|
"chat_model": "",
|
|
},
|
|
},
|
|
"custom_providers": {
|
|
"mydash": {
|
|
"id": "mydash",
|
|
"name": "MyDash",
|
|
"default_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", # noqa: E501
|
|
"api_key_prefix": "sk-",
|
|
"models": [{"id": "qwen3-max", "name": "qwen3-max"}],
|
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
"api_key": "sk-test-legacy-custom-secret",
|
|
"chat_model": "OpenAIChatModel",
|
|
},
|
|
},
|
|
"active_llm": {"provider_id": "dashscope", "model": "qwen3-max"},
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_secret_dir(monkeypatch, tmp_path):
|
|
secret_dir = tmp_path / ".pineagents.secret"
|
|
monkeypatch.setattr(provider_manager_module, "SECRET_DIR", secret_dir)
|
|
return secret_dir
|
|
|
|
|
|
def test_builtin_zhipu_providers_registered(isolated_secret_dir) -> None:
|
|
manager = ProviderManager()
|
|
|
|
expected_configs = {
|
|
"zhipu-cn": {
|
|
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
|
"support_connection_check": True,
|
|
},
|
|
"zhipu-cn-codingplan": {
|
|
"base_url": "https://open.bigmodel.cn/api/coding/paas/v4",
|
|
"support_connection_check": False,
|
|
},
|
|
"zhipu-intl": {
|
|
"base_url": "https://api.z.ai/api/paas/v4",
|
|
"support_connection_check": True,
|
|
},
|
|
"zhipu-intl-codingplan": {
|
|
"base_url": "https://api.z.ai/api/coding/paas/v4",
|
|
"support_connection_check": False,
|
|
},
|
|
}
|
|
|
|
for provider_id, expected in expected_configs.items():
|
|
provider = manager.get_provider(provider_id)
|
|
|
|
assert provider is not None
|
|
assert isinstance(provider, OpenAIProvider)
|
|
assert provider.base_url == expected["base_url"]
|
|
assert provider.freeze_url is True
|
|
assert (
|
|
provider.support_connection_check
|
|
== expected["support_connection_check"]
|
|
)
|
|
model_ids = [m.id for m in provider.models]
|
|
assert len(model_ids) > 0
|
|
assert len(model_ids) == len(set(model_ids))
|
|
|
|
|
|
async def test_add_custom_provider_and_reload_from_storage(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
custom = OpenAIProvider(
|
|
id="custom-openai",
|
|
name="Custom OpenAI",
|
|
base_url="https://custom.example/v1",
|
|
api_key="sk-custom",
|
|
models=[ModelInfo(id="custom-model", name="Custom Model")],
|
|
)
|
|
|
|
created = await manager.add_custom_provider(custom)
|
|
builtin_conflict = await manager.add_custom_provider(
|
|
OpenAIProvider(
|
|
id="openai",
|
|
name="Conflict OpenAI",
|
|
),
|
|
)
|
|
duplicate = await manager.add_custom_provider(custom)
|
|
|
|
reloaded = ProviderManager()
|
|
loaded = reloaded.get_provider("custom-openai")
|
|
loaded_builtin_conflict = reloaded.get_provider("openai-custom")
|
|
loaded_duplicate = reloaded.get_provider("custom-openai-new")
|
|
|
|
assert created.id == "custom-openai"
|
|
assert builtin_conflict.id == "openai-custom"
|
|
assert duplicate.id == "custom-openai-new"
|
|
assert loaded is not None
|
|
assert isinstance(loaded, OpenAIProvider)
|
|
assert loaded.is_custom is True
|
|
assert loaded.base_url == "https://custom.example/v1"
|
|
assert loaded.api_key == "sk-custom"
|
|
assert [m.id for m in loaded.models] == ["custom-model"]
|
|
assert loaded_builtin_conflict is not None
|
|
assert isinstance(loaded_builtin_conflict, OpenAIProvider)
|
|
assert loaded_duplicate is not None
|
|
assert isinstance(loaded_duplicate, OpenAIProvider)
|
|
|
|
|
|
async def test_custom_provider_preserves_explicit_default_context_window(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
request_model = ModelInfo(
|
|
id="claude-sonnet-4-5",
|
|
name="Claude Sonnet 4.5",
|
|
max_input_length=DEFAULT_CONTEXT_WINDOW,
|
|
)
|
|
assert "max_input_length" in request_model.model_fields_set
|
|
assert request_model.max_input_length_configured is False
|
|
|
|
await manager.add_custom_provider(
|
|
ProviderInfo(
|
|
id="custom-context-window",
|
|
name="Custom Context Window",
|
|
chat_model="OpenAIChatModel",
|
|
extra_models=[request_model],
|
|
),
|
|
)
|
|
|
|
reloaded = ProviderManager().get_provider("custom-context-window")
|
|
assert reloaded is not None
|
|
model = reloaded.get_model_info("claude-sonnet-4-5")
|
|
assert model is not None
|
|
assert model.max_input_length_configured is True
|
|
assert (
|
|
reloaded.get_context_size("claude-sonnet-4-5")
|
|
== DEFAULT_CONTEXT_WINDOW
|
|
)
|
|
|
|
|
|
async def test_activate_provider_persists_active_model(
|
|
isolated_secret_dir,
|
|
monkeypatch,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
|
|
class FakeCompletions:
|
|
async def create(self, **kwargs):
|
|
return SimpleNamespace(id="ok", request=kwargs)
|
|
|
|
fake_client = SimpleNamespace(
|
|
chat=SimpleNamespace(completions=FakeCompletions()),
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
OpenAIProvider,
|
|
"_client",
|
|
lambda self, timeout=5: fake_client,
|
|
)
|
|
|
|
await manager.activate_model("openai", "gpt-5")
|
|
|
|
assert manager.active_model is not None
|
|
assert manager.active_model.provider_id == "openai"
|
|
assert manager.active_model.model == "gpt-5"
|
|
|
|
reloaded = ProviderManager()
|
|
assert reloaded.active_model is not None
|
|
assert reloaded.active_model.provider_id == "openai"
|
|
assert reloaded.active_model.model == "gpt-5"
|
|
|
|
|
|
async def test_resume_local_model_restores_server_and_runtime_state(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
model_id = "AgentScope/QwenPaw-Flash-2B-Q4_K_M"
|
|
manager.update_provider(
|
|
"qwenpaw-local",
|
|
{
|
|
"base_url": "http://127.0.0.1:9000/v1",
|
|
"extra_models": [
|
|
{
|
|
"id": model_id,
|
|
"name": model_id,
|
|
},
|
|
],
|
|
},
|
|
)
|
|
manager.active_model = ModelSlotConfig(
|
|
provider_id="qwenpaw-local",
|
|
model=model_id,
|
|
)
|
|
manager.save_active_model(manager.active_model)
|
|
|
|
class FakeLocalManager:
|
|
def __init__(self) -> None:
|
|
self.restored_model_id = None
|
|
|
|
def check_llamacpp_installation(self) -> tuple[bool, str]:
|
|
return True, ""
|
|
|
|
def is_model_downloaded(self, requested_model_id: str) -> bool:
|
|
return requested_model_id == model_id
|
|
|
|
async def setup_server(
|
|
self,
|
|
requested_model_id: str,
|
|
) -> LlamaCppServerSetupResult:
|
|
self.restored_model_id = requested_model_id
|
|
return LlamaCppServerSetupResult(
|
|
port=43111,
|
|
model_info=ModelInfo(
|
|
id=requested_model_id,
|
|
name=requested_model_id,
|
|
supports_multimodal=True,
|
|
supports_image=True,
|
|
supports_video=True,
|
|
probe_source="documentation",
|
|
),
|
|
)
|
|
|
|
local_manager = FakeLocalManager()
|
|
|
|
await manager._resume_local_model(local_manager)
|
|
|
|
provider = manager.get_provider("qwenpaw-local")
|
|
|
|
assert local_manager.restored_model_id == model_id
|
|
assert provider is not None
|
|
assert provider.base_url == "http://127.0.0.1:43111/v1"
|
|
assert [model.id for model in provider.extra_models] == [model_id]
|
|
assert provider.extra_models[0].supports_multimodal is True
|
|
assert provider.extra_models[0].supports_image is True
|
|
assert provider.extra_models[0].supports_video is True
|
|
assert provider.extra_models[0].probe_source == "documentation"
|
|
|
|
|
|
async def test_remove_custom_provider_missing_file_is_safe(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
custom = OpenAIProvider(
|
|
id="custom-to-remove",
|
|
name="Custom To Remove",
|
|
base_url="https://remove.example/v1",
|
|
api_key="sk-remove",
|
|
)
|
|
await manager.add_custom_provider(custom)
|
|
|
|
custom_path = manager.custom_path / "custom-to-remove.json"
|
|
custom_path.unlink()
|
|
|
|
manager.remove_custom_provider("custom-to-remove")
|
|
|
|
assert manager.get_provider("custom-to-remove") is None
|
|
|
|
|
|
def test_load_provider_invalid_json_returns_none(isolated_secret_dir) -> None:
|
|
manager = ProviderManager()
|
|
bad_file = manager.custom_path / "bad-provider.json"
|
|
bad_file.write_text("{invalid-json", encoding="utf-8")
|
|
|
|
loaded = manager.load_provider("bad-provider", is_builtin=False)
|
|
|
|
assert loaded is None
|
|
|
|
|
|
def test_migrate_legacy_file_and_persist_active_model(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
isolated_secret_dir.mkdir(parents=True, exist_ok=True)
|
|
legacy_file = isolated_secret_dir / "providers.json"
|
|
legacy_file.write_text(
|
|
json.dumps(
|
|
LEGACY_PROVIDER,
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager = ProviderManager()
|
|
|
|
assert legacy_file.exists() is False
|
|
assert manager.active_model is not None
|
|
assert manager.active_model.provider_id == "dashscope"
|
|
assert manager.active_model.model == "qwen3-max"
|
|
|
|
dashscope_provider = manager.get_provider("dashscope")
|
|
assert dashscope_provider is not None
|
|
assert dashscope_provider.api_key == "sk-test-legacy-secret"
|
|
|
|
legacy_custom = manager.get_provider("mydash")
|
|
assert legacy_custom is not None
|
|
assert isinstance(legacy_custom, OpenAIProvider)
|
|
assert len(legacy_custom.extra_models) == 1
|
|
assert legacy_custom.extra_models[0].id == "qwen3-max"
|
|
assert legacy_custom.api_key == "sk-test-legacy-custom-secret"
|
|
|
|
legacy_ollama = manager.get_provider("ollama")
|
|
assert legacy_ollama.base_url == "http://myhost:11434"
|
|
|
|
active_model_file = isolated_secret_dir / "providers" / "active_model.json"
|
|
assert active_model_file.exists()
|
|
|
|
|
|
async def test_add_custom_provider_conflict_resolution_loops_until_unique(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
conflict = OpenAIProvider(
|
|
id="openai",
|
|
name="Conflict OpenAI",
|
|
)
|
|
|
|
first = await manager.add_custom_provider(conflict)
|
|
second = await manager.add_custom_provider(conflict)
|
|
third = await manager.add_custom_provider(conflict)
|
|
|
|
assert first.id == "openai-custom"
|
|
assert second.id == "openai-custom-new"
|
|
assert third.id == "openai-custom-new-new"
|
|
|
|
assert manager.get_provider("openai-custom") is not None
|
|
assert manager.get_provider("openai-custom-new") is not None
|
|
assert manager.get_provider("openai-custom-new-new") is not None
|
|
|
|
|
|
def test_update_provider_for_builtin_persists_to_builtin_path(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
|
|
ok = manager.update_provider(
|
|
"openai",
|
|
{
|
|
"base_url": "https://updated.example/v1", # not taken effect
|
|
"api_key": "sk-updated",
|
|
},
|
|
)
|
|
|
|
assert ok is True
|
|
persisted = manager.load_provider("openai", is_builtin=True)
|
|
assert persisted is not None
|
|
assert isinstance(persisted, OpenAIProvider)
|
|
assert persisted.base_url == "https://api.openai.com/v1"
|
|
assert persisted.api_key == "sk-updated"
|
|
|
|
ok = manager.update_provider(
|
|
"azure-openai",
|
|
{
|
|
"base_url": "https://azure-updated.example/v1",
|
|
"api_key": "sk-azure-updated",
|
|
},
|
|
)
|
|
assert ok is True
|
|
persisted_azure = manager.load_provider("azure-openai", is_builtin=True)
|
|
assert persisted_azure is not None
|
|
assert isinstance(persisted_azure, OpenAIProvider)
|
|
assert persisted_azure.base_url == "https://azure-updated.example/v1"
|
|
assert persisted_azure.api_key == "sk-azure-updated"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("saved_length", "expected_configured"),
|
|
[
|
|
(64_000, True),
|
|
(DEFAULT_CONTEXT_WINDOW, False),
|
|
],
|
|
)
|
|
def test_legacy_builtin_context_window_infers_non_default_as_configured(
|
|
isolated_secret_dir,
|
|
saved_length: int,
|
|
expected_configured: bool,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
provider = manager.get_provider("openai")
|
|
assert provider is not None
|
|
data = provider.model_dump()
|
|
for model in data["models"]:
|
|
model.pop("max_input_length_configured", None)
|
|
if model["id"] == "gpt-4o":
|
|
model["max_input_length"] = saved_length
|
|
|
|
builtin_path = isolated_secret_dir / "providers" / "builtin"
|
|
(builtin_path / "openai.json").write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
reloaded = ProviderManager().get_provider("openai")
|
|
assert reloaded is not None
|
|
model = reloaded.get_model_info("gpt-4o")
|
|
assert model is not None
|
|
assert model.max_input_length == saved_length
|
|
assert model.max_input_length_configured is expected_configured
|
|
|
|
|
|
def test_builtin_capability_probe_results_survive_storage_reload(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
provider = manager.get_provider("openai")
|
|
assert provider is not None
|
|
data = provider.model_dump()
|
|
for model in data["models"]:
|
|
if model["id"] == "gpt-4o":
|
|
model["supports_multimodal"] = False
|
|
model["supports_image"] = False
|
|
model["supports_video"] = False
|
|
|
|
builtin_path = isolated_secret_dir / "providers" / "builtin"
|
|
(builtin_path / "openai.json").write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
reloaded = ProviderManager().get_provider("openai")
|
|
assert reloaded is not None
|
|
model = reloaded.get_model_info("gpt-4o")
|
|
assert model is not None
|
|
assert model.supports_multimodal is False
|
|
assert model.supports_image is False
|
|
assert model.supports_video is False
|
|
|
|
|
|
def test_update_provider_for_unknown_returns_false(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
|
|
ok = manager.update_provider("unknown-provider", {"api_key": "sk-x"})
|
|
|
|
assert ok is False
|
|
|
|
|
|
async def test_activate_provider_invalid_provider_raises(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
|
|
with pytest.raises(ProviderError, match="Provider 'missing' not found"):
|
|
await manager.activate_model("missing", "gpt-5")
|
|
|
|
|
|
async def test_activate_provider_invalid_model_raises(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
|
|
with pytest.raises(ModelNotFoundException, match="not-exists"):
|
|
await manager.activate_model("openai", "not-exists")
|
|
|
|
|
|
async def test_add_model_to_provider_duplicate_id_raises(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
model_info = ModelInfo(id="custom-duplicate", name="Custom Duplicate")
|
|
|
|
provider = await manager.add_model_to_provider("openai", model_info)
|
|
|
|
assert [m.id for m in provider.extra_models].count("custom-duplicate") == 1
|
|
|
|
with pytest.raises(ProviderError, match="already exists"):
|
|
await manager.add_model_to_provider("openai", model_info)
|
|
|
|
reloaded = ProviderManager()
|
|
reloaded_provider = reloaded.get_provider("openai")
|
|
|
|
assert reloaded_provider is not None
|
|
assert reloaded_provider.extra_models is not None
|
|
assert [m.id for m in reloaded_provider.extra_models].count(
|
|
"custom-duplicate",
|
|
) == 1
|
|
|
|
|
|
def test_save_provider_skip_if_exists_does_not_overwrite(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
provider = OpenAIProvider(
|
|
id="custom-skip",
|
|
name="Original",
|
|
api_key="sk-original",
|
|
)
|
|
manager._save_provider(provider, is_builtin=False)
|
|
|
|
provider.name = "Changed"
|
|
provider.api_key = "sk-changed"
|
|
manager._save_provider(provider, is_builtin=False, skip_if_exists=True)
|
|
|
|
loaded = manager.load_provider("custom-skip", is_builtin=False)
|
|
assert loaded is not None
|
|
assert loaded.name == "Original"
|
|
assert loaded.api_key == "sk-original"
|
|
|
|
|
|
def test_load_provider_missing_returns_none(isolated_secret_dir) -> None:
|
|
manager = ProviderManager()
|
|
|
|
loaded = manager.load_provider("not-found", is_builtin=False)
|
|
|
|
assert loaded is None
|
|
|
|
|
|
def test_provider_from_data_dispatch_to_anthropic(isolated_secret_dir) -> None:
|
|
manager = ProviderManager()
|
|
|
|
provider = manager._provider_from_data(
|
|
{
|
|
"id": "custom-anthropic",
|
|
"name": "Custom Anthropic",
|
|
"chat_model": "AnthropicChatModel",
|
|
"api_key": "sk-ant-x",
|
|
},
|
|
)
|
|
|
|
assert isinstance(provider, AnthropicProvider)
|
|
|
|
|
|
def test_provider_from_data_fallback_to_openai(isolated_secret_dir) -> None:
|
|
manager = ProviderManager()
|
|
|
|
provider = manager._provider_from_data(
|
|
{
|
|
"id": "custom-openai-like",
|
|
"name": "OpenAI Like",
|
|
"base_url": "https://custom.example/v1",
|
|
},
|
|
)
|
|
|
|
assert isinstance(provider, OpenAIProvider)
|
|
|
|
|
|
def test_init_from_storage_migrates_with_different_provider(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
builtin_path = isolated_secret_dir / "providers" / "builtin"
|
|
builtin_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
legacy_minimax_provider = {
|
|
"id": "minimax",
|
|
"name": "MiniMax",
|
|
"base_url": "https://api.minimax.io/v1",
|
|
"api_key": "sk-legacy-minimax",
|
|
"chat_model": "OpenAIChatModel",
|
|
"models": [{"id": "MiniMax-M2.5", "name": "MiniMax M2.5"}],
|
|
"generate_kwargs": {"temperature": 1.0},
|
|
}
|
|
(builtin_path / "minimax.json").write_text(
|
|
json.dumps(legacy_minimax_provider, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager = ProviderManager()
|
|
|
|
provider = manager.get_provider("minimax")
|
|
|
|
assert provider is not None
|
|
assert isinstance(provider, AnthropicProvider)
|
|
# url / name / chatmodel should be updated
|
|
assert provider.base_url == "https://api.minimax.io/anthropic"
|
|
assert provider.chat_model == "AnthropicChatModel"
|
|
assert provider.name == "MiniMax (International)"
|
|
# api key should be preserved
|
|
assert provider.api_key == "sk-legacy-minimax"
|
|
|
|
from agentscope.model import AnthropicChatModel
|
|
|
|
assert provider.get_chat_model_cls() == AnthropicChatModel
|
|
|
|
legacy_ollama_provider = {
|
|
"id": "ollama",
|
|
"name": "Ollama New",
|
|
"base_url": "http://legacy-ollama:11434",
|
|
"api_key": "sk-legacy-ollama",
|
|
"chat_model": "OpenAIChatModel",
|
|
"models": [],
|
|
}
|
|
(builtin_path / "ollama.json").write_text(
|
|
json.dumps(legacy_ollama_provider, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
manager = ProviderManager()
|
|
assert manager.get_provider("ollama") is not None
|
|
assert (
|
|
manager.get_provider("ollama").base_url == "http://legacy-ollama:11434"
|
|
)
|
|
|
|
|
|
def test_provider_group_metadata(isolated_secret_dir) -> None:
|
|
"""Providers in the same brand share provider_group."""
|
|
manager = ProviderManager()
|
|
|
|
aliyun_ids = [
|
|
"dashscope",
|
|
"aliyun-codingplan",
|
|
"aliyun-codingplan-intl",
|
|
"aliyun-tokenplan",
|
|
]
|
|
for pid in aliyun_ids:
|
|
p = manager.get_provider(pid)
|
|
assert p is not None, f"{pid} not found"
|
|
assert p.provider_group == "aliyun"
|
|
assert p.provider_group_name == "Aliyun"
|
|
|
|
kimi_ids = ["kimi-cn", "kimi-intl", "kimi-codingplan"]
|
|
for pid in kimi_ids:
|
|
p = manager.get_provider(pid)
|
|
assert p is not None, f"{pid} not found"
|
|
assert p.provider_group == "kimi"
|
|
|
|
volcengine_ids = ["volcengine-cn", "volcengine-cn-codingplan"]
|
|
for pid in volcengine_ids:
|
|
p = manager.get_provider(pid)
|
|
assert p is not None, f"{pid} not found"
|
|
assert p.provider_group == "volcengine"
|
|
|
|
|
|
async def test_provider_group_in_get_info(isolated_secret_dir) -> None:
|
|
"""get_info() should include provider_group fields."""
|
|
manager = ProviderManager()
|
|
provider = manager.get_provider("dashscope")
|
|
assert provider is not None
|
|
|
|
info = await provider.get_info()
|
|
assert info.provider_group == "aliyun"
|
|
assert info.provider_group_name == "Aliyun"
|
|
assert info.provider_variant == "dashscope"
|
|
|
|
|
|
def test_dashscope_max_inline_media_bytes_loaded_from_json(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
"""A user-set ``max_inline_media_bytes`` in dashscope.json must be
|
|
loaded by ``_init_from_storage`` and actually used by the capping
|
|
formatter at runtime.
|
|
|
|
Writes a builtin dashscope.json with a custom threshold, boots a fresh
|
|
``ProviderManager`` (which runs ``_init_from_storage``), and asserts
|
|
the runtime builtin instance — not just the freshly deserialized one —
|
|
carries the value through to the formatter.
|
|
"""
|
|
builtin_path = isolated_secret_dir / "providers" / "builtin"
|
|
builtin_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
dashscope_json = {
|
|
"id": "dashscope",
|
|
"name": "DashScope",
|
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
"api_key": "sk-test",
|
|
"chat_model": "DashScopeChatModel",
|
|
"models": [{"id": "qwen3-max", "name": "Qwen3 Max"}],
|
|
"max_inline_media_bytes": 4096,
|
|
}
|
|
(builtin_path / "dashscope.json").write_text(
|
|
json.dumps(dashscope_json, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager = ProviderManager()
|
|
|
|
provider = manager.get_provider("dashscope")
|
|
assert provider is not None
|
|
# The runtime builtin must reflect the value loaded from disk, not the
|
|
# field default (2 MB).
|
|
assert provider.max_inline_media_bytes == 4096
|
|
|
|
# And it must reach the capping formatter that actually guards requests.
|
|
model = provider.get_chat_model_instance("qwen3-max")
|
|
assert model.formatter.max_bytes == 4096
|
|
|
|
|
|
def test_dashscope_max_inline_media_bytes_defaults_when_absent(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
"""An existing dashscope.json without the new key must fall back to the
|
|
built-in default (2 MB) — i.e. upgrading must not silently cap at 0."""
|
|
builtin_path = isolated_secret_dir / "providers" / "builtin"
|
|
builtin_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Legacy JSON: no max_inline_media_bytes key at all.
|
|
dashscope_json = {
|
|
"id": "dashscope",
|
|
"name": "DashScope",
|
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
"api_key": "sk-test",
|
|
"chat_model": "DashScopeChatModel",
|
|
"models": [{"id": "qwen3-max", "name": "Qwen3 Max"}],
|
|
}
|
|
(builtin_path / "dashscope.json").write_text(
|
|
json.dumps(dashscope_json, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
manager = ProviderManager()
|
|
provider = manager.get_provider("dashscope")
|
|
assert provider is not None
|
|
assert provider.max_inline_media_bytes == 2 * 1024 * 1024
|
|
assert (
|
|
provider.get_chat_model_instance("qwen3-max").formatter.max_bytes
|
|
== 2 * 1024 * 1024
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inline-media capping for the other providers (OpenAI / Anthropic / Gemini).
|
|
# Same oversized-request bug as DashScope: their agentscope formatters read
|
|
# every file:// media off disk and base64-inline the whole file on every
|
|
# call. Each provider now wires a shared capping formatter and exposes the
|
|
# same configurable ``max_inline_media_bytes`` field, restored by
|
|
# ``_init_from_storage`` via the generic ``hasattr`` branch.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# (provider_id, chat_model, model_id, capping_formatter_cls)
|
|
_CAPPING_PROVIDER_CASES = [
|
|
("openai", "OpenAIChatModel", "gpt-4o", _CappingOpenAIFormatter),
|
|
(
|
|
"anthropic",
|
|
"AnthropicChatModel",
|
|
"claude-3-5-sonnet",
|
|
_CappingAnthropicFormatter,
|
|
),
|
|
(
|
|
"gemini",
|
|
"GeminiChatModel",
|
|
"gemini-2.0-flash",
|
|
_CappingGeminiFormatter,
|
|
),
|
|
]
|
|
|
|
|
|
def _write_builtin_provider_json(
|
|
isolated_secret_dir,
|
|
provider_id: str,
|
|
chat_model: str,
|
|
model_id: str,
|
|
*,
|
|
with_cap: bool,
|
|
) -> None:
|
|
"""Write a builtin <id>.json under providers/builtin/.
|
|
|
|
``with_cap=True`` sets a 4096-byte ``max_inline_media_bytes``;
|
|
``False`` omits the key (legacy JSON) to exercise the default fallback.
|
|
"""
|
|
builtin_path = isolated_secret_dir / "providers" / "builtin"
|
|
builtin_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
data = {
|
|
"id": provider_id,
|
|
"name": provider_id.title(),
|
|
"base_url": "https://example.test/v1",
|
|
"api_key": "sk-test",
|
|
"chat_model": chat_model,
|
|
"models": [{"id": model_id, "name": model_id}],
|
|
}
|
|
if with_cap:
|
|
data["max_inline_media_bytes"] = 4096
|
|
(builtin_path / f"{provider_id}.json").write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_id,chat_model,model_id,formatter_cls",
|
|
_CAPPING_PROVIDER_CASES,
|
|
)
|
|
def test_max_inline_media_bytes_loaded_from_json(
|
|
isolated_secret_dir,
|
|
provider_id,
|
|
chat_model,
|
|
model_id,
|
|
formatter_cls,
|
|
) -> None:
|
|
"""A user-set ``max_inline_media_bytes`` in <id>.json must be loaded by
|
|
``_init_from_storage`` and reach the runtime capping formatter."""
|
|
_write_builtin_provider_json(
|
|
isolated_secret_dir,
|
|
provider_id,
|
|
chat_model,
|
|
model_id,
|
|
with_cap=True,
|
|
)
|
|
|
|
manager = ProviderManager()
|
|
provider = manager.get_provider(provider_id)
|
|
assert provider is not None
|
|
# Runtime builtin reflects the disk value, not the 2 MB default.
|
|
assert provider.max_inline_media_bytes == 4096
|
|
|
|
model = provider.get_chat_model_instance(model_id)
|
|
assert isinstance(model.formatter, formatter_cls)
|
|
assert model.formatter.max_bytes == 4096
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_id,chat_model,model_id,formatter_cls",
|
|
_CAPPING_PROVIDER_CASES,
|
|
)
|
|
def test_max_inline_media_bytes_defaults_when_absent(
|
|
isolated_secret_dir,
|
|
provider_id,
|
|
chat_model,
|
|
model_id,
|
|
formatter_cls,
|
|
) -> None:
|
|
"""A legacy <id>.json without the key falls back to the 2 MB default
|
|
(upgrading must not silently cap at 0)."""
|
|
_write_builtin_provider_json(
|
|
isolated_secret_dir,
|
|
provider_id,
|
|
chat_model,
|
|
model_id,
|
|
with_cap=False,
|
|
)
|
|
|
|
manager = ProviderManager()
|
|
provider = manager.get_provider(provider_id)
|
|
assert provider is not None
|
|
assert provider.max_inline_media_bytes == 2 * 1024 * 1024
|
|
|
|
model = provider.get_chat_model_instance(model_id)
|
|
assert isinstance(model.formatter, formatter_cls)
|
|
assert model.formatter.max_bytes == 2 * 1024 * 1024
|
|
|
|
|
|
async def test_github_models_provider_uses_new_endpoint_and_prefixes(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
provider = manager.get_provider("github-models")
|
|
|
|
assert provider is not None
|
|
assert isinstance(provider, OpenAIProvider)
|
|
assert isinstance(provider, GitHubModelsProvider)
|
|
assert provider.base_url == "https://models.github.ai/inference"
|
|
assert provider.freeze_url is False
|
|
assert provider.api_key_prefix == "ghp_"
|
|
assert provider.api_key_prefixes == ["ghp_", "github_pat_"]
|
|
|
|
info = await provider.get_info()
|
|
assert info.base_url == "https://models.github.ai/inference"
|
|
assert info.freeze_url is False
|
|
assert info.api_key_prefix == "ghp_"
|
|
assert info.api_key_prefixes == ["ghp_", "github_pat_"]
|
|
|
|
|
|
async def test_update_config_persists_api_key_prefixes(
|
|
isolated_secret_dir,
|
|
) -> None:
|
|
manager = ProviderManager()
|
|
provider = manager.get_provider("github-models")
|
|
assert provider is not None
|
|
|
|
manager.update_provider(
|
|
"github-models",
|
|
{"api_key_prefixes": ["ghp_", "github_pat_"]},
|
|
)
|
|
|
|
provider = manager.get_provider("github-models")
|
|
assert provider.api_key_prefixes == ["ghp_", "github_pat_"]
|
|
info = await provider.get_info()
|
|
assert info.api_key_prefixes == ["ghp_", "github_pat_"]
|