2026-07-06 21:55:48 +08:00
|
|
|
import shutil
|
|
|
|
|
from collections.abc import Mapping
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-07-24 17:11:39 +08:00
|
|
|
import tomlkit
|
2026-07-06 21:55:48 +08:00
|
|
|
|
|
|
|
|
import plyngent
|
|
|
|
|
from plyngent.config import (
|
|
|
|
|
AnthropicProvider,
|
|
|
|
|
ConfigFormatError,
|
|
|
|
|
DeepseekProvider,
|
|
|
|
|
OpenAICompatibleProvider,
|
|
|
|
|
OpenAIProvider,
|
|
|
|
|
)
|
2026-07-24 17:11:39 +08:00
|
|
|
from plyngent.config.store import ConfigStore
|
2026-07-06 21:55:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def default_config_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setattr(plyngent.config, "default_config_source", Path(__file__).parent / "plyngent-valid.toml")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_default_config(default_config_source: None) -> None:
|
|
|
|
|
_ = default_config_source
|
|
|
|
|
config = plyngent.config.load()
|
|
|
|
|
providers = config.providers
|
|
|
|
|
assert isinstance(providers, Mapping)
|
|
|
|
|
assert isinstance(providers["test1"], OpenAIProvider)
|
|
|
|
|
assert isinstance(providers["test2"], OpenAICompatibleProvider)
|
|
|
|
|
assert isinstance(providers["test3"], AnthropicProvider)
|
|
|
|
|
assert isinstance(providers["foo1"], DeepseekProvider)
|
2026-07-14 17:19:21 +08:00
|
|
|
db = config.database
|
|
|
|
|
assert db["implementation"] == "sqlite"
|
|
|
|
|
assert db["url"] == ":memory:"
|
|
|
|
|
assert db["username"] is None
|
|
|
|
|
assert db["password"] is None
|
2026-07-06 21:55:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_valid_config() -> None:
|
|
|
|
|
config = plyngent.config.load(Path(__file__).parent / "plyngent-valid.toml")
|
|
|
|
|
providers = config.providers
|
|
|
|
|
assert isinstance(providers, Mapping)
|
|
|
|
|
assert isinstance(providers["test1"], OpenAIProvider)
|
|
|
|
|
assert isinstance(providers["test2"], OpenAICompatibleProvider)
|
|
|
|
|
assert isinstance(providers["test3"], AnthropicProvider)
|
|
|
|
|
assert isinstance(providers["foo1"], DeepseekProvider)
|
2026-07-15 13:57:29 +08:00
|
|
|
# TOML omitted models → DeepSeek defaults.
|
2026-10-06 21:22:35 +08:00
|
|
|
assert set(providers["foo1"].models) == {"deepseek-flash", "deepseek-v4-pro"}
|
|
|
|
|
assert providers["foo1"].models["deepseek-flash"].text is True
|
2026-07-14 17:19:21 +08:00
|
|
|
db = config.database
|
|
|
|
|
assert db["implementation"] == "sqlite"
|
|
|
|
|
assert db["url"] == ":memory:"
|
|
|
|
|
assert db["username"] is None
|
|
|
|
|
assert db["password"] is None
|
2026-07-06 21:55:48 +08:00
|
|
|
|
|
|
|
|
|
2026-07-15 13:57:29 +08:00
|
|
|
def test_deepseek_default_models_on_construct() -> None:
|
|
|
|
|
provider = DeepseekProvider(access_key_or_token="sk-test")
|
2026-10-06 21:22:35 +08:00
|
|
|
assert set(provider.models) == {"deepseek-flash", "deepseek-v4-pro"}
|
2026-07-15 13:57:29 +08:00
|
|
|
|
|
|
|
|
|
2026-07-15 21:40:43 +08:00
|
|
|
def test_openai_default_models_on_construct() -> None:
|
|
|
|
|
provider = OpenAIProvider(access_key_or_token="sk-test")
|
|
|
|
|
assert set(provider.models) == {"gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano"}
|
2026-07-15 21:43:39 +08:00
|
|
|
assert provider.provider_tools == [{"type": "web_search"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_openai_explicit_empty_provider_tools() -> None:
|
|
|
|
|
provider = OpenAIProvider(access_key_or_token="sk-test", provider_tools=[])
|
|
|
|
|
assert provider.provider_tools == []
|
2026-07-15 21:40:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_openai_omitted_preset_and_models_from_toml(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "openai-defaults.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.oai]
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
assert "oai" in config.providers
|
|
|
|
|
provider = config.providers["oai"]
|
|
|
|
|
assert isinstance(provider, OpenAIProvider)
|
|
|
|
|
assert set(provider.models) == {"gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano"}
|
2026-07-20 18:08:27 +08:00
|
|
|
assert provider.timeout is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_provider_timeout_float_from_toml(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "timeout-float.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.local]
|
|
|
|
|
preset = "openai-compatible"
|
|
|
|
|
url = "https://example.com/v1"
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
timeout = 90
|
|
|
|
|
models = { "m" = { text = true } }
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
provider = config.providers["local"]
|
|
|
|
|
assert isinstance(provider, OpenAICompatibleProvider)
|
|
|
|
|
assert provider.timeout == 90
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_provider_timeout_table_from_toml(tmp_path: Path) -> None:
|
|
|
|
|
from plyngent.config import HttpTimeoutConfig
|
|
|
|
|
|
|
|
|
|
path = tmp_path / "timeout-table.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.oai]
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
timeout = { connect = 5, read = 120 }
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
provider = config.providers["oai"]
|
|
|
|
|
assert isinstance(provider, OpenAIProvider)
|
|
|
|
|
assert isinstance(provider.timeout, HttpTimeoutConfig)
|
|
|
|
|
assert provider.timeout.connect == 5
|
|
|
|
|
assert provider.timeout.read == 120
|
2026-07-15 21:40:43 +08:00
|
|
|
|
|
|
|
|
|
2026-07-15 13:57:29 +08:00
|
|
|
def test_deepseek_explicit_models_override_defaults() -> None:
|
|
|
|
|
from plyngent.config import ModelConfig
|
|
|
|
|
|
|
|
|
|
provider = DeepseekProvider(
|
|
|
|
|
access_key_or_token="sk-test",
|
|
|
|
|
models={"custom-only": ModelConfig(text=True)},
|
|
|
|
|
)
|
|
|
|
|
assert set(provider.models) == {"custom-only"}
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 01:59:39 +08:00
|
|
|
def test_deepseek_convention_from_toml(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "deepseek-convention.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.ds]
|
|
|
|
|
preset = "deepseek"
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
convention = "responses"
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
provider = config.providers["ds"]
|
|
|
|
|
assert isinstance(provider, DeepseekProvider)
|
|
|
|
|
assert provider.convention == "responses"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deepseek_convention_invalid_is_bad_provider(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "deepseek-bad-convention.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.ds]
|
|
|
|
|
preset = "deepseek"
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
convention = "bogus"
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
assert "ds" not in config.providers
|
|
|
|
|
assert "ds" in config.bad_providers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deepseek_model_convention_override_parses(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "deepseek-model-convention.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.ds]
|
|
|
|
|
preset = "deepseek"
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
|
|
|
|
|
[providers.ds.models]
|
2026-10-06 21:22:35 +08:00
|
|
|
"deepseek-flash" = { text = true, convention = "responses" }
|
2026-08-01 01:59:39 +08:00
|
|
|
"deepseek-v4-pro" = { text = true }
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
provider = config.providers["ds"]
|
|
|
|
|
assert isinstance(provider, DeepseekProvider)
|
2026-10-06 21:22:35 +08:00
|
|
|
assert provider.models["deepseek-flash"].convention == "responses"
|
2026-08-01 01:59:39 +08:00
|
|
|
assert provider.models["deepseek-v4-pro"].convention == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deepseek_convention_write_back(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "deepseek-convention-write.toml"
|
|
|
|
|
config = ConfigStore(path=path, document=tomlkit.document())
|
|
|
|
|
config.providers = {
|
|
|
|
|
"ds": DeepseekProvider(access_key_or_token="sk-test", convention="responses"),
|
|
|
|
|
}
|
|
|
|
|
config.write()
|
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
|
assert 'convention = "responses"' in text
|
|
|
|
|
again = plyngent.config.load(path)
|
|
|
|
|
assert again.providers["ds"].convention == "responses"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deepseek_convention_default_not_written(tmp_path: Path) -> None:
|
|
|
|
|
from plyngent.config import ModelConfig
|
|
|
|
|
|
|
|
|
|
path = tmp_path / "deepseek-convention-default.toml"
|
|
|
|
|
config = ConfigStore(path=path, document=tomlkit.document())
|
|
|
|
|
config.providers = {
|
|
|
|
|
"ds": DeepseekProvider(
|
|
|
|
|
access_key_or_token="sk-test",
|
|
|
|
|
models={"m": ModelConfig(text=True)},
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
config.write()
|
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
|
assert "convention" not in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deepseek_effective_convention_precedence() -> None:
|
|
|
|
|
from plyngent.config import ModelConfig, resolve_effective_provider
|
|
|
|
|
|
|
|
|
|
provider = DeepseekProvider(
|
|
|
|
|
access_key_or_token="sk-test",
|
|
|
|
|
models={
|
|
|
|
|
"flash": ModelConfig(convention="responses"),
|
|
|
|
|
"pro": ModelConfig(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
# Provider default "" → chat completions convention.
|
|
|
|
|
effective = resolve_effective_provider(provider)
|
|
|
|
|
assert effective.convention == "openai"
|
|
|
|
|
# Model override wins.
|
|
|
|
|
effective = resolve_effective_provider(provider, model="flash")
|
|
|
|
|
assert effective.convention == "responses"
|
|
|
|
|
effective = resolve_effective_provider(provider, model="pro")
|
|
|
|
|
assert effective.convention == "openai"
|
|
|
|
|
# Provider-level convention is the fallback.
|
|
|
|
|
provider = DeepseekProvider(access_key_or_token="sk-test", convention="responses")
|
|
|
|
|
assert resolve_effective_provider(provider).convention == "responses"
|
|
|
|
|
# Non-deepseek presets leave convention empty.
|
|
|
|
|
from plyngent.config import OpenAIProvider
|
|
|
|
|
|
|
|
|
|
assert resolve_effective_provider(OpenAIProvider(access_key_or_token="sk")).convention == ""
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 02:27:23 +08:00
|
|
|
def test_deepseek_anthropic_convention_default_url() -> None:
|
|
|
|
|
from plyngent.config import resolve_effective_provider
|
|
|
|
|
from plyngent.config.routing import DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, DEFAULT_DEEPSEEK_BASE_URL
|
|
|
|
|
|
|
|
|
|
provider = DeepseekProvider(access_key_or_token="sk-test", convention="anthropic")
|
|
|
|
|
effective = resolve_effective_provider(provider)
|
|
|
|
|
assert effective.convention == "anthropic"
|
|
|
|
|
assert effective.url == DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
|
|
|
|
|
|
|
|
|
|
# Custom provider url wins over the convention default.
|
|
|
|
|
provider = DeepseekProvider(
|
|
|
|
|
access_key_or_token="sk-test",
|
|
|
|
|
url="https://gateway.example/anthropic",
|
|
|
|
|
convention="anthropic",
|
|
|
|
|
)
|
|
|
|
|
assert resolve_effective_provider(provider).url == "https://gateway.example/anthropic"
|
|
|
|
|
|
|
|
|
|
# Other conventions keep the plain deepseek base.
|
|
|
|
|
for convention in ("", "openai", "responses"):
|
|
|
|
|
provider = DeepseekProvider(access_key_or_token="sk-test", convention=convention)
|
|
|
|
|
assert resolve_effective_provider(provider).url == DEFAULT_DEEPSEEK_BASE_URL
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deepseek_anthropic_model_convention_default_url() -> None:
|
|
|
|
|
from plyngent.config import ModelConfig, resolve_effective_provider
|
|
|
|
|
from plyngent.config.routing import DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
|
|
|
|
|
|
|
|
|
|
provider = DeepseekProvider(
|
|
|
|
|
access_key_or_token="sk-test",
|
|
|
|
|
models={"claude-proxy": ModelConfig(convention="anthropic")},
|
|
|
|
|
)
|
|
|
|
|
effective = resolve_effective_provider(provider, model="claude-proxy")
|
|
|
|
|
assert effective.convention == "anthropic"
|
|
|
|
|
assert effective.url == DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
|
|
|
|
|
# Provider default url for a model without convention stays the deepseek base.
|
|
|
|
|
other = resolve_effective_provider(provider, model=None)
|
|
|
|
|
assert other.url == "https://api.deepseek.com"
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 21:55:48 +08:00
|
|
|
def test_read_empty_config() -> None:
|
|
|
|
|
config = plyngent.config.load(Path(__file__).parent / "plyngent-empty.toml")
|
|
|
|
|
assert isinstance(config.providers, Mapping)
|
|
|
|
|
assert not config.providers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_bad_config() -> None:
|
|
|
|
|
config = plyngent.config.load(Path(__file__).parent / "plyngent-bad.toml")
|
|
|
|
|
assert isinstance(config.providers, Mapping)
|
|
|
|
|
assert not config.providers
|
|
|
|
|
assert isinstance(config.bad_providers, Mapping)
|
|
|
|
|
|
|
|
|
|
|
2026-10-06 21:06:46 +08:00
|
|
|
def test_reasoning_effort_from_toml(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "reasoning.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[agent]
|
|
|
|
|
reasoning_effort = "low"
|
|
|
|
|
thinking_budget_tokens = 4000
|
|
|
|
|
|
|
|
|
|
[providers.ds]
|
|
|
|
|
preset = "deepseek"
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
reasoning_effort = "high"
|
|
|
|
|
|
|
|
|
|
[providers.ds.models]
|
|
|
|
|
"deepseek-v4-pro" = { text = true, reasoning_effort = "max", thinking_budget_tokens = 32000 }
|
2026-10-06 21:22:35 +08:00
|
|
|
"deepseek-flash" = { text = true }
|
2026-10-06 21:06:46 +08:00
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
agent_cfg = config.agent_config
|
|
|
|
|
assert agent_cfg.reasoning_effort == "low"
|
|
|
|
|
assert agent_cfg.thinking_budget_tokens == 4000
|
|
|
|
|
provider = config.providers["ds"]
|
|
|
|
|
assert provider.reasoning_effort == "high"
|
|
|
|
|
assert provider.thinking_budget_tokens == 0
|
|
|
|
|
assert provider.models["deepseek-v4-pro"].reasoning_effort == "max"
|
|
|
|
|
assert provider.models["deepseek-v4-pro"].thinking_budget_tokens == 32000
|
2026-10-06 21:22:35 +08:00
|
|
|
assert provider.models["deepseek-flash"].reasoning_effort == ""
|
|
|
|
|
assert provider.models["deepseek-flash"].thinking_budget_tokens == 0
|
2026-10-06 21:06:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reasoning_effort_defaults_are_unset(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "reasoning-default.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.ds]
|
|
|
|
|
preset = "deepseek"
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
assert config.agent_config.reasoning_effort == ""
|
|
|
|
|
assert config.agent_config.thinking_budget_tokens == 0
|
|
|
|
|
assert config.providers["ds"].reasoning_effort == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_reasoning_effort_invalid_is_bad_provider(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "reasoning-bad.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.ds]
|
|
|
|
|
preset = "deepseek"
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
reasoning_effort = "highest"
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
assert "ds" not in config.providers
|
|
|
|
|
assert "ds" in config.bad_providers
|
|
|
|
|
|
|
|
|
|
|
2026-07-15 17:32:10 +08:00
|
|
|
def test_provider_with_empty_models_is_recoverable(tmp_path: Path) -> None:
|
2026-07-15 14:17:34 +08:00
|
|
|
path = tmp_path / "empty-models.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.hollow]
|
|
|
|
|
preset = "openai-compatible"
|
|
|
|
|
url = "https://example.com/v1"
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
models = {}
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
assert "hollow" not in config.providers
|
2026-07-15 17:32:10 +08:00
|
|
|
assert "hollow" not in config.bad_providers
|
|
|
|
|
assert "hollow" in config.recoverable_providers
|
|
|
|
|
promoted = config.promote_provider("hollow", ["m1", "m2"])
|
|
|
|
|
assert "hollow" in config.providers
|
|
|
|
|
assert "hollow" not in config.recoverable_providers
|
|
|
|
|
assert set(promoted.models) == {"m1", "m2"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_promote_provider_requires_ids(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "empty-models.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.hollow]
|
|
|
|
|
preset = "openai-compatible"
|
|
|
|
|
url = "https://example.com/v1"
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
models = {}
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
with pytest.raises(ValueError, match="no model ids"):
|
|
|
|
|
_ = config.promote_provider("hollow", [])
|
2026-07-15 14:17:34 +08:00
|
|
|
|
|
|
|
|
|
2026-07-28 21:45:59 +08:00
|
|
|
def test_model_level_preset_url_overrides_parse(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "mixed.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.mixed]
|
|
|
|
|
preset = "openai-compatible"
|
|
|
|
|
url = "https://gateway.example/v1"
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
|
|
|
|
|
[providers.mixed.models]
|
|
|
|
|
"claude" = { text = true, preset = "anthropic", url = "https://api.anthropic.com/v1" }
|
|
|
|
|
"qwen" = { text = true }
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
provider = config.providers["mixed"]
|
|
|
|
|
assert isinstance(provider, OpenAICompatibleProvider)
|
|
|
|
|
assert provider.models["claude"].preset == "anthropic"
|
|
|
|
|
assert provider.models["claude"].url == "https://api.anthropic.com/v1"
|
|
|
|
|
assert provider.models["qwen"].preset == ""
|
|
|
|
|
assert provider.models["qwen"].url == ""
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 21:55:48 +08:00
|
|
|
def test_read_invalid_config() -> None:
|
|
|
|
|
with pytest.raises(ConfigFormatError):
|
|
|
|
|
_ = plyngent.config.load(Path(__file__).parent / "plyngent-invalid.toml")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_write_new_config() -> None:
|
|
|
|
|
file = Path(__file__).parent / "plyngent-edit-1.toml"
|
|
|
|
|
file.unlink(missing_ok=True)
|
|
|
|
|
config = plyngent.config.load(file)
|
|
|
|
|
assert isinstance(config.providers, Mapping)
|
2026-07-15 14:17:34 +08:00
|
|
|
from plyngent.config import ModelConfig
|
|
|
|
|
|
2026-07-06 21:55:48 +08:00
|
|
|
config.providers = {
|
2026-07-15 14:17:34 +08:00
|
|
|
"foo1": OpenAIProvider(
|
|
|
|
|
access_key_or_token="sk-00301212",
|
|
|
|
|
models={"gpt-test": ModelConfig()},
|
|
|
|
|
),
|
2026-07-06 21:55:48 +08:00
|
|
|
"foo2": DeepseekProvider(access_key_or_token="sk-00301212"),
|
|
|
|
|
}
|
|
|
|
|
assert isinstance(config.providers, Mapping)
|
|
|
|
|
config.write()
|
|
|
|
|
assert isinstance(config.providers["foo1"], OpenAIProvider)
|
|
|
|
|
assert config.providers["foo1"].access_key_or_token == "sk-00301212"
|
|
|
|
|
assert isinstance(config.providers["foo2"], DeepseekProvider)
|
|
|
|
|
assert config.providers["foo2"].access_key_or_token == "sk-00301212"
|
|
|
|
|
config.reload()
|
|
|
|
|
assert isinstance(config.providers["foo1"], OpenAIProvider)
|
|
|
|
|
assert config.providers["foo1"].access_key_or_token == "sk-00301212"
|
|
|
|
|
assert isinstance(config.providers["foo2"], DeepseekProvider)
|
|
|
|
|
assert config.providers["foo2"].access_key_or_token == "sk-00301212"
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 01:32:06 +08:00
|
|
|
def test_ensure_model_and_merge_models(tmp_path: Path) -> None:
|
|
|
|
|
path = tmp_path / "models-persist.toml"
|
|
|
|
|
_ = path.write_text(
|
|
|
|
|
"""
|
|
|
|
|
[providers.local]
|
|
|
|
|
preset = "openai-compatible"
|
|
|
|
|
access_key_or_token = "sk-test"
|
|
|
|
|
url = "https://example/v1"
|
|
|
|
|
|
|
|
|
|
[providers.local.models.base]
|
|
|
|
|
""",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
config = plyngent.config.load(path)
|
|
|
|
|
assert "base" in config.providers["local"].models
|
|
|
|
|
assert "extra" not in config.providers["local"].models
|
|
|
|
|
|
|
|
|
|
provider = config.ensure_model("local", "extra")
|
|
|
|
|
assert "extra" in provider.models
|
|
|
|
|
assert "base" in provider.models
|
|
|
|
|
# idempotent
|
|
|
|
|
_ = config.ensure_model("local", "extra")
|
|
|
|
|
config.write()
|
|
|
|
|
config.reload()
|
|
|
|
|
assert set(config.providers["local"].models) == {"base", "extra"}
|
|
|
|
|
assert config.providers["local"].access_key_or_token == "sk-test"
|
|
|
|
|
|
|
|
|
|
_ = config.merge_models("local", ["extra", "remote-a", "remote-b"])
|
|
|
|
|
config.write()
|
|
|
|
|
config.reload()
|
|
|
|
|
assert set(config.providers["local"].models) == {"base", "extra", "remote-a", "remote-b"}
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 17:11:39 +08:00
|
|
|
def test_write_models_as_inline_tables(tmp_path: Path) -> None:
|
|
|
|
|
"""Persisted models use inline tables, not dotted [providers.x.models.id] sections."""
|
|
|
|
|
from plyngent.config import ModelConfig, OpenAICompatibleProvider
|
|
|
|
|
|
|
|
|
|
path = tmp_path / "inline-models.toml"
|
|
|
|
|
config = ConfigStore(path=path, document=tomlkit.document())
|
|
|
|
|
config.providers = {
|
|
|
|
|
"local": OpenAICompatibleProvider(
|
|
|
|
|
access_key_or_token="sk-test",
|
|
|
|
|
url="https://example/v1",
|
|
|
|
|
models={
|
|
|
|
|
"base": ModelConfig(),
|
|
|
|
|
"gpt-test": ModelConfig(text=True, cost_factor=2),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
config.write()
|
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
|
assert "[providers.local.models]" in text
|
|
|
|
|
assert "[providers.local.models.base]" not in text
|
|
|
|
|
assert "[providers.local.models.gpt-test]" not in text
|
|
|
|
|
assert "gpt-test" in text and "cost_factor" in text
|
|
|
|
|
# Round-trip still loads models.
|
|
|
|
|
again = plyngent.config.load(path)
|
|
|
|
|
assert set(again.providers["local"].models) == {"base", "gpt-test"}
|
|
|
|
|
assert again.providers["local"].models["gpt-test"].cost_factor == 2
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 17:21:50 +08:00
|
|
|
def test_write_lf_strings_as_multiline(tmp_path: Path) -> None:
|
|
|
|
|
"""Strings containing LF are written as TOML multi-line strings (\"\"\"…\"\"\")."""
|
|
|
|
|
from plyngent.config import AgentConfig
|
|
|
|
|
|
|
|
|
|
path = tmp_path / "ml-string.toml"
|
|
|
|
|
config = ConfigStore(path=path, document=tomlkit.document())
|
|
|
|
|
# AgentConfig has no public setter; exercise the TOML encoder via write path.
|
|
|
|
|
config._agent = AgentConfig(
|
|
|
|
|
system_prompt="Line one.\nLine two.",
|
|
|
|
|
tool_directives="Use tools.\nBe careful.",
|
|
|
|
|
)
|
|
|
|
|
config.write()
|
|
|
|
|
text = path.read_text(encoding="utf-8")
|
|
|
|
|
assert 'system_prompt = """' in text
|
|
|
|
|
assert "Line one." in text and "Line two." in text
|
|
|
|
|
assert 'tool_directives = """' in text
|
|
|
|
|
# Escaped single-line form should not appear for these values.
|
|
|
|
|
assert 'system_prompt = "Line one.\\nLine two."' not in text
|
|
|
|
|
|
|
|
|
|
again = plyngent.config.load(path)
|
|
|
|
|
assert again.agent_config.system_prompt == "Line one.\nLine two."
|
|
|
|
|
assert again.agent_config.tool_directives == "Use tools.\nBe careful."
|
|
|
|
|
|
|
|
|
|
|
2026-07-06 21:55:48 +08:00
|
|
|
def test_update_config() -> None:
|
|
|
|
|
file = Path(__file__).parent / "plyngent-edit-2.toml"
|
|
|
|
|
_ = shutil.copy(Path(__file__).parent / "plyngent-valid.toml", file)
|
|
|
|
|
config = plyngent.config.load(file)
|
|
|
|
|
assert isinstance(config.providers, Mapping)
|
|
|
|
|
config.providers = config.providers | {"foo2": DeepseekProvider(access_key_or_token="sk-00301212")}
|
|
|
|
|
assert isinstance(config.providers, Mapping)
|
|
|
|
|
config.write()
|
|
|
|
|
assert isinstance(config.providers["foo1"], DeepseekProvider)
|
|
|
|
|
assert config.providers["foo1"].access_key_or_token == "sk-1145141919810"
|
|
|
|
|
assert isinstance(config.providers["foo2"], DeepseekProvider)
|
|
|
|
|
assert config.providers["foo2"].access_key_or_token == "sk-00301212"
|
|
|
|
|
config.reload()
|
|
|
|
|
assert isinstance(config.providers["foo1"], DeepseekProvider)
|
|
|
|
|
assert config.providers["foo1"].access_key_or_token == "sk-1145141919810"
|
|
|
|
|
assert isinstance(config.providers["foo2"], DeepseekProvider)
|
|
|
|
|
assert config.providers["foo2"].access_key_or_token == "sk-00301212"
|