tse-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """Agent Client di The Social Experiment.
2
+
3
+ Gira sul computer dell'utente. Ospita l'LLM locale (Ollama / LM Studio), carica la
4
+ personalita' dall'AGENT.md e traduce le decisioni del modello in azioni del protocollo
5
+ verso il World Server. Il server non vede mai il modello ne' il prompt: solo le azioni.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,221 @@
1
+ """Calibrazione vera: l'LLM dell'utente risponde alle sonde (roadmap-next 1a + 1b).
2
+
3
+ Il server genera le prove (probe/start), il client le fa risolvere al modello e rimanda
4
+ le risposte (probe/submit); il grading resta tutto sul server (il client non vede mai le
5
+ risposte attese della batteria di potenza). Questo rende VERI i numeri su cui poggiano i
6
+ requisiti delle stanze, la regola di fascia del master e il trigger a divergenza.
7
+
8
+ Scelte (concept-agent-power, concept-agent-capabilities):
9
+ - il system prompt NON e' l'AGENT.md: si misura il MODELLO, non la personalita'
10
+ (una personalita' laconica o teatrale falserebbe la misura);
11
+ - un modello lento/offline a meta' batteria: le prove non risposte valgono 0 (una
12
+ misura bassa e' comunque una misura valida), ma lo si dice all'utente;
13
+ - sonda 'image': l'immagine (data URL) va al modello via complete_with_image; se
14
+ l'adapter/modello non la supporta la sonda resta senza risposta (non verificata);
15
+ - sonde 'tools'/'tools_multi': il client fa da HARNESS. Il risultato dello strumento
16
+ viene rivelato SOLO se il modello emette la chiamata JSON corretta (e per la catena
17
+ get_key -> unlock(key) solo con la chiave giusta), poi il modello risponde col codice.
18
+ Limite onesto (come da wiki): il segreto viaggia nella sfida per l'harness, la sonda
19
+ verifica il comportamento di function-calling, non e' una prova crittografica.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import json
26
+ import re
27
+
28
+ import httpx
29
+
30
+ from .llm.base import LLMAdapter
31
+
32
+ # Neutro e severo: solo la risposta, nessuna personalita', nessuna spiegazione.
33
+ NEUTRAL_SYSTEM = (
34
+ "You are taking a technical capability test. Answer with the exact answer only: "
35
+ "no explanations, no reasoning out loud, no punctuation around it, no extra words."
36
+ )
37
+
38
+ # Tempo massimo per singola prova: un modello lento non deve bloccare la batteria per ore.
39
+ PER_CHALLENGE_TIMEOUT_S = 120.0
40
+
41
+
42
+ async def _answer_power_battery(
43
+ adapter: LLMAdapter, challenges: list[dict], on_progress=None
44
+ ) -> tuple[dict[str, str], list[str]]:
45
+ """Fa risolvere le prove al modello. Ritorna (risposte, id delle prove saltate)."""
46
+ answers: dict[str, str] = {}
47
+ skipped: list[str] = []
48
+ for i, ch in enumerate(challenges, start=1):
49
+ qid = ch["id"]
50
+ try:
51
+ text = await asyncio.wait_for(
52
+ adapter.complete(NEUTRAL_SYSTEM, ch["prompt"]), timeout=PER_CHALLENGE_TIMEOUT_S
53
+ )
54
+ answers[qid] = (text or "").strip()
55
+ except Exception: # noqa: BLE001 - modello lento/offline: la prova vale 0
56
+ skipped.append(qid)
57
+ if on_progress:
58
+ on_progress(i, len(challenges), qid)
59
+ return answers, skipped
60
+
61
+
62
+ # --- Harness delle sonde di capacita' (1b) ---
63
+
64
+ _JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
65
+
66
+
67
+ def _parse_tool_call(text: str) -> tuple[str, dict] | None:
68
+ """Estrae {"tool_call": <nome>, "args": {...}} dalla risposta del modello."""
69
+ m = _JSON_RE.search(text or "")
70
+ if not m:
71
+ return None
72
+ try:
73
+ obj = json.loads(m.group(0))
74
+ except (json.JSONDecodeError, ValueError):
75
+ return None
76
+ if not isinstance(obj, dict) or "tool_call" not in obj:
77
+ return None
78
+ args = obj.get("args")
79
+ return str(obj["tool_call"]), args if isinstance(args, dict) else {}
80
+
81
+
82
+ async def _run_tools_challenge(adapter: LLMAdapter, ch: dict) -> str | None:
83
+ """Esegue una sonda tools/tools_multi facendo da harness. Ritorna la risposta finale.
84
+
85
+ Il modello vede il prompt della sfida; ogni sua chiamata JSON corretta viene
86
+ 'eseguita' rivelando il risultato dal blocco harness; alla fine deve rispondere con
87
+ il codice. Massimo 3 passi di strumento (la catena vera ne richiede 2).
88
+ """
89
+ harness = ch.get("harness") or {}
90
+ results: dict = dict(harness.get("results") or {})
91
+ unlock_spec = harness.get("unlock") or None
92
+
93
+ transcript = ch["prompt"]
94
+ for _step in range(3):
95
+ reply = await asyncio.wait_for(
96
+ adapter.complete(NEUTRAL_SYSTEM, transcript), timeout=PER_CHALLENGE_TIMEOUT_S
97
+ )
98
+ call = _parse_tool_call(reply)
99
+ if call is None:
100
+ # Nessuna chiamata: e' la risposta finale (giusta o sbagliata che sia).
101
+ return (reply or "").strip()
102
+ name, args = call
103
+ if name in results:
104
+ tool_result = results[name]
105
+ elif unlock_spec is not None and name == "unlock":
106
+ # La catena: unlock rivela il codice SOLO con la chiave giusta.
107
+ if str(args.get("key", "")) == str(unlock_spec.get("expects")):
108
+ tool_result = unlock_spec.get("result")
109
+ else:
110
+ tool_result = "ERROR: wrong key"
111
+ else:
112
+ tool_result = "ERROR: unknown tool"
113
+ transcript += (
114
+ f"\n\nAssistant called: {json.dumps({'tool_call': name, 'args': args})}"
115
+ f"\nTool result: {tool_result}"
116
+ "\nContinue: call the next tool if needed, otherwise answer with the code only."
117
+ )
118
+ return None # non e' arrivato a una risposta finale
119
+
120
+
121
+ async def _answer_capabilities(
122
+ adapter: LLMAdapter, challenges: list[dict], on_progress=None
123
+ ) -> tuple[dict[str, str], list[str]]:
124
+ """Risponde alle sonde di capacita'. Chiavi risposta: cap_<modality> (come il server).
125
+
126
+ Le sonde che l'adapter/modello non supporta restano senza risposta: la capacita'
127
+ risulta non verificata (e costa la penalita' lieve solo se era stata dichiarata).
128
+ """
129
+ answers: dict[str, str] = {}
130
+ skipped: list[str] = []
131
+ for i, ch in enumerate(challenges, start=1):
132
+ modality = ch.get("modality", "")
133
+ key = f"cap_{modality}"
134
+ try:
135
+ if modality == "image" and ch.get("image"):
136
+ text = await asyncio.wait_for(
137
+ adapter.complete_with_image(NEUTRAL_SYSTEM, ch["prompt"], ch["image"]),
138
+ timeout=PER_CHALLENGE_TIMEOUT_S,
139
+ )
140
+ answers[key] = (text or "").strip()
141
+ elif modality in ("tools", "tools_multi"):
142
+ final = await _run_tools_challenge(adapter, ch)
143
+ if final is None:
144
+ skipped.append(key)
145
+ else:
146
+ answers[key] = final
147
+ else:
148
+ skipped.append(key) # es. audio: nessuna sonda reale lato client
149
+ except Exception: # noqa: BLE001 - non supportata/offline: resta non verificata
150
+ skipped.append(key)
151
+ if on_progress:
152
+ on_progress(i, len(challenges), key)
153
+ return answers, skipped
154
+
155
+
156
+ async def run_calibration(
157
+ server_url: str,
158
+ agent_id: str,
159
+ token: str,
160
+ adapter: LLMAdapter | None,
161
+ on_progress=None,
162
+ transport=None, # per i test: un httpx.MockTransport al posto della rete
163
+ ) -> dict:
164
+ """Esegue la calibrazione end-to-end contro il server. Ritorna l'esito arricchito.
165
+
166
+ Con adapter None (modalita' test, nessun LLM) le risposte sono vuote: la potenza
167
+ risulta 0, comunque MISURATA (non null), quindi l'onboarding puo' proseguire.
168
+ """
169
+ headers = {"X-Agent-Token": token}
170
+ async with httpx.AsyncClient(
171
+ base_url=server_url.rstrip("/"), timeout=60, transport=transport
172
+ ) as c:
173
+ started = await c.post(f"/api/agents/{agent_id}/probe/start", headers=headers)
174
+ started.raise_for_status()
175
+ battery = started.json()
176
+
177
+ power_challenges = battery.get("power", [])
178
+ capability_challenges = battery.get("capabilities", [])
179
+ if adapter is None:
180
+ answers, skipped = {}, [ch["id"] for ch in power_challenges]
181
+ else:
182
+ answers, skipped = await _answer_power_battery(
183
+ adapter, power_challenges, on_progress
184
+ )
185
+ cap_answers, cap_skipped = await _answer_capabilities(
186
+ adapter, capability_challenges, on_progress
187
+ )
188
+ answers.update(cap_answers)
189
+ skipped.extend(cap_skipped)
190
+
191
+ submitted = await c.post(
192
+ f"/api/agents/{agent_id}/probe/submit", json={"answers": answers}, headers=headers
193
+ )
194
+ submitted.raise_for_status()
195
+ result = submitted.json()
196
+
197
+ result["challenges_total"] = len(power_challenges) + (
198
+ len(capability_challenges) if adapter is not None else 0
199
+ )
200
+ result["challenges_answered"] = len(answers)
201
+ result["challenges_skipped"] = skipped
202
+ return result
203
+
204
+
205
+ def print_calibration_result(result: dict) -> None:
206
+ """Stampa leggibile dell'esito per la CLI."""
207
+ print("\n--- Calibrazione completata ---")
208
+ print("power score: ", result.get("power_score"))
209
+ print("power level: ", result.get("power_level"))
210
+ print("battery ver.: ", result.get("battery_version"))
211
+ est = result.get("model_estimate") or {}
212
+ print("stima modello:", est.get("guess", "?"))
213
+ caps = result.get("verified_capabilities") or []
214
+ print("capacita' verificate:", ", ".join(caps) if caps else "-")
215
+ skipped = result.get("challenges_skipped") or []
216
+ if skipped:
217
+ print(
218
+ f"\nATTENZIONE: {len(skipped)} prove su {result.get('challenges_total')} non hanno "
219
+ "avuto risposta (modello lento o non raggiungibile): valgono 0."
220
+ )
221
+ print("Prove saltate:", ", ".join(skipped))
agent_client/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """Configurazione dell'Agent Client.
2
+
3
+ Legge i parametri da riga di comando e da variabili d'ambiente. Il `token` e l'`agent_id`
4
+ si ottengono registrando l'agente (dal frontend o dal sottocomando `register`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass
14
+ class Config:
15
+ server_url: str = "http://localhost:8099" # base HTTP del World Server (porta di dev)
16
+ token: str = "" # token dell'agente (auth della connessione WS)
17
+ agent_id: str = ""
18
+ room_id: str = "" # stanza in cui entrare e collaborare
19
+ runtime: str = "test" # ollama | lmstudio | anthropic | openai | google | test
20
+ base_url: str = "" # endpoint locale dell'LLM (per ollama/lmstudio)
21
+ model: str = "TestModel" # modello dichiarato al server (auto-dichiarato)
22
+ api_key: str = "" # chiave dell'utente per i runtime via API (Claude/GPT/Gemini)
23
+ inference_interval_s: int = 5 # tetto anti-spreco: intervallo minimo tra inferenze
24
+ agent_md_path: str = "AGENT.md"
25
+ # La personalita' gia' risolta. `tse-agent start` la prende dal server (e' li' che vive,
26
+ # approvata dal gate): se e' valorizzata vince sul file, cosi' l'utente non deve avere
27
+ # alcun AGENT.md sul disco. Vuota = si legge `agent_md_path` (comandi espliciti).
28
+ agent_md_text: str = ""
29
+ # Ragionamento (runtime anthropic): adaptive thinking per le decisioni piu' complesse.
30
+ # Spento di default per parsimonia; 'effort' regola profondita'/spesa (low..max).
31
+ thinking: bool = False
32
+ effort: str = "" # "" = default dell'API; low | medium | high | xhigh | max
33
+
34
+ def ws_url(self) -> str:
35
+ base = self.server_url.replace("http://", "ws://").replace("https://", "wss://").rstrip("/")
36
+ return f"{base}/ws?token={self.token}"
37
+
38
+ def llm_base_url(self) -> str:
39
+ if self.base_url:
40
+ return self.base_url
41
+ if self.runtime == "ollama":
42
+ return "http://localhost:11434"
43
+ if self.runtime == "lmstudio":
44
+ return "http://localhost:1234/v1"
45
+ return ""
46
+
47
+
48
+ def from_env() -> Config:
49
+ return Config(
50
+ server_url=os.environ.get("TSE_SERVER_URL", "http://localhost:8099"),
51
+ token=os.environ.get("TSE_TOKEN", ""),
52
+ agent_id=os.environ.get("TSE_AGENT_ID", ""),
53
+ room_id=os.environ.get("TSE_ROOM_ID", ""),
54
+ runtime=os.environ.get("TSE_RUNTIME", "test"),
55
+ base_url=os.environ.get("TSE_LLM_BASE_URL", ""),
56
+ model=os.environ.get("TSE_MODEL", "TestModel"),
57
+ api_key=os.environ.get("TSE_API_KEY", ""),
58
+ inference_interval_s=int(os.environ.get("TSE_INFERENCE_INTERVAL", "5")),
59
+ agent_md_path=os.environ.get("TSE_AGENT_MD", "AGENT.md"),
60
+ thinking=os.environ.get("TSE_THINKING", "").lower() in ("1", "true", "yes", "on"),
61
+ effort=os.environ.get("TSE_EFFORT", ""),
62
+ )
@@ -0,0 +1,10 @@
1
+ """Adapter verso l'LLM locale (Ollama / LM Studio).
2
+
3
+ Astraggono il runtime dietro un'unica interfaccia, cosi' il resto del client non dipende
4
+ dal runtime specifico (integration-ollama). In modalita' 'test' non c'e' LLM: il loop usa
5
+ un'euristica, utile per far girare il mondo senza un modello locale.
6
+ """
7
+
8
+ from .base import LLMAdapter, build_adapter
9
+
10
+ __all__ = ["LLMAdapter", "build_adapter"]
@@ -0,0 +1,65 @@
1
+ """Adapter per Claude (Anthropic API), via l'SDK ufficiale `anthropic`.
2
+
3
+ L'utente porta la propria chiave API (variabile TSE_API_KEY / ANTHROPIC_API_KEY o --api-key):
4
+ il costo dei token resta a carico suo, quindi la decentralizzazione della spesa e' preservata
5
+ (adr-user-hosted-llms). Il World Server non vede mai la chiave ne' il prompt.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .base import LLMAdapter
11
+
12
+
13
+ class AnthropicAdapter(LLMAdapter):
14
+ def __init__(self, api_key: str, model: str, thinking: bool = False, effort: str = "") -> None:
15
+ try:
16
+ import anthropic
17
+ except ImportError as exc: # noqa: BLE001
18
+ raise RuntimeError(
19
+ "Manca il pacchetto 'anthropic'. Installa con: pip install \".[api]\""
20
+ ) from exc
21
+ # api_key vuota -> l'SDK usa ANTHROPIC_API_KEY dall'ambiente.
22
+ self.client = anthropic.AsyncAnthropic(api_key=api_key) if api_key else anthropic.AsyncAnthropic()
23
+ self.model = model or "claude-opus-4-8"
24
+ # Adaptive thinking (opzionale, --thinking): il modello decide da solo quando e quanto
25
+ # ragionare; 'effort' (low..max) regola profondita' e spesa di token. Spento di default
26
+ # per parsimonia: le decisioni della stanza sono brevi e strutturate.
27
+ self.thinking = thinking
28
+ self.effort = effort
29
+
30
+ def _request_kwargs(self, system: str, user: str) -> dict:
31
+ """Costruisce i parametri della richiesta (separato per testabilita' senza rete)."""
32
+ kwargs: dict = {
33
+ "model": self.model,
34
+ # Col thinking attivo il ragionamento consuma output token: serve piu' spazio.
35
+ "max_tokens": 8192 if self.thinking else 1024,
36
+ "system": system,
37
+ "messages": [{"role": "user", "content": user}],
38
+ }
39
+ if self.thinking:
40
+ kwargs["thinking"] = {"type": "adaptive"}
41
+ if self.effort:
42
+ kwargs["output_config"] = {"effort": self.effort}
43
+ return kwargs
44
+
45
+ async def complete(self, system: str, user: str) -> str:
46
+ resp = await self.client.messages.create(**self._request_kwargs(system, user))
47
+ # Solo i blocchi di testo: gli eventuali blocchi 'thinking' non entrano nella risposta.
48
+ return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
49
+
50
+ async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
51
+ from .base import parse_data_url
52
+
53
+ media, b64 = parse_data_url(image_data_url)
54
+ kwargs = self._request_kwargs(system, user)
55
+ kwargs["messages"] = [
56
+ {
57
+ "role": "user",
58
+ "content": [
59
+ {"type": "image", "source": {"type": "base64", "media_type": media, "data": b64}},
60
+ {"type": "text", "text": user},
61
+ ],
62
+ }
63
+ ]
64
+ resp = await self.client.messages.create(**kwargs)
65
+ return "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
@@ -0,0 +1,61 @@
1
+ """Interfaccia comune degli adapter LLM e factory per runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+
8
+ class LLMAdapter(ABC):
9
+ @abstractmethod
10
+ async def complete(self, system: str, user: str) -> str:
11
+ """Restituisce il testo generato dal modello dato system+user prompt."""
12
+
13
+ async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
14
+ """Come complete, ma con un'immagine (data URL) allegata al messaggio utente.
15
+
16
+ Serve alla sonda 'image' (roadmap-next 1b). Default: non supportato; gli adapter
17
+ multimodali fanno override. Chi chiama tratta l'eccezione come 'capacita' non
18
+ disponibile' (la sonda resta senza risposta, mai un crash).
19
+ """
20
+ raise NotImplementedError("image input not supported by this adapter")
21
+
22
+
23
+ def parse_data_url(data_url: str) -> tuple[str, str]:
24
+ """Scompone un data URL 'data:<media>;base64,<dati>' in (media_type, base64)."""
25
+ head, _, b64 = data_url.partition(",")
26
+ media = head.removeprefix("data:").removesuffix(";base64") or "image/png"
27
+ return media, b64
28
+
29
+
30
+ def build_adapter(config) -> LLMAdapter | None:
31
+ """Crea l'adapter per il runtime scelto. `None` = modalita' test (nessun LLM).
32
+
33
+ Runtime locali: ollama, lmstudio. Runtime via API (l'utente porta la propria chiave):
34
+ anthropic (Claude), openai (GPT), google (Gemini).
35
+ """
36
+ if config.runtime == "ollama":
37
+ from .ollama import OllamaAdapter
38
+
39
+ return OllamaAdapter(config.llm_base_url(), config.model)
40
+ if config.runtime == "lmstudio":
41
+ from .lmstudio import LMStudioAdapter
42
+
43
+ return LMStudioAdapter(config.llm_base_url(), config.model)
44
+ if config.runtime == "anthropic":
45
+ from .anthropic import AnthropicAdapter
46
+
47
+ return AnthropicAdapter(
48
+ config.api_key,
49
+ config.model,
50
+ thinking=getattr(config, "thinking", False),
51
+ effort=getattr(config, "effort", ""),
52
+ )
53
+ if config.runtime == "openai":
54
+ from .openai import OpenAIAdapter
55
+
56
+ return OpenAIAdapter(config.api_key, config.model)
57
+ if config.runtime == "google":
58
+ from .gemini import GoogleAdapter
59
+
60
+ return GoogleAdapter(config.api_key, config.model)
61
+ return None
@@ -0,0 +1,47 @@
1
+ """Adapter per Gemini (Google AI), via l'SDK ufficiale `google-genai`.
2
+
3
+ L'utente porta la propria chiave (TSE_API_KEY / GOOGLE_API_KEY o --api-key). Il costo resta suo.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .base import LLMAdapter
9
+
10
+
11
+ class GoogleAdapter(LLMAdapter):
12
+ def __init__(self, api_key: str, model: str) -> None:
13
+ try:
14
+ from google import genai
15
+ except ImportError as exc: # noqa: BLE001
16
+ raise RuntimeError(
17
+ "Manca il pacchetto 'google-genai'. Installa con: pip install \".[api]\""
18
+ ) from exc
19
+ self._genai = genai
20
+ self.client = genai.Client(api_key=api_key) if api_key else genai.Client()
21
+ self.model = model or "gemini-2.0-flash"
22
+
23
+ async def complete(self, system: str, user: str) -> str:
24
+ from google.genai import types
25
+
26
+ resp = await self.client.aio.models.generate_content(
27
+ model=self.model,
28
+ contents=user,
29
+ config=types.GenerateContentConfig(system_instruction=system),
30
+ )
31
+ return resp.text or ""
32
+
33
+ async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
34
+ import base64
35
+
36
+ from google.genai import types
37
+
38
+ from .base import parse_data_url
39
+
40
+ media, b64 = parse_data_url(image_data_url)
41
+ part = types.Part.from_bytes(data=base64.b64decode(b64), mime_type=media)
42
+ resp = await self.client.aio.models.generate_content(
43
+ model=self.model,
44
+ contents=[part, user],
45
+ config=types.GenerateContentConfig(system_instruction=system),
46
+ )
47
+ return resp.text or ""
@@ -0,0 +1,54 @@
1
+ """Adapter per LM Studio (server locale OpenAI-compatibile, default http://localhost:1234/v1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from .base import LLMAdapter
8
+
9
+
10
+ class LMStudioAdapter(LLMAdapter):
11
+ def __init__(self, base_url: str, model: str) -> None:
12
+ self.base_url = base_url.rstrip("/")
13
+ self.model = model
14
+
15
+ async def complete(self, system: str, user: str) -> str:
16
+ payload = {
17
+ "model": self.model,
18
+ "messages": [
19
+ {"role": "system", "content": system},
20
+ {"role": "user", "content": user},
21
+ ],
22
+ "temperature": 0.7,
23
+ "stream": False,
24
+ }
25
+ async with httpx.AsyncClient(timeout=120) as http:
26
+ resp = await http.post(f"{self.base_url}/chat/completions", json=payload)
27
+ resp.raise_for_status()
28
+ data = resp.json()
29
+ choices = data.get("choices") or [{}]
30
+ return (choices[0].get("message") or {}).get("content", "")
31
+
32
+ async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
33
+ # Formato OpenAI-compatibile (funziona solo con modelli multimodali caricati).
34
+ payload = {
35
+ "model": self.model,
36
+ "messages": [
37
+ {"role": "system", "content": system},
38
+ {
39
+ "role": "user",
40
+ "content": [
41
+ {"type": "text", "text": user},
42
+ {"type": "image_url", "image_url": {"url": image_data_url}},
43
+ ],
44
+ },
45
+ ],
46
+ "temperature": 0.7,
47
+ "stream": False,
48
+ }
49
+ async with httpx.AsyncClient(timeout=120) as http:
50
+ resp = await http.post(f"{self.base_url}/chat/completions", json=payload)
51
+ resp.raise_for_status()
52
+ data = resp.json()
53
+ choices = data.get("choices") or [{}]
54
+ return (choices[0].get("message") or {}).get("content", "")
@@ -0,0 +1,47 @@
1
+ """Adapter per Ollama (API HTTP locale, default http://localhost:11434)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from .base import LLMAdapter
8
+
9
+
10
+ class OllamaAdapter(LLMAdapter):
11
+ def __init__(self, base_url: str, model: str) -> None:
12
+ self.base_url = base_url.rstrip("/")
13
+ self.model = model
14
+
15
+ async def complete(self, system: str, user: str) -> str:
16
+ payload = {
17
+ "model": self.model,
18
+ "messages": [
19
+ {"role": "system", "content": system},
20
+ {"role": "user", "content": user},
21
+ ],
22
+ "stream": False,
23
+ }
24
+ async with httpx.AsyncClient(timeout=120) as http:
25
+ resp = await http.post(f"{self.base_url}/api/chat", json=payload)
26
+ resp.raise_for_status()
27
+ data = resp.json()
28
+ return (data.get("message") or {}).get("content", "")
29
+
30
+ async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
31
+ # Ollama accetta immagini base64 nel messaggio (solo modelli multimodali).
32
+ from .base import parse_data_url
33
+
34
+ _media, b64 = parse_data_url(image_data_url)
35
+ payload = {
36
+ "model": self.model,
37
+ "messages": [
38
+ {"role": "system", "content": system},
39
+ {"role": "user", "content": user, "images": [b64]},
40
+ ],
41
+ "stream": False,
42
+ }
43
+ async with httpx.AsyncClient(timeout=120) as http:
44
+ resp = await http.post(f"{self.base_url}/api/chat", json=payload)
45
+ resp.raise_for_status()
46
+ data = resp.json()
47
+ return (data.get("message") or {}).get("content", "")
@@ -0,0 +1,46 @@
1
+ """Adapter per GPT (OpenAI API), via l'SDK ufficiale `openai`.
2
+
3
+ L'utente porta la propria chiave (TSE_API_KEY / OPENAI_API_KEY o --api-key). Il costo resta suo.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .base import LLMAdapter
9
+
10
+
11
+ class OpenAIAdapter(LLMAdapter):
12
+ def __init__(self, api_key: str, model: str) -> None:
13
+ try:
14
+ from openai import AsyncOpenAI
15
+ except ImportError as exc: # noqa: BLE001
16
+ raise RuntimeError(
17
+ "Manca il pacchetto 'openai'. Installa con: pip install \".[api]\""
18
+ ) from exc
19
+ self.client = AsyncOpenAI(api_key=api_key) if api_key else AsyncOpenAI()
20
+ self.model = model or "gpt-4o"
21
+
22
+ async def complete(self, system: str, user: str) -> str:
23
+ resp = await self.client.chat.completions.create(
24
+ model=self.model,
25
+ messages=[
26
+ {"role": "system", "content": system},
27
+ {"role": "user", "content": user},
28
+ ],
29
+ )
30
+ return resp.choices[0].message.content or ""
31
+
32
+ async def complete_with_image(self, system: str, user: str, image_data_url: str) -> str:
33
+ resp = await self.client.chat.completions.create(
34
+ model=self.model,
35
+ messages=[
36
+ {"role": "system", "content": system},
37
+ {
38
+ "role": "user",
39
+ "content": [
40
+ {"type": "text", "text": user},
41
+ {"type": "image_url", "image_url": {"url": image_data_url}},
42
+ ],
43
+ },
44
+ ],
45
+ )
46
+ return resp.choices[0].message.content or ""