初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,41 @@
from pathlib import Path
import pytest
from app.ai_provider.config.loader import load_models_config, load_providers_config
class TestLoader:
def test_load_models_config(self, tmp_path: Path):
yaml_content = """
version: 1
models:
- name: test-model
provider: deepseek
model: deepseek-v4-flash
format: openai
"""
config_file = tmp_path / "models.yaml"
config_file.write_text(yaml_content)
models = load_models_config(config_file)
assert len(models) == 1
assert models[0].name == "test-model"
def test_load_providers_config(self, tmp_path: Path):
yaml_content = """
version: 1
providers:
deepseek:
format: openai
base_url_env: DEEPSEEK_BASE_URL
api_key_env: DEEPSEEK_API_KEY
"""
config_file = tmp_path / "providers.yaml"
config_file.write_text(yaml_content)
providers = load_providers_config(config_file)
assert "deepseek" in providers
assert providers["deepseek"].format == "openai"
def test_missing_file(self):
with pytest.raises(FileNotFoundError):
load_models_config(Path("/nonexistent/models.yaml"))
@@ -0,0 +1,69 @@
import pytest
from app.ai_provider.config.models import (
ModelConfig,
ProviderConfig,
derive_capabilities,
)
class TestModelConfig:
def test_minimal_config(self):
cfg = ModelConfig(
name="deepseek-v4-flash",
provider="deepseek",
model="deepseek-v4-flash",
format="openai",
)
assert cfg.name == "deepseek-v4-flash"
assert cfg.format == "openai"
assert not cfg.supports_vision # defaulted
def test_capability_override(self):
cfg = ModelConfig(
name="claude-sonnet",
provider="anthropic",
model="claude-sonnet-4-6",
format="anthropic",
supports_vision=True,
supports_tools=True,
)
assert cfg.supports_vision
assert cfg.supports_tools
def test_capability_auto_derive(self):
caps = derive_capabilities(provider="anthropic", model="claude-fable-5")
assert "chat" in caps
assert "tool_use" in caps
assert "vision" in caps
assert "reasoning" in caps
class TestProviderConfig:
def test_minimal(self):
cfg = ProviderConfig(
name="deepseek",
format="openai",
base_url_env="DEEPSEEK_BASE_URL",
api_key_env="DEEPSEEK_API_KEY",
)
assert cfg.name == "deepseek"
def test_with_parameter_mapping(self):
cfg = ProviderConfig(
name="deepseek",
format="openai",
base_url_env="DEEPSEEK_BASE_URL",
api_key_env="DEEPSEEK_API_KEY",
parameter_mappings={
"max_tokens": {"provider_param": "max_tokens"},
"temperature": {"provider_param": "temperature", "min": 0, "max": 2},
},
)
mapping = cfg.parameter_mappings["temperature"]
assert mapping.min == 0
assert mapping.max == 2
def test_format_validation(self):
with pytest.raises(ValueError, match="format"):
ProviderConfig(name="bad", format="unknown", base_url_env="X", api_key_env="Y")
Whitespace-only changes.
@@ -0,0 +1,67 @@
import pytest
from app.ai_provider.core.billing import (
BudgetController,
CostCalculator,
PricingInfo,
UsageRecord,
)
class TestCostCalculator:
def test_openai_pricing(self):
pricing = PricingInfo(currency="USD", input_per_1m=5.0, output_per_1m=15.0)
cost = CostCalculator.calculate(pricing, 1000, 500)
assert cost.input_cost == 0.005 # 1000 / 1M * 5.0
assert cost.output_cost == 0.0075 # 500 / 1M * 15.0
assert cost.total == 0.0125
assert cost.currency == "USD"
def test_no_output_pricing(self):
pricing = PricingInfo(currency="CNY", input_per_1m=1.0, output_per_1m=None)
cost = CostCalculator.calculate(pricing, 1000, 0)
assert cost.input_cost == 0.001
assert cost.output_cost == 0.0
def test_no_pricing(self):
cost = CostCalculator.calculate(None, 1000, 500)
assert cost.total == 0.0 # gracefully handles missing pricing
class TestUsageRecord:
def test_create_record(self):
record = UsageRecord(
model_name="deepseek-v4-flash",
provider="deepseek",
prompt_tokens=100,
completion_tokens=50,
total_tokens=150,
currency="CNY",
cost=0.0002,
)
assert record.total_tokens == 150
class TestBudgetController:
@pytest.fixture
def controller(self):
return BudgetController(
daily_budget_cny=10.0,
monthly_budget_cny=200.0,
)
def test_within_budget(self, controller):
controller.add_cost_cny(1.0)
assert controller.allow_request()
def test_exceed_daily_budget(self, controller):
controller.add_cost_cny(11.0)
assert not controller.allow_request()
def test_reset_daily(self):
controller = BudgetController(daily_budget_cny=10.0, monthly_budget_cny=200.0)
controller.add_cost_cny(11.0)
assert not controller.allow_request()
# Simulate next day
controller.reset_daily()
assert controller.allow_request()
@@ -0,0 +1,225 @@
from __future__ import annotations
import pytest
from app.ai_provider.config.models import ModelConfig, ProviderConfig
from app.ai_provider.core.registry import (
ModelRegistry,
ProviderRegistry,
RegistryFactory,
RegistryHolder,
)
from app.ai_provider.types.errors import AIProviderError
class TestModelRegistry:
@pytest.fixture
def sample_models(self):
return [
ModelConfig(
name="fast-model",
provider="deepseek",
model="deepseek-v4-flash",
format="openai",
tags=["default"],
weight=100,
),
ModelConfig(
name="pro-model",
provider="deepseek",
model="deepseek-v4-pro",
format="openai",
tags=["fallback"],
weight=50,
),
ModelConfig(
name="vision-model",
provider="anthropic",
model="claude-sonnet-4-6",
format="anthropic",
supports_vision=True,
),
]
def test_get_model(self, sample_models):
reg = ModelRegistry(sample_models)
model = reg.get("fast-model")
assert model.name == "fast-model"
def test_get_nonexistent(self, sample_models):
reg = ModelRegistry(sample_models)
with pytest.raises(AIProviderError):
reg.get("nonexistent")
def test_default_model(self, sample_models):
reg = ModelRegistry(sample_models)
assert reg.get_default_name() == "fast-model"
def test_fallback_model(self, sample_models):
reg = ModelRegistry(sample_models)
assert reg.get_fallback_name() == "pro-model"
def test_resolve_by_alias(self, sample_models):
reg = ModelRegistry(sample_models, aliases={"deepseek:deepseek-v4-flash": "fast-model"})
model = reg.resolve("deepseek:deepseek-v4-flash")
assert model.name == "fast-model"
def test_resolve_by_logical_name(self, sample_models):
reg = ModelRegistry(sample_models)
model = reg.resolve("fast-model")
assert model.name == "fast-model"
def test_list_by_capability(self, sample_models):
reg = ModelRegistry(sample_models)
models = reg.list_by_capability("vision")
assert len(models) == 1
assert models[0].name == "vision-model"
def test_resolve_by_capability(self, sample_models):
reg = ModelRegistry(sample_models)
name = reg.resolve_by_capability("vision")
assert name == "vision-model"
def test_weighted_random_resolve(self, sample_models):
reg = ModelRegistry(sample_models)
name = reg.resolve()
assert name.name == "fast-model"
def test_contains_returns_true_for_registered(self, sample_models):
reg = ModelRegistry(sample_models)
assert reg.contains("fast-model") is True
def test_contains_returns_false_for_missing(self, sample_models):
reg = ModelRegistry(sample_models)
assert reg.contains("phantom-model") is False
def test_list_all_returns_all_models(self, sample_models):
reg = ModelRegistry(sample_models)
assert len(reg.list_all()) == 3
class TestProviderRegistry:
@pytest.fixture
def sample_providers(self):
return {
"deepseek": ProviderConfig(
name="deepseek",
format="openai",
base_url_env="DEEPSEEK_BASE_URL",
api_key_env="DEEPSEEK_API_KEY",
),
"anthropic": ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
),
}
def test_get_provider(self, sample_providers):
reg = ProviderRegistry(sample_providers)
prov = reg.get("deepseek")
assert prov.format == "openai"
def test_get_nonexistent(self, sample_providers):
reg = ProviderRegistry(sample_providers)
with pytest.raises(AIProviderError):
reg.get("nonexistent")
def test_list_all(self, sample_providers):
reg = ProviderRegistry(sample_providers)
provs = reg.list_all()
assert set(provs.keys()) == {"deepseek", "anthropic"}
class TestRegistryFactory:
def test_from_configs(self, tmp_path):
models_yaml = tmp_path / "models.yaml"
models_yaml.write_text(
"""
version: 1
models:
- name: test-model
provider: deepseek
model: deepseek-v4-flash
format: openai
"""
)
providers_yaml = tmp_path / "providers.yaml"
providers_yaml.write_text(
"""
version: 1
providers:
deepseek:
format: openai
base_url_env: DEEPSEEK_BASE_URL
api_key_env: DEEPSEEK_API_KEY
"""
)
from app.ai_provider.config.loader import load_models_config, load_providers_config
models = load_models_config(models_yaml)
providers = load_providers_config(providers_yaml)
registry = RegistryFactory.create(models, providers)
assert registry.model_registry.get("test-model") is not None
assert registry.provider_registry.get("deepseek") is not None
def test_create_with_aliases(self, tmp_path):
models_yaml = tmp_path / "models.yaml"
models_yaml.write_text(
"""
version: 1
models:
- name: fast-model
provider: deepseek
model: deepseek-v4-flash
format: openai
tags: ["default"]
"""
)
providers_yaml = tmp_path / "providers.yaml"
providers_yaml.write_text(
"""
version: 1
providers:
deepseek:
format: openai
base_url_env: DEEPSEEK_BASE_URL
api_key_env: DEEPSEEK_API_KEY
"""
)
from app.ai_provider.config.loader import load_models_config, load_providers_config
models = load_models_config(models_yaml)
providers = load_providers_config(providers_yaml)
registry = RegistryFactory.create(
models, providers, aliases={"deepseek:deepseek-v4-flash": "fast-model"}
)
assert registry.model_registry.resolve("deepseek:deepseek-v4-flash").name == "fast-model"
class TestRegistryHolder:
def test_holds_both_registries(self):
model_reg = ModelRegistry(
[
ModelConfig(
name="m1",
provider="deepseek",
model="deepseek-v4-flash",
format="openai",
)
]
)
prov_reg = ProviderRegistry(
{
"deepseek": ProviderConfig(
name="deepseek",
format="openai",
base_url_env="DEEPSEEK_BASE_URL",
api_key_env="DEEPSEEK_API_KEY",
)
}
)
holder = RegistryHolder(model_reg, prov_reg)
assert holder.model_registry.get("m1") is not None
assert holder.provider_registry.get("deepseek") is not None
@@ -0,0 +1,109 @@
# backend/tests/ai_provider/core/test_resilience.py
import asyncio
import pytest
from app.ai_provider.core.resilience import (
CircuitBreaker,
CircuitBreakerState,
DowngradeAction,
DowngradeChain,
ResilienceFactory,
RetryStrategy,
)
class TestCircuitBreaker:
@pytest.fixture
def breaker(self):
return CircuitBreaker(
name="test-breaker",
failure_threshold=3,
cooldown_seconds=1.0,
)
def test_initial_state(self, breaker):
assert breaker.state == CircuitBreakerState.CLOSED
def test_trip_after_threshold(self, breaker):
breaker.record_failure()
assert breaker.state == CircuitBreakerState.CLOSED
breaker.record_failure()
assert breaker.state == CircuitBreakerState.CLOSED
breaker.record_failure()
assert breaker.state == CircuitBreakerState.OPEN
def test_reset_on_success(self, breaker):
breaker.record_failure()
breaker.record_failure()
breaker.record_success()
assert breaker.state == CircuitBreakerState.CLOSED
def test_half_open_after_cooldown(self):
breaker = CircuitBreaker(name="test", failure_threshold=1, cooldown_seconds=0.01)
breaker.record_failure()
assert breaker.state == CircuitBreakerState.OPEN
asyncio.run(asyncio.sleep(0.02))
assert breaker.state == CircuitBreakerState.HALF_OPEN
def test_trip_from_half_open(self):
breaker = CircuitBreaker(name="test", failure_threshold=1, cooldown_seconds=0.01)
breaker.record_failure()
asyncio.run(asyncio.sleep(0.02))
assert breaker.state == CircuitBreakerState.HALF_OPEN
breaker.record_failure()
assert breaker.state == CircuitBreakerState.OPEN
class TestRetryStrategy:
@pytest.fixture
def strategy(self):
return RetryStrategy(max_retries=3, base_delay=0.01, max_delay=0.1)
def test_should_retry(self, strategy):
assert strategy.should_retry(0)
assert strategy.should_retry(1)
assert strategy.should_retry(2)
assert not strategy.should_retry(3)
def test_delay_exponential(self, strategy):
d0 = strategy.get_delay(0)
d1 = strategy.get_delay(1)
d2 = strategy.get_delay(2)
assert d1 > d0
assert d2 > d1
class TestDowngradeChain:
def test_fallback_chain(self):
chain = DowngradeChain(
[
DowngradeAction(type="fallback_endpoint", target="backup-1"),
DowngradeAction(type="fallback_endpoint", target="backup-2"),
DowngradeAction(type="fallback_model", target="cheap-model"),
]
)
assert chain.next_action() == ("fallback_endpoint", "backup-1")
chain.advance()
assert chain.next_action() == ("fallback_endpoint", "backup-2")
chain.advance()
assert chain.next_action() == ("fallback_model", "cheap-model")
chain.advance()
assert chain.is_exhausted()
class TestResilienceFactory:
def test_create_breaker(self):
breaker = ResilienceFactory.create_circuit_breaker(
name="test",
failure_threshold=5,
cooldown_seconds=30,
)
assert breaker.failure_threshold == 5
def test_create_retry(self):
strategy = ResilienceFactory.create_retry_strategy(
max_retries=3,
base_delay=1.0,
)
assert strategy.max_retries == 3
@@ -0,0 +1,543 @@
from __future__ import annotations
import pytest
from app.ai_provider.config.models import ModelConfig, ParameterMapping, ProviderConfig
from app.ai_provider.llm.anthropic_format import AnthropicFormatAdapter
from app.ai_provider.types.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
class TestAnthropicMessageConversion:
"""Conversion of internal message types to Anthropic content-block format."""
@pytest.fixture
def adapter(self):
provider = ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
parameter_mappings={
"temperature": ParameterMapping(provider_param="temperature", min=0, max=1),
"max_tokens": ParameterMapping(provider_param="max_tokens"),
"top_p": ParameterMapping(provider_param="top_p", min=0, max=1),
},
)
return AnthropicFormatAdapter(provider_config=provider)
def test_convert_human_message(self, adapter):
"""HumanMessage becomes user role with a text content block."""
msg = HumanMessage(content="Hello!")
result = adapter._convert_message(msg)
assert result["role"] == "user"
assert result["content"] == [{"type": "text", "text": "Hello!"}]
def test_convert_ai_message(self, adapter):
"""AIMessage becomes assistant role with a text content block."""
msg = AIMessage(content="Sure, let me help.")
result = adapter._convert_message(msg)
assert result["role"] == "assistant"
assert result["content"] == [{"type": "text", "text": "Sure, let me help."}]
def test_convert_ai_message_replays_raw_content_blocks(self, adapter):
blocks = [
{"type": "thinking", "thinking": "summary", "signature": "sig_1"},
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {}},
]
msg = AIMessage(content="", raw_content_blocks=blocks)
assert adapter._convert_message(msg) == {"role": "assistant", "content": blocks}
def test_convert_ai_message_with_tool_calls(self, adapter):
"""AIMessage with tool_calls produces tool_use content blocks."""
msg = AIMessage(
content="",
tool_calls=[
{"name": "read_file", "args": {"path": "x.txt"}, "id": "call_1"},
],
)
result = adapter._convert_message(msg)
assert result["role"] == "assistant"
assert result["content"] == [
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "x.txt"}},
]
def test_convert_ai_message_with_text_and_tool_calls(self, adapter):
"""AIMessage with both text and tool_calls produces multiple content blocks."""
msg = AIMessage(
content="I will check that file.",
tool_calls=[
{"name": "read_file", "args": {"path": "x.txt"}, "id": "call_1"},
{"name": "list_dir", "args": {"path": "/tmp"}, "id": "call_2"},
],
)
result = adapter._convert_message(msg)
assert result["role"] == "assistant"
assert len(result["content"]) == 3
assert result["content"][0] == {"type": "text", "text": "I will check that file."}
assert result["content"][1] == {
"type": "tool_use",
"id": "call_1",
"name": "read_file",
"input": {"path": "x.txt"},
}
assert result["content"][2] == {
"type": "tool_use",
"id": "call_2",
"name": "list_dir",
"input": {"path": "/tmp"},
}
def test_convert_tool_message(self, adapter):
"""ToolMessage becomes user role with a tool_result content block."""
msg = ToolMessage(content="File contents", tool_call_id="call_1")
result = adapter._convert_message(msg)
assert result["role"] == "user"
assert result["content"] == [
{"type": "tool_result", "tool_use_id": "call_1", "content": "File contents"},
]
class TestAnthropicRequestBuilding:
"""Building the full request body for the Anthropic API."""
@pytest.fixture
def adapter(self):
provider = ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
parameter_mappings={
"temperature": ParameterMapping(provider_param="temperature", min=0, max=1),
"max_tokens": ParameterMapping(provider_param="max_tokens"),
},
)
return AnthropicFormatAdapter(provider_config=provider)
@pytest.fixture
def model(self):
return ModelConfig(
name="claude-sonnet-4",
provider="anthropic",
model="claude-sonnet-4-20250514",
format="anthropic",
max_output_tokens=8192,
)
def test_build_request_body(self, adapter, model):
"""Request body includes model, messages, and pipeline-processed params."""
messages = [HumanMessage(content="Hi")]
body = adapter.build_request(
model=model,
messages=messages,
params={"temperature": 0.7, "max_tokens": 4096},
)
assert body["model"] == "claude-sonnet-4-20250514"
assert body["temperature"] == 0.7
assert body["max_tokens"] == 4096
assert len(body["messages"]) == 1
assert body["messages"][0]["role"] == "user"
def test_build_request_with_system_message(self, adapter, model):
"""System message is placed in top-level 'system' field, not in messages."""
messages = [
SystemMessage(content="You are a helpful assistant."),
HumanMessage(content="Hi"),
]
body = adapter.build_request(model=model, messages=messages)
assert body["system"] == "You are a helpful assistant."
# system message is removed from the messages array
assert len(body["messages"]) == 1
assert body["messages"][0]["role"] == "user"
def test_build_request_with_multiple_system_messages(self, adapter, model):
"""Multiple system messages are concatenated with newlines."""
messages = [
SystemMessage(content="Be concise."),
SystemMessage(content="Use Chinese."),
HumanMessage(content="Hi"),
]
body = adapter.build_request(model=model, messages=messages)
assert body["system"] == "Be concise.\nUse Chinese."
assert len(body["messages"]) == 1
def test_build_request_sets_default_max_tokens(self, adapter, model):
"""max_tokens defaults to model.max_output_tokens when not in params."""
messages = [HumanMessage(content="Hi")]
body = adapter.build_request(model=model, messages=messages, params={})
assert body["max_tokens"] == 8192
def test_build_request_with_tools(self, adapter, model):
"""Tools are converted to Anthropic format in the request."""
messages = [HumanMessage(content="Read file x.txt")]
openai_tools = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file's contents",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
},
]
body = adapter.build_request(
model=model,
messages=messages,
tools=openai_tools,
tool_choice="auto",
)
assert "tools" in body
assert len(body["tools"]) == 1
anthropic_tool = body["tools"][0]
assert anthropic_tool["name"] == "read_file"
assert anthropic_tool["description"] == "Read a file's contents"
assert anthropic_tool["input_schema"] == {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
}
assert body["tool_choice"] == "auto"
def test_build_request_streaming(self, adapter, model):
"""Streaming flag is passed through in the request."""
messages = [HumanMessage(content="Hi")]
body = adapter.build_request(model=model, messages=messages, stream=True)
assert body["stream"] is True
class TestAnthropicToolConversion:
"""Converting tools between OpenAI and Anthropic formats."""
def test_convert_tools_standard(self):
"""OpenAI format tools are converted to Anthropic format."""
tools = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object"},
},
},
]
result = AnthropicFormatAdapter._convert_tools(tools)
assert len(result) == 1
assert result[0] == {
"name": "read_file",
"description": "Read a file",
"input_schema": {"type": "object"},
}
def test_convert_tools_already_anthropic(self):
"""Tools already in Anthropic format pass through correctly."""
tools = [
{
"name": "read_file",
"description": "Read a file",
"input_schema": {"type": "object"},
},
]
result = AnthropicFormatAdapter._convert_tools(tools)
assert len(result) == 1
assert result[0] == tools[0]
class TestAnthropicResponseParsing:
"""Parsing Anthropic API responses (non-streaming)."""
@pytest.fixture
def adapter(self):
return AnthropicFormatAdapter(
provider_config=ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
)
)
def test_parse_nonstreaming_text(self, adapter):
"""Simple text response is parsed correctly."""
raw = {
"id": "msg_123",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "Hello, world!"}],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5},
}
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
assert result.content == "Hello, world!"
assert result.model_name == "claude-sonnet-4"
assert result.usage is not None
assert result.usage.prompt_tokens == 10
assert result.usage.completion_tokens == 5
assert result.usage.total_tokens == 15
def test_parse_nonstreaming_with_tool_calls(self, adapter):
"""Response with tool_use blocks is parsed with tool_calls."""
raw = {
"id": "msg_456",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "I will read the file."},
{
"type": "tool_use",
"id": "tu_123",
"name": "read_file",
"input": {"path": "x.txt"},
},
],
"model": "claude-sonnet-4-20250514",
"stop_reason": "tool_use",
"usage": {"input_tokens": 20, "output_tokens": 15},
}
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
assert result.content == "I will read the file."
assert len(result.tool_calls) == 1
assert result.tool_calls[0] == {
"name": "read_file",
"args": {"path": "x.txt"},
"id": "tu_123",
}
def test_parse_nonstreaming_with_reasoning(self, adapter):
"""Response with thinking blocks populates reasoning field."""
raw = {
"id": "msg_789",
"type": "message",
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "Let me think step by step..."},
{"type": "text", "text": "Here is the answer."},
{"type": "signature", "signature": "sig_12345"},
],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn",
"usage": {"input_tokens": 5, "output_tokens": 10},
}
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
assert result.content == "Here is the answer."
# signature 块是加密签名,不进入 reasoning
assert result.reasoning == "Let me think step by step..."
def test_parse_nonstreaming_no_usage(self, adapter):
"""Response without usage data still parses correctly."""
raw = {
"id": "msg_000",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "No usage info."}],
"model": "claude-sonnet-4-20250514",
"stop_reason": "end_turn",
}
result = adapter.parse_nonstreaming_response(raw, "claude-sonnet-4")
assert result.content == "No usage info."
assert result.usage is None
def test_parse_content_blocks_empty(self):
"""Empty content blocks produce empty text and no tool calls."""
raw = {"content": []}
text, tools, reasoning = AnthropicFormatAdapter._parse_content_blocks(raw)
assert text == ""
assert tools == []
assert reasoning == ""
def test_bind_tools(self):
"""bind_tools adds converted tools to the request body."""
adapter = AnthropicFormatAdapter(
provider_config=ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
)
)
tools = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object"},
},
},
]
body = {}
adapter.bind_tools(body, tools, tool_choice="auto")
assert body["tools"] == [
{
"name": "read_file",
"description": "Read a file",
"input_schema": {"type": "object"},
},
]
assert body["tool_choice"] == "auto"
class TestAnthropicStreamParsing:
"""Parsing Anthropic SSE stream events."""
def test_parse_message_start(self):
"""message_start event sets initial token usage."""
data = {
"type": "message_start",
"message": {
"id": "msg_123",
"usage": {"input_tokens": 15, "output_tokens": 0},
},
}
chunk = AnthropicFormatAdapter._parse_stream_event("message_start", data)
assert chunk.usage is not None
assert chunk.usage.prompt_tokens == 15
assert chunk.usage.completion_tokens == 0
assert chunk.delta_content == ""
def test_parse_content_block_start_text(self):
"""content_block_start for text sets delta_content."""
data = {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "text", "text": "Hello"},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_start", data)
assert chunk.delta_content == "Hello"
def test_parse_content_block_start_thinking(self):
"""content_block_start for thinking sets delta_reasoning."""
data = {
"type": "content_block_start",
"index": 0,
"content_block": {"type": "thinking", "thinking": "Let me think..."},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_start", data)
assert chunk.delta_reasoning == "Let me think..."
def test_parse_content_block_start_tool_use(self):
"""content_block_start for tool_use creates a tool call chunk stub."""
data = {
"type": "content_block_start",
"index": 1,
"content_block": {
"type": "tool_use",
"id": "tu_123",
"name": "read_file",
"input": {},
},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_start", data)
assert len(chunk.tool_call_chunks) == 1
assert chunk.tool_call_chunks[0]["index"] == 1
assert chunk.tool_call_chunks[0]["id"] == "tu_123"
assert chunk.tool_call_chunks[0]["name"] == "read_file"
assert chunk.tool_call_chunks[0]["args"] == ""
def test_parse_content_block_delta_text(self):
"""content_block_delta for text_delta appends content."""
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": " world"},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
assert chunk.delta_content == " world"
def test_parse_content_block_delta_thinking(self):
"""content_block_delta for thinking_delta appends reasoning."""
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": " more..."},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
assert chunk.delta_reasoning == " more..."
def test_parse_content_block_delta_signature(self):
"""content_block_delta for signature_delta is ignored (not reasoning)."""
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "signature_delta", "signature": "sig_abc"},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
# signature 不展示为 reasoning,但保留原始 delta 供 Agent 下一轮回放。
assert chunk.delta_reasoning == ""
assert chunk.content_block_index == 0
assert chunk.content_block_delta == data["delta"]
def test_parse_content_block_delta_input_json(self):
"""content_block_delta for input_json_delta creates a tool call arg chunk."""
data = {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "input_json_delta", "partial_json": '{"path": "'},
}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_delta", data)
assert len(chunk.tool_call_chunks) == 1
assert chunk.tool_call_chunks[0]["args"] == '{"path": "'
def test_parse_message_delta_stop(self):
"""message_delta with end_turn sets finish_reason to 'stop'."""
data = {
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": 42},
}
chunk = AnthropicFormatAdapter._parse_stream_event("message_delta", data)
assert chunk.finish_reason == "stop"
assert chunk.usage is not None
assert chunk.usage.completion_tokens == 42
def test_parse_message_delta_tool_use(self):
"""message_delta with tool_use sets finish_reason to 'tool_calls'."""
data = {
"type": "message_delta",
"delta": {"stop_reason": "tool_use"},
"usage": {"output_tokens": 10},
}
chunk = AnthropicFormatAdapter._parse_stream_event("message_delta", data)
assert chunk.finish_reason == "tool_calls"
def test_parse_message_delta_max_tokens(self):
"""message_delta with max_tokens stop reason."""
data = {
"type": "message_delta",
"delta": {"stop_reason": "max_tokens"},
"usage": {"output_tokens": 5},
}
chunk = AnthropicFormatAdapter._parse_stream_event("message_delta", data)
assert chunk.finish_reason == "length"
def test_parse_content_block_stop_noop(self):
"""content_block_stop produces an empty chunk."""
data = {"type": "content_block_stop", "index": 0}
chunk = AnthropicFormatAdapter._parse_stream_event("content_block_stop", data)
assert chunk.delta_content == ""
assert chunk.delta_reasoning == ""
assert chunk.usage is None
class TestAnthropicAuthHeaders:
"""Anthropic-specific authentication headers."""
def test_headers_contain_api_key_and_version(self):
"""Headers include x-api-key, anthropic-version, and content type."""
adapter = AnthropicFormatAdapter(
provider_config=ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
)
)
headers = adapter._headers("sk-ant-12345")
assert headers["x-api-key"] == "sk-ant-12345"
assert headers["anthropic-version"] == "2023-06-01"
assert headers["Content-Type"] == "application/json"
@@ -0,0 +1,114 @@
from __future__ import annotations
import pytest
from app.ai_provider.config.models import ModelConfig, ParameterMapping, ProviderConfig
from app.ai_provider.llm.openai_format import OpenAIFormatAdapter
from app.ai_provider.types.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
class TestOpenAIMessageConversion:
@pytest.fixture
def adapter(self):
provider = ProviderConfig(
name="deepseek",
format="openai",
base_url_env="DEEPSEEK_BASE_URL",
api_key_env="DEEPSEEK_API_KEY",
parameter_mappings={
"temperature": ParameterMapping(provider_param="temperature", min=0, max=2),
"max_tokens": ParameterMapping(provider_param="max_tokens"),
},
)
return OpenAIFormatAdapter(provider_config=provider)
def test_convert_system_message(self, adapter):
msg = SystemMessage(content="You are a helpful assistant.")
result = adapter._convert_message(msg)
assert result["role"] == "system"
assert result["content"] == "You are a helpful assistant."
def test_convert_human_message(self, adapter):
msg = HumanMessage(content="Hello!")
result = adapter._convert_message(msg)
assert result["role"] == "user"
def test_convert_ai_message(self, adapter):
msg = AIMessage(
content="Sure!",
tool_calls=[{"name": "read_file", "args": {"path": "x.txt"}, "id": "call_1"}],
)
result = adapter._convert_message(msg)
assert result["role"] == "assistant"
assert "tool_calls" in result
def test_convert_tool_message(self, adapter):
msg = ToolMessage(content="File contents", tool_call_id="call_1")
result = adapter._convert_message(msg)
assert result["role"] == "tool"
assert result["tool_call_id"] == "call_1"
def test_build_request_body(self, adapter):
model = ModelConfig(
name="test", provider="deepseek", model="deepseek-v4-flash", format="openai"
)
messages = [HumanMessage(content="Hi")]
body = adapter.build_request(
model=model,
messages=messages,
params={"temperature": 0.7, "max_tokens": 4096},
)
assert body["model"] == "deepseek-v4-flash"
assert body["temperature"] == 0.7
assert body["max_tokens"] == 4096
assert len(body["messages"]) == 1
def test_parse_chunk(self, adapter):
raw = {"choices": [{"delta": {"content": "Hello"}, "index": 0}], "usage": None}
chunk = adapter.parse_chunk(raw)
assert chunk.delta_content == "Hello"
def test_parse_chunk_with_usage(self, adapter):
raw = {
"choices": [{"delta": {}, "index": 0, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
chunk = adapter.parse_chunk(raw)
assert chunk.usage is not None
assert chunk.usage.prompt_tokens == 10
def test_parse_tool_call_chunk(self, adapter):
raw = {
"choices": [
{
"delta": {
"tool_calls": [
{
"index": 0,
"id": "call_1",
"function": {
"name": "read_file",
"arguments": '{"path": "test.txt"}',
},
}
],
},
"index": 0,
}
],
}
chunk = adapter.parse_chunk(raw)
assert len(chunk.tool_call_chunks) == 1
assert chunk.tool_call_chunks[0]["name"] == "read_file"
def test_bind_tools(self, adapter):
tools = [
{
"type": "function",
"function": {"name": "read_file", "parameters": {"type": "object"}},
}
]
body = {}
adapter.bind_tools(body, tools, tool_choice="auto")
assert body["tools"] == tools
assert body["tool_choice"] == "auto"
+100
View File
@@ -0,0 +1,100 @@
from app.ai_provider.llm.pool import (
CostAwareStrategy,
EndpointPool,
LeastConnectionsStrategy,
LowestLatencyStrategy,
PoolFactory,
PriorityFailoverStrategy,
WeightedRoundRobinStrategy,
)
class TestWeightedRoundRobin:
def test_distribution(self):
endpoints = [{"name": "a", "weight": 100}, {"name": "b", "weight": 50}]
strategy = WeightedRoundRobinStrategy(endpoints)
picks = [strategy.select([])["name"] for _ in range(15)]
assert picks.count("a") > picks.count("b")
def test_skip_excluded(self):
endpoints = [{"name": "a", "weight": 100}, {"name": "b", "weight": 100}]
strategy = WeightedRoundRobinStrategy(endpoints)
pick = strategy.select(["a"])
assert pick["name"] == "b"
class TestPriorityFailover:
def test_primary_selected(self):
endpoints = [{"name": "primary", "priority": 10}, {"name": "backup", "priority": 20}]
strategy = PriorityFailoverStrategy(endpoints)
pick = strategy.select([])
assert pick["name"] == "primary"
def test_failover_on_excluded(self):
endpoints = [{"name": "primary", "priority": 10}, {"name": "backup", "priority": 20}]
strategy = PriorityFailoverStrategy(endpoints)
pick = strategy.select(["primary"])
assert pick["name"] == "backup"
def test_exhausted(self):
endpoints = [{"name": "a", "priority": 10}]
strategy = PriorityFailoverStrategy(endpoints)
pick = strategy.select(["a"])
assert pick is None
class TestLowestLatency:
def test_pick_lowest_latency(self):
endpoints = [{"name": "fast", "latency_ms": 100}, {"name": "slow", "latency_ms": 500}]
strategy = LowestLatencyStrategy(endpoints)
pick = strategy.select([])
assert pick["name"] == "fast"
class TestCostAware:
def test_pick_cheapest(self):
endpoints = [{"name": "cheap"}, {"name": "expensive"}]
def _cost(name: str) -> float:
return 0.1 if name == "cheap" else 1.0
strategy = CostAwareStrategy(endpoints, get_cost=_cost)
pick = strategy.select([])
assert pick["name"] == "cheap"
class TestLeastConnections:
def test_pick_least_connections(self):
endpoints = [{"name": "busy", "connections": 5}, {"name": "idle", "connections": 0}]
strategy = LeastConnectionsStrategy(endpoints)
pick = strategy.select([])
assert pick["name"] == "idle"
class TestEndpointPool:
def test_select_with_weighted_round_robin(self):
pool = EndpointPool(
logical_model_name="test",
endpoints_configs=[
{"name": "a", "weight": 100, "status": "active"},
{"name": "b", "weight": 50, "status": "active"},
],
strategy="weighted_round_robin",
)
selected = pool.select([])
assert selected["name"] in ("a", "b")
def test_select_all_excluded_returns_none(self):
pool = EndpointPool(
logical_model_name="test",
endpoints_configs=[{"name": "a", "weight": 100, "status": "active"}],
)
assert pool.select(["a"]) is None
def test_strategy_factory(self):
pool = PoolFactory.create_pool(
logical_model_name="test",
endpoints=[{"name": "a", "weight": 100, "status": "active"}],
strategy_name="priority_failover",
)
assert isinstance(pool.strategy, PriorityFailoverStrategy)
@@ -0,0 +1,168 @@
from __future__ import annotations
from app.ai_provider.llm.stream import StreamFactory, StreamHandler, ToolCallAccumulator
from app.ai_provider.types.chat import ChatChunk, TokenUsage
class TestToolCallAccumulator:
def test_accumulate_simple(self):
acc = ToolCallAccumulator()
acc.add_chunk(ChatChunk(delta_content="Hello"))
assert acc.content == "Hello"
def test_accumulate_reasoning(self):
acc = ToolCallAccumulator()
acc.add_chunk(ChatChunk(delta_reasoning="I think"))
acc.add_chunk(ChatChunk(delta_reasoning=" therefore"))
assert acc.reasoning == "I think therefore"
def test_accumulate_tool_call(self):
acc = ToolCallAccumulator()
acc.add_chunk(
ChatChunk(
tool_call_chunks=[
{
"index": 0,
"id": "call_1",
"name": "read_file",
"args": '{"path": "test.txt"}',
},
]
)
)
result = acc.drain()
assert len(result) == 1
assert result[0]["name"] == "read_file"
def test_accumulate_tool_call_args(self):
acc = ToolCallAccumulator()
acc.add_chunk(ChatChunk(tool_call_chunks=[{"index": 0, "args": '{"path": "'}]))
acc.add_chunk(ChatChunk(tool_call_chunks=[{"index": 0, "args": 'test.txt"}'}]))
result = acc.drain()
assert result[0]["args"] == '{"path": "test.txt"}'
def test_accumulate_usage(self):
acc = ToolCallAccumulator()
acc.add_chunk(
ChatChunk(usage=TokenUsage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
)
assert acc.usage is not None
assert acc.usage.prompt_tokens == 10
def test_empty_drain(self):
acc = ToolCallAccumulator()
result = acc.drain()
assert result == []
def test_accumulate_multiple_tool_calls(self):
acc = ToolCallAccumulator()
acc.add_chunk(
ChatChunk(
tool_call_chunks=[
{"index": 0, "id": "call_1", "name": "read_file", "args": '{"path": "'},
{"index": 1, "id": "call_2", "name": "list_dir", "args": '{"path": "'},
]
)
)
acc.add_chunk(
ChatChunk(
tool_call_chunks=[
{"index": 0, "args": 'x.txt"}'},
{"index": 1, "args": '/tmp"}'},
]
)
)
result = acc.drain()
assert len(result) == 2
assert result[0]["name"] == "read_file"
assert result[0]["args"] == '{"path": "x.txt"}'
assert result[1]["name"] == "list_dir"
assert result[1]["args"] == '{"path": "/tmp"}'
class TestStreamHandler:
def test_empty_result(self):
handler = StreamHandler()
result = handler.to_result(model_name="test-model")
assert result.content == ""
assert result.reasoning == ""
assert result.tool_calls == []
assert result.usage is None
assert result.model_name == "test-model"
def test_stream_to_result(self):
handler = StreamHandler()
handler.add_chunk(ChatChunk(delta_content="Hello world"))
handler.add_chunk(ChatChunk(delta_reasoning="Thinking..."))
handler.add_chunk(
ChatChunk(usage=TokenUsage(prompt_tokens=5, completion_tokens=3, total_tokens=8))
)
result = handler.to_result(model_name="test-model")
assert result.content == "Hello world"
assert result.reasoning == "Thinking..."
assert result.usage is not None
assert result.usage.total_tokens == 8
assert result.model_name == "test-model"
def test_stream_with_tool_calls(self):
handler = StreamHandler()
handler.add_chunk(
ChatChunk(
tool_call_chunks=[
{"index": 0, "id": "call_1", "name": "read_file", "args": '{"path": "'},
]
)
)
handler.add_chunk(
ChatChunk(
tool_call_chunks=[
{"index": 0, "args": 'test.txt"}'},
]
)
)
result = handler.to_result(model_name="test-model")
assert len(result.tool_calls) == 1
assert result.tool_calls[0]["name"] == "read_file"
assert result.tool_calls[0]["args"] == {"path": "test.txt"}
def test_chunks_property(self):
handler = StreamHandler()
handler.add_chunk(ChatChunk(delta_content="Hello"))
handler.add_chunk(ChatChunk(delta_content=" world"))
assert len(handler.chunks) == 2
def test_stream_content_and_tool_calls_together(self):
handler = StreamHandler()
handler.add_chunk(ChatChunk(delta_content="I will read the file."))
handler.add_chunk(
ChatChunk(
tool_call_chunks=[
{"index": 0, "id": "call_1", "name": "read_file", "args": "{}"},
]
)
)
result = handler.to_result()
assert result.content == "I will read the file."
assert len(result.tool_calls) == 1
def test_non_json_args_fallback(self):
handler = StreamHandler()
handler.add_chunk(
ChatChunk(
tool_call_chunks=[
{"index": 0, "id": "call_1", "name": "read_file", "args": "not valid json"},
]
)
)
result = handler.to_result()
assert result.tool_calls[0]["args"] == {"_raw": "not valid json"}
class TestStreamFactory:
def test_create_handler(self):
handler = StreamFactory.create_handler()
assert isinstance(handler, StreamHandler)
def test_create_accumulator(self):
acc = StreamFactory.create_accumulator()
assert isinstance(acc, ToolCallAccumulator)
@@ -0,0 +1,139 @@
from __future__ import annotations
from app.ai_provider.routing.multimodal import MultiModalDecision, MultiModalRouter
class TestMultiModalDecision:
def test_enum_values(self):
assert MultiModalDecision.PLAIN_TEXT == "plain_text"
assert MultiModalDecision.VISION == "vision"
assert MultiModalDecision.THINKING == "thinking"
assert MultiModalDecision.TOOL_USE == "tool_use"
assert MultiModalDecision.STRUCTURED_OUTPUT == "structured_output"
def test_enum_is_str_enum(self):
assert issubclass(MultiModalDecision, str)
class TestMultiModalRouter:
def test_plain_text_message(self):
messages = [{"role": "user", "content": "Hello, world!"}]
decisions = MultiModalRouter.decide_from_messages(messages)
assert decisions == {MultiModalDecision.PLAIN_TEXT}
def test_plain_text_multiple_messages(self):
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is Python?"},
{"role": "assistant", "content": "Python is a programming language."},
]
decisions = MultiModalRouter.decide_from_messages(messages)
assert decisions == {MultiModalDecision.PLAIN_TEXT}
def test_vision_with_openai_image_url_block(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
],
}
]
decisions = MultiModalRouter.decide_from_messages(messages)
assert MultiModalDecision.VISION in decisions
def test_vision_with_image_type_block(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this"},
{
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": "..."},
},
],
}
]
decisions = MultiModalRouter.decide_from_messages(messages)
assert MultiModalDecision.VISION in decisions
def test_plain_text_with_single_content_block(self):
"""A single text-only content block should not trigger VISION."""
messages = [
{
"role": "user",
"content": [{"type": "text", "text": "Hello!"}],
}
]
decisions = MultiModalRouter.decide_from_messages(messages)
assert MultiModalDecision.VISION not in decisions
def test_tool_use_from_assistant_tool_calls(self):
messages = [
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call_1", "function": {"name": "get_weather", "arguments": "{}"}}
],
},
]
decisions = MultiModalRouter.decide_from_messages(messages)
assert MultiModalDecision.TOOL_USE in decisions
def test_tool_use_from_tool_role(self):
messages = [
{"role": "user", "content": "What's the weather?"},
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call_1", "name": "get_weather"}],
},
{"role": "tool", "content": "25°C", "tool_call_id": "call_1"},
]
decisions = MultiModalRouter.decide_from_messages(messages)
assert MultiModalDecision.TOOL_USE in decisions
def test_thinking_from_reasoning_field(self):
messages = [
{"role": "user", "content": "Think step by step"},
{"role": "assistant", "content": "Final answer", "reasoning": "Let me think..."},
]
decisions = MultiModalRouter.decide_from_messages(messages)
assert MultiModalDecision.THINKING in decisions
def test_empty_messages(self):
decisions = MultiModalRouter.decide_from_messages([])
assert decisions == {MultiModalDecision.PLAIN_TEXT}
def test_multiple_capabilities_combined(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Analyze this image step by step"},
{"type": "image_url", "image_url": {"url": "https://example.com/img.png"}},
],
},
{
"role": "assistant",
"content": "Let me think about this image...",
"reasoning": "First, I see an image. Let me analyze it carefully.",
"tool_calls": [
{"id": "call_1", "function": {"name": "analyze_image", "arguments": "{}"}}
],
},
{"role": "tool", "content": "Analysis complete", "tool_call_id": "call_1"},
]
decisions = MultiModalRouter.decide_from_messages(messages)
assert MultiModalDecision.VISION in decisions
assert MultiModalDecision.THINKING in decisions
assert MultiModalDecision.TOOL_USE in decisions
def test_decide_with_empty_content(self):
messages = [{"role": "user", "content": ""}]
decisions = MultiModalRouter.decide_from_messages(messages)
assert decisions == {MultiModalDecision.PLAIN_TEXT}
@@ -0,0 +1,141 @@
from __future__ import annotations
from app.ai_provider.routing.permissions import CapabilityPermissionController, PermissionRule
class TestPermissionRule:
def test_exact_match(self):
rule = PermissionRule(model_pattern="fast-model", capability="vision", allowed=True)
assert rule.match("fast-model") is True
assert rule.match("pro-model") is False
def test_wildcard_match(self):
rule = PermissionRule(model_pattern="deepseek-*", capability="thinking", allowed=False)
assert rule.match("deepseek-v4-flash") is True
assert rule.match("deepseek-v4-pro") is True
assert rule.match("claude-sonnet-4-6") is False
def test_universal_match(self):
rule = PermissionRule(model_pattern="*", capability="vision", allowed=True)
assert rule.match("any-model") is True
assert rule.match("another-one") is True
def test_suffix_wildcard(self):
rule = PermissionRule(model_pattern="*-pro", capability="thinking", allowed=True)
assert rule.match("deepseek-v4-pro") is True
assert rule.match("fast-model") is False
def test_match_method_called_explicitly(self):
rule = PermissionRule(model_pattern="vision-*", capability="vision")
assert rule.match("vision-model") is True
def test_allowed_defaults_to_true(self):
rule = PermissionRule(model_pattern="*", capability="vision")
assert rule.allowed is True
def test_allowed_set_to_false(self):
rule = PermissionRule(model_pattern="*", capability="vision", allowed=False)
assert rule.allowed is False
def test_repr(self):
rule = PermissionRule(model_pattern="*", capability="vision", allowed=True)
r = repr(rule)
assert "PermissionRule" in r
assert "*" in r
assert "vision" in r
def test_eq_same_rules(self):
rule1 = PermissionRule(model_pattern="foo-*", capability="vision", allowed=True)
rule2 = PermissionRule(model_pattern="foo-*", capability="vision", allowed=True)
assert rule1 == rule2
def test_eq_different_rules(self):
rule1 = PermissionRule(model_pattern="foo-*", capability="vision", allowed=True)
rule2 = PermissionRule(model_pattern="bar-*", capability="vision", allowed=True)
assert rule1 != rule2
class TestCapabilityPermissionController:
def test_allow_all_by_default(self):
controller = CapabilityPermissionController([])
assert controller.check("fast-model", "vision") is True
def test_deny_specific_capability_on_model(self):
controller = CapabilityPermissionController(
[
PermissionRule(model_pattern="fast-*", capability="vision", allowed=False),
]
)
assert controller.check("fast-model", "vision") is False
assert controller.check("pro-model", "vision") is True
def test_deny_then_allow_more_specific(self):
"""A more specific deny should override a broader allow."""
controller = CapabilityPermissionController(
[
PermissionRule(model_pattern="*", capability="thinking", allowed=True),
PermissionRule(model_pattern="fast-*", capability="thinking", allowed=False),
]
)
assert controller.check("fast-model", "thinking") is False
assert controller.check("pro-model", "thinking") is True
def test_filter_capabilities(self):
controller = CapabilityPermissionController(
[
PermissionRule(model_pattern="*", capability="vision", allowed=False),
PermissionRule(model_pattern="vision-*", capability="vision", allowed=True),
]
)
filtered = controller.filter_capabilities("fast-model", {"chat", "vision", "thinking"})
assert filtered == {"chat", "thinking"}
filtered_vision = controller.filter_capabilities(
"vision-model", {"chat", "vision", "thinking"}
)
assert filtered_vision == {"chat", "vision", "thinking"}
def test_filter_with_no_rules(self):
controller = CapabilityPermissionController([])
filtered = controller.filter_capabilities("any-model", {"chat", "vision"})
assert filtered == {"chat", "vision"}
def test_check_nonexistent_model(self):
"""Non-existent models should still be checked against rules."""
controller = CapabilityPermissionController(
[
PermissionRule(model_pattern="*", capability="vision", allowed=False),
]
)
assert controller.check("unknown-model", "vision") is False
def test_multiple_rules_matching(self):
"""When multiple rules match, the last one wins (most specific wins by order)."""
controller = CapabilityPermissionController(
[
PermissionRule(model_pattern="*", capability="tool_use", allowed=True),
PermissionRule(model_pattern="basic-*", capability="tool_use", allowed=False),
PermissionRule(model_pattern="basic-pro", capability="tool_use", allowed=True),
]
)
assert controller.check("basic-lite", "tool_use") is False
assert controller.check("basic-pro", "tool_use") is True
assert controller.check("pro-model", "tool_use") is True
def test_check_capability_not_in_rules(self):
controller = CapabilityPermissionController(
[
PermissionRule(model_pattern="*", capability="vision", allowed=False),
]
)
# 'chat' is not in any rule, so it's allowed by default
assert controller.check("fast-model", "chat") is True
def test_is_allowed_model(self):
controller = CapabilityPermissionController(
[
PermissionRule(model_pattern="*", capability="vision", allowed=False),
]
)
assert controller.is_allowed("fast-model", "vision") is False
assert controller.is_allowed("fast-model", "chat") is True
@@ -0,0 +1,135 @@
from __future__ import annotations
import pytest
from app.ai_provider.config.models import ModelConfig
from app.ai_provider.core.registry import ModelRegistry
from app.ai_provider.routing.multimodal import MultiModalDecision
from app.ai_provider.routing.selector import ModelSelector
from app.ai_provider.types.errors import AIProviderError
class TestModelSelector:
@pytest.fixture
def registry(self):
models = [
ModelConfig(
name="fast-model",
provider="deepseek",
model="deepseek-v4-flash",
format="openai",
tags=["default"],
weight=100,
),
ModelConfig(
name="pro-model",
provider="deepseek",
model="deepseek-v4-pro",
format="openai",
tags=["fallback"],
weight=50,
),
ModelConfig(
name="vision-model",
provider="anthropic",
model="claude-sonnet-4-6",
format="anthropic",
supports_vision=True,
supports_thinking=True,
supports_tools=True,
),
ModelConfig(
name="thinking-model",
provider="deepseek",
model="deepseek-v4-thinking",
format="openai",
supports_reasoning_effort=True,
supports_thinking=True,
),
]
return ModelRegistry(models)
def test_select_default_model(self, registry):
selector = ModelSelector(registry)
model = selector.select({})
assert model.name == "fast-model"
def test_select_with_preferred_model(self, registry):
selector = ModelSelector(registry)
model = selector.select({"preferred": "pro-model"})
assert model.name == "pro-model"
def test_select_preferred_with_capability(self, registry):
selector = ModelSelector(registry)
model = selector.select({"preferred": "vision-model", "capabilities": {"vision"}})
assert model.name == "vision-model"
def test_select_preferred_missing_required_capability(self, registry):
"""Preferred model doesn't have required capability — falls back."""
selector = ModelSelector(registry)
model = selector.select({"preferred": "fast-model", "capabilities": {"vision"}})
assert model.name != "fast-model"
assert "vision" in model.resolved_capabilities()
assert model.resolved_capabilities()["vision"] is True
def test_select_for_vision(self, registry):
selector = ModelSelector(registry)
model = selector.select({"capabilities": {"vision"}})
assert model.name == "vision-model"
def test_select_for_thinking(self, registry):
selector = ModelSelector(registry)
model = selector.select({"capabilities": {"thinking"}})
assert model.resolved_capabilities()["thinking"] is True
def test_select_for_tool_use(self, registry):
selector = ModelSelector(registry)
model = selector.select({"capabilities": {"tool_use"}})
assert model.resolved_capabilities()["tool_use"] is True
def test_select_for_multiple_capabilities(self, registry):
selector = ModelSelector(registry)
model = selector.select({"capabilities": {"vision", "thinking"}})
assert model.resolved_capabilities()["vision"] is True
assert model.resolved_capabilities()["thinking"] is True
def test_select_from_multi_modal_decision(self, registry):
selector = ModelSelector(registry)
model = selector.select_from_decisions(
{MultiModalDecision.VISION, MultiModalDecision.THINKING}
)
assert model.resolved_capabilities()["vision"] is True
assert model.resolved_capabilities()["thinking"] is True
def test_select_from_plain_text_decision(self, registry):
selector = ModelSelector(registry)
model = selector.select_from_decisions({MultiModalDecision.PLAIN_TEXT})
assert model.name == "fast-model"
def test_select_with_invalid_preferred(self, registry):
selector = ModelSelector(registry)
with pytest.raises(AIProviderError):
selector.select({"preferred": "nonexistent-model"})
def test_select_with_capability_no_match(self, registry):
"""No model supports an unknown capability."""
selector = ModelSelector(registry)
with pytest.raises(AIProviderError):
selector.select({"capabilities": {"telepathy"}})
def test_select_allows_preferred_with_superset_capabilities(self, registry):
"""Preferred model has MORE capabilities than required — should still match."""
selector = ModelSelector(registry)
model = selector.select({"preferred": "vision-model", "capabilities": {"tool_use"}})
assert model.name == "vision-model"
def test_select_from_decisions_with_empty_set(self, registry):
selector = ModelSelector(registry)
model = selector.select_from_decisions(set())
assert model.name == "fast-model"
def test_select_for_avoid_list(self, registry):
"""Avoid certain models — prefer a non-avoided model."""
selector = ModelSelector(registry)
model = selector.select({"capabilities": {"chat"}, "avoid": {"fast-model"}})
assert model.name != "fast-model"
+61
View File
@@ -0,0 +1,61 @@
import pytest
from app.ai_provider.config.models import ModelConfig, ProviderConfig
from app.ai_provider.core.registry import ModelRegistry, ProviderRegistry
from app.ai_provider.manager import ProviderManager
from app.ai_provider.types.messages import HumanMessage, SystemMessage
class TestProviderManager:
@pytest.fixture
def manager(self):
models = [
ModelConfig(
name="test",
provider="deepseek",
model="deepseek-v4-flash",
format="openai",
tags=["default"],
),
]
providers = {
"deepseek": ProviderConfig(
name="deepseek",
format="openai",
base_url_env="DEEPSEEK_BASE_URL",
api_key_env="DEEPSEEK_API_KEY",
),
}
model_reg = ModelRegistry(models)
provider_reg = ProviderRegistry(providers)
return ProviderManager(
model_registry=model_reg,
provider_registry=provider_reg,
)
def test_resolve_model(self, manager):
name = manager.resolve_model("test")
assert name == "test"
def test_resolve_model_default(self, manager):
name = manager.resolve_model()
assert name == "test"
def test_get_adapter_for_model(self, manager):
adapter = manager.get_adapter("test")
from app.ai_provider.llm.openai_format import OpenAIFormatAdapter
assert isinstance(adapter, OpenAIFormatAdapter)
def test_get_provider_config(self, manager):
prov = manager.get_provider_config("deepseek")
assert prov.name == "deepseek"
def test_to_message_list(self, manager):
msgs = manager.to_message_list(
system_messages=["You are helpful."],
history=[("user", "Hello")],
)
assert len(msgs) == 2
assert isinstance(msgs[0], SystemMessage)
assert isinstance(msgs[1], HumanMessage)
@@ -0,0 +1,91 @@
"""Tests for the parameter pipeline (Task 4)."""
from __future__ import annotations
import pytest
from app.ai_provider.config.models import ModelConfig, ParameterMapping, ProviderConfig
from app.ai_provider.pipeline import PipelineContext, PipelineFactory
class TestParameterPipeline:
"""Test the config-driven 4-stage parameter pipeline."""
@pytest.fixture
def pipeline(self):
provider_cfg = ProviderConfig(
name="deepseek",
format="openai",
base_url_env="DEEPSEEK_BASE_URL",
api_key_env="DEEPSEEK_API_KEY",
parameter_mappings={
"temperature": ParameterMapping(provider_param="temperature", min=0, max=2),
"max_tokens": ParameterMapping(provider_param="max_tokens", min=1),
"top_p": ParameterMapping(provider_param="top_p", min=0, max=1),
},
)
return PipelineFactory.create(provider_cfg)
def test_normalize_temperature(self, pipeline):
ctx = PipelineContext(params={"temperature": 0.7})
result = pipeline.run(ctx)
assert result.params["temperature"] == 0.7
def test_clamp_temperature(self, pipeline):
ctx = PipelineContext(params={"temperature": 5.0})
result = pipeline.run(ctx)
assert result.params["temperature"] == 2.0 # clamped to max
def test_filter_unknown_param(self, pipeline):
ctx = PipelineContext(params={"temperature": 0.5, "unknown_param": "value"})
result = pipeline.run(ctx)
assert "unknown_param" not in result.params
assert "temperature" in result.params
def test_inject_provider_defaults(self):
provider_cfg = ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
parameter_mappings={
"max_tokens": ParameterMapping(provider_param="max_tokens", default=4096),
},
)
pipeline = PipelineFactory.create(provider_cfg)
ctx = PipelineContext(params={})
result = pipeline.run(ctx)
assert result.params["max_tokens"] == 4096
def test_transform_param(self):
provider_cfg = ProviderConfig(
name="openai",
format="openai",
base_url_env="OPENAI_BASE_URL",
api_key_env="OPENAI_API_KEY",
parameter_mappings={
"max_tokens": ParameterMapping(provider_param="max_tokens", transform="int"),
},
)
pipeline = PipelineFactory.create(provider_cfg)
ctx = PipelineContext(params={"max_tokens": 4096.0})
result = pipeline.run(ctx)
assert isinstance(result.params["max_tokens"], int)
def test_capability_filter_vision(self):
model = ModelConfig(
name="test",
provider="openai",
model="gpt-4o",
format="openai",
supports_vision=False,
)
provider_cfg = ProviderConfig(
name="openai", format="openai", base_url_env="X", api_key_env="Y"
)
pipeline = PipelineFactory.create(provider_cfg)
ctx = PipelineContext(
params={"image_url": "http://example.com/img.png"}, model_config=model
)
result = pipeline.run(ctx)
assert "image_url" in result.filtered_params
@@ -0,0 +1,305 @@
"""Tests for the parameter handler mechanism (Task 4 extension)."""
from __future__ import annotations
import pytest
from app.ai_provider.config.models import ModelConfig, ParameterMapping, ProviderConfig
from app.ai_provider.pipeline import PipelineContext, PipelineFactory
from app.ai_provider.pipeline_handlers import ParameterHandlerRegistry
class TestParameterHandlerRegistry:
"""Test the handler registry itself."""
def test_register_and_get(self):
@ParameterHandlerRegistry.register("test_handler_1")
def my_handler(_ctx, _result, _param_key=None):
pass
handler = ParameterHandlerRegistry.get("test_handler_1")
assert handler is my_handler
def test_get_unknown_handler_raises(self):
with pytest.raises(KeyError, match="Unknown parameter handler"):
ParameterHandlerRegistry.get("nonexistent_handler")
def test_list_handlers(self):
handlers = ParameterHandlerRegistry.list()
assert "anthropic_thinking" in handlers
assert "validate_max_tokens" in handlers
assert "strip_unsupported_capability_params" in handlers
class TestAnthropicThinkingHandler:
"""Test the anthropic_thinking handler via the pipeline."""
def test_thinking_true_converts_to_dict(self):
"""标量 thinking=True → {type: enabled}"""
provider = ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
parameter_mappings={
"thinking": ParameterMapping(
provider_param="thinking",
handler="anthropic_thinking",
),
"max_tokens": ParameterMapping(provider_param="max_tokens"),
},
)
pipeline = PipelineFactory.create(provider)
ctx = PipelineContext(params={"thinking": True, "max_tokens": 4096})
result = pipeline.run(ctx)
assert result.params["thinking"] == {"type": "enabled"}
assert result.params["max_tokens"] == 4096
def test_thinking_budget_enforces_max_tokens(self):
"""thinking.budget_tokens 确保 max_tokens > budget_tokens"""
provider = ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="X",
api_key_env="Y",
parameter_mappings={
"thinking": ParameterMapping(
provider_param="thinking",
handler="anthropic_thinking",
),
"max_tokens": ParameterMapping(provider_param="max_tokens"),
},
)
pipeline = PipelineFactory.create(provider)
# budget=8192, max_tokens=4096 → 不满足,应被抬到 8193
ctx = PipelineContext(params={"thinking": {"budget_tokens": 8192}, "max_tokens": 4096})
result = pipeline.run(ctx)
assert result.params["max_tokens"] >= 8193
def test_thinking_removes_temperature(self):
"""开启 thinking 时 temperature 被移除"""
provider = ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="X",
api_key_env="Y",
parameter_mappings={
"thinking": ParameterMapping(
provider_param="thinking",
handler="anthropic_thinking",
),
"temperature": ParameterMapping(
provider_param="temperature",
min=0,
max=1,
),
"max_tokens": ParameterMapping(provider_param="max_tokens"),
},
)
pipeline = PipelineFactory.create(provider)
ctx = PipelineContext(params={"thinking": True, "temperature": 0.7, "max_tokens": 4096})
result = pipeline.run(ctx)
assert "temperature" not in result.params
class TestValidateMaxTokensHandler:
"""Test the validate_max_tokens handler."""
def test_clamps_to_model_limit(self):
"""max_tokens 应被裁剪到模型上限"""
model = ModelConfig(
name="test-model",
provider="anthropic",
model="claude-sonnet-4-6",
format="anthropic",
max_output_tokens=16000,
)
provider = ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="X",
api_key_env="Y",
parameter_mappings={
"max_tokens": ParameterMapping(provider_param="max_tokens"),
},
parameter_handlers=["validate_max_tokens"],
)
pipeline = PipelineFactory.create(provider)
ctx = PipelineContext(
params={"max_tokens": 999999},
model_config=model,
provider_config=provider,
)
result = pipeline.run(ctx)
assert result.params["max_tokens"] == 16000
def test_within_limit_unchanged(self):
"""max_tokens 在合理范围内时不变"""
model = ModelConfig(
name="test-model",
provider="anthropic",
model="claude-sonnet-4-6",
format="anthropic",
max_output_tokens=16000,
)
provider = ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="X",
api_key_env="Y",
parameter_mappings={
"max_tokens": ParameterMapping(provider_param="max_tokens"),
},
parameter_handlers=["validate_max_tokens"],
)
pipeline = PipelineFactory.create(provider)
ctx = PipelineContext(
params={"max_tokens": 8000},
model_config=model,
provider_config=provider,
)
result = pipeline.run(ctx)
assert result.params["max_tokens"] == 8000
class TestStripUnsupportedCapabilityParams:
"""Test the strip_unsupported_capability_params handler."""
def test_strips_image_params_when_no_vision(self):
"""模型不支持 vision 时移除 image_url"""
model = ModelConfig(
name="test-model",
provider="openai",
model="gpt-4o",
format="openai",
supports_vision=False,
)
provider = ProviderConfig(
name="openai",
format="openai",
base_url_env="X",
api_key_env="Y",
parameter_mappings={
"max_tokens": ParameterMapping(provider_param="max_tokens"),
"image_url": ParameterMapping(provider_param="image_url"),
},
parameter_handlers=["strip_unsupported_capability_params"],
)
pipeline = PipelineFactory.create(provider)
ctx = PipelineContext(
params={"max_tokens": 100, "image_url": "http://example.com/img.png"},
model_config=model,
provider_config=provider,
)
result = pipeline.run(ctx)
assert "image_url" not in result.params
assert "max_tokens" in result.params
def test_keeps_image_params_when_vision_supported(self):
"""模型支持 vision 时保留 image_url"""
model = ModelConfig(
name="test-model",
provider="openai",
model="gpt-4o",
format="openai",
supports_vision=True,
)
provider = ProviderConfig(
name="openai",
format="openai",
base_url_env="X",
api_key_env="Y",
parameter_mappings={
"max_tokens": ParameterMapping(provider_param="max_tokens"),
"image_url": ParameterMapping(provider_param="image_url"),
},
parameter_handlers=["strip_unsupported_capability_params"],
)
pipeline = PipelineFactory.create(provider)
ctx = PipelineContext(
params={"max_tokens": 100, "image_url": "http://example.com/img.png"},
model_config=model,
provider_config=provider,
)
result = pipeline.run(ctx)
assert "image_url" in result.params
def test_strips_thinking_when_not_supported(self):
"""模型不支持 thinking 时移除 thinking 参数"""
model = ModelConfig(
name="test-model",
provider="openai",
model="gpt-4o",
format="openai",
supports_thinking=False,
)
provider = ProviderConfig(
name="openai",
format="openai",
base_url_env="X",
api_key_env="Y",
parameter_mappings={
"thinking": ParameterMapping(provider_param="thinking"),
},
parameter_handlers=["strip_unsupported_capability_params"],
)
pipeline = PipelineFactory.create(provider)
ctx = PipelineContext(
params={"thinking": True},
model_config=model,
provider_config=provider,
)
result = pipeline.run(ctx)
assert "thinking" not in result.params
class TestHandlerIntegration:
"""Test full pipeline integration with handlers."""
def test_anthropic_pipeline_end_to_end(self):
"""模拟完整的 Anthropic pipeline 流程"""
provider = ProviderConfig(
name="anthropic",
format="anthropic",
base_url_env="ANTHROPIC_BASE_URL",
api_key_env="ANTHROPIC_API_KEY",
parameter_mappings={
"max_tokens": ParameterMapping(provider_param="max_tokens", default=4096),
"thinking": ParameterMapping(
provider_param="thinking", handler="anthropic_thinking"
),
"temperature": ParameterMapping(
provider_param="temperature", min=0, max=1, default=1.0
),
},
parameter_handlers=[
"strip_unsupported_capability_params",
"validate_max_tokens",
],
)
model = ModelConfig(
name="claude-sonnet",
provider="anthropic",
model="claude-sonnet-4-6",
format="anthropic",
supports_vision=True,
supports_thinking=True,
max_output_tokens=16000,
)
pipeline = PipelineFactory.create(provider)
ctx = PipelineContext(
params={
"thinking": {"budget_tokens": 8000},
"temperature": 0.7,
},
model_config=model,
provider_config=provider,
)
result = pipeline.run(ctx)
# thinking → provider_param name
assert "thinking" in result.params
# max_tokens injected via default, then lifted by handler
assert result.params["max_tokens"] >= 8001
# temperature removed by handler
assert "temperature" not in result.params
@@ -0,0 +1,46 @@
# backend/tests/ai_provider/types/test_messages.py
from app.ai_provider.types.messages import (
AIMessage,
HumanMessage,
SystemMessage,
ToolMessage,
)
class TestMessageCreation:
def test_system_message(self):
msg = SystemMessage(content="You are a helpful assistant.")
assert msg.content == "You are a helpful assistant."
assert msg.role == "system"
def test_human_message(self):
msg = HumanMessage(content="Hello!")
assert msg.content == "Hello!"
assert msg.role == "user"
def test_ai_message_with_tool_calls(self):
msg = AIMessage(
content="I'll check that.",
tool_calls=[{"name": "read_file", "args": {"path": "test.txt"}, "id": "call_1"}],
)
assert msg.tool_calls is not None
assert len(msg.tool_calls) == 1
assert msg.tool_calls[0]["name"] == "read_file"
def test_tool_message(self):
msg = ToolMessage(content="File contents here", tool_call_id="call_1")
assert msg.content == "File contents here"
assert msg.tool_call_id == "call_1"
def test_ai_message_with_reasoning(self):
msg = AIMessage(content="Answer", reasoning="I think therefore...")
assert msg.reasoning == "I think therefore..."
def test_message_to_dict_roundtrip(self):
original = HumanMessage(content="Hello")
d = original.to_dict()
assert d["role"] == "user"
assert d["content"] == "Hello"
restored = HumanMessage.from_dict(d)
assert restored.content == original.content
assert restored.role == original.role
@@ -0,0 +1,21 @@
# backend/tests/ai_provider/types/test_errors.py
from app.ai_provider.types.errors import AIProviderError
class TestErrors:
def test_error_with_code(self):
err = AIProviderError(message="Model not found", code="MODEL_NOT_FOUND", status_code=404)
assert err.code == "MODEL_NOT_FOUND"
assert err.status_code == 404
assert str(err) == "Model not found"
def test_error_from_provider(self):
cause = ValueError("API returned 429")
err = AIProviderError.from_provider(
message="Rate limited",
code="RATE_LIMITED",
provider="deepseek",
original=cause,
)
assert err.provider == "deepseek"
assert err.original is cause