From a9261452e051d3f59212f4ed6294941ce1dcbecd Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Sat, 9 May 2026 19:54:39 +0200 Subject: [PATCH] feat(llm): unified client for OpenRouter (Qwen) + Anthropic (Sonnet) LLMClient instrada richieste in base a ModelTier del genome: - Tier C -> Qwen 2.5 72B via OpenRouter (chat completions) - Tier B -> Sonnet 4.6 via Anthropic (messages API) CompletionResult dataclass frozen con text, tokens, tier, model. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/multi_swarm/llm/__init__.py | 0 src/multi_swarm/llm/client.py | 80 +++++++++++++++++++++++++++++++++ tests/unit/test_llm_client.py | 53 ++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 src/multi_swarm/llm/__init__.py create mode 100644 src/multi_swarm/llm/client.py create mode 100644 tests/unit/test_llm_client.py diff --git a/src/multi_swarm/llm/__init__.py b/src/multi_swarm/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/multi_swarm/llm/client.py b/src/multi_swarm/llm/client.py new file mode 100644 index 0000000..8ec810c --- /dev/null +++ b/src/multi_swarm/llm/client.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from anthropic import Anthropic +from openai import OpenAI + +from ..genome.hypothesis import HypothesisAgentGenome, ModelTier + +# Modelli configurati per Phase 1 +MODEL_TIER_C = "qwen/qwen-2.5-72b-instruct" # via OpenRouter +MODEL_TIER_B = "claude-sonnet-4-6" # via Anthropic +OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" + + +@dataclass(frozen=True) +class CompletionResult: + text: str + input_tokens: int + output_tokens: int + tier: ModelTier + model: str + + +class LLMClient: + def __init__( + self, + openrouter_api_key: str, + anthropic_api_key: str | None = None, + ) -> None: + self._openrouter = OpenAI(api_key=openrouter_api_key, base_url=OPENROUTER_BASE_URL) + self._anthropic = Anthropic(api_key=anthropic_api_key) if anthropic_api_key else None + + def complete( + self, + genome: HypothesisAgentGenome, + system: str, + user: str, + max_tokens: int = 2000, + ) -> CompletionResult: + if genome.model_tier == ModelTier.C: + resp = self._openrouter.chat.completions.create( + model=MODEL_TIER_C, + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + temperature=genome.temperature, + top_p=genome.top_p, + max_tokens=max_tokens, + ) + usage = resp.usage + assert usage is not None + return CompletionResult( + text=resp.choices[0].message.content or "", + input_tokens=usage.prompt_tokens, + output_tokens=usage.completion_tokens, + tier=ModelTier.C, + model=MODEL_TIER_C, + ) + + if self._anthropic is None: + raise RuntimeError("ANTHROPIC_API_KEY required for tier B genomes") + + msg = self._anthropic.messages.create( + model=MODEL_TIER_B, + system=system, + messages=[{"role": "user", "content": user}], + temperature=genome.temperature, + top_p=genome.top_p, + max_tokens=max_tokens, + ) + text = "".join(block.text for block in msg.content if hasattr(block, "text")) + return CompletionResult( + text=text, + input_tokens=msg.usage.input_tokens, + output_tokens=msg.usage.output_tokens, + tier=ModelTier.B, + model=MODEL_TIER_B, + ) diff --git a/tests/unit/test_llm_client.py b/tests/unit/test_llm_client.py new file mode 100644 index 0000000..54ee930 --- /dev/null +++ b/tests/unit/test_llm_client.py @@ -0,0 +1,53 @@ +from multi_swarm.genome.hypothesis import HypothesisAgentGenome, ModelTier +from multi_swarm.llm.client import CompletionResult, LLMClient + + +def make_genome(tier: ModelTier) -> HypothesisAgentGenome: + return HypothesisAgentGenome( + system_prompt="x", + feature_access=["close"], + temperature=0.9, + top_p=0.95, + model_tier=tier, + lookback_window=200, + cognitive_style="physicist", + ) + + +def test_completion_tier_c_uses_openrouter(mocker): + fake_openai = mocker.MagicMock() + fake_response = mocker.MagicMock() + fake_response.choices = [mocker.MagicMock(message=mocker.MagicMock(content="(strategy ...)"))] + fake_response.usage = mocker.MagicMock(prompt_tokens=100, completion_tokens=200) + fake_openai.chat.completions.create.return_value = fake_response + + mocker.patch("multi_swarm.llm.client.OpenAI", return_value=fake_openai) + + client = LLMClient(openrouter_api_key="or-x", anthropic_api_key=None) + g = make_genome(ModelTier.C) + out = client.complete(g, system="sys", user="usr") + + assert isinstance(out, CompletionResult) + assert out.text == "(strategy ...)" + assert out.input_tokens == 100 + assert out.output_tokens == 200 + assert out.tier == ModelTier.C + fake_openai.chat.completions.create.assert_called_once() + + +def test_completion_tier_b_uses_anthropic(mocker): + fake_anthropic = mocker.MagicMock() + fake_msg = mocker.MagicMock() + fake_msg.content = [mocker.MagicMock(text="(strategy ...)")] + fake_msg.usage = mocker.MagicMock(input_tokens=80, output_tokens=150) + fake_anthropic.messages.create.return_value = fake_msg + mocker.patch("multi_swarm.llm.client.Anthropic", return_value=fake_anthropic) + + client = LLMClient(openrouter_api_key="or-x", anthropic_api_key="an-x") + g = make_genome(ModelTier.B) + out = client.complete(g, system="sys", user="usr") + + assert out.text == "(strategy ...)" + assert out.input_tokens == 80 + assert out.output_tokens == 150 + assert out.tier == ModelTier.B