trace-agent-server 0.0.1__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,140 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ from fastapi import FastAPI, Request, Response
7
+
8
+ from trace_agent_server.config import Settings
9
+
10
+
11
+ class AppObservability:
12
+ def __init__(self, enabled: bool) -> None:
13
+ self.enabled = enabled
14
+ self._registry = None
15
+ self._http_requests_total = None
16
+ self._http_request_duration_seconds = None
17
+ self._guardrail_findings_total = None
18
+ self._guardrail_blocked_total = None
19
+
20
+ if not enabled:
21
+ return
22
+
23
+ try:
24
+ from prometheus_client import CollectorRegistry, Counter, Histogram
25
+
26
+ self._registry = CollectorRegistry()
27
+ self._http_requests_total = Counter(
28
+ "trace_agent_http_requests_total",
29
+ "Total HTTP requests processed by the backend.",
30
+ ["method", "path", "status_code"],
31
+ registry=self._registry,
32
+ )
33
+ self._http_request_duration_seconds = Histogram(
34
+ "trace_agent_http_request_duration_seconds",
35
+ "HTTP request latency in seconds.",
36
+ ["method", "path"],
37
+ registry=self._registry,
38
+ buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10),
39
+ )
40
+ self._guardrail_findings_total = Counter(
41
+ "trace_agent_guardrail_findings_total",
42
+ "Total guardrail findings.",
43
+ ["kind", "severity"],
44
+ registry=self._registry,
45
+ )
46
+ self._guardrail_blocked_total = Counter(
47
+ "trace_agent_guardrail_blocked_total",
48
+ "Total blocked requests due to guardrails.",
49
+ ["reason"],
50
+ registry=self._registry,
51
+ )
52
+ except Exception:
53
+ self.enabled = False
54
+
55
+ def instrument_app(self, app: FastAPI, metrics_path: str = "/metrics") -> None:
56
+ if not self.enabled:
57
+ return
58
+
59
+ @app.middleware("http")
60
+ async def _metrics_middleware(request: Request, call_next):
61
+ started = time.perf_counter()
62
+ status_code = 500
63
+ try:
64
+ response = await call_next(request)
65
+ status_code = response.status_code
66
+ return response
67
+ finally:
68
+ route_path = self._route_path(request)
69
+ elapsed = max(0.0, time.perf_counter() - started)
70
+ if self._http_requests_total is not None:
71
+ self._http_requests_total.labels(request.method, route_path, str(status_code)).inc()
72
+ if self._http_request_duration_seconds is not None:
73
+ self._http_request_duration_seconds.labels(request.method, route_path).observe(elapsed)
74
+
75
+ @app.get(metrics_path)
76
+ def _metrics() -> Response:
77
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
78
+
79
+ payload = generate_latest(self._registry)
80
+ return Response(content=payload, media_type=CONTENT_TYPE_LATEST)
81
+
82
+ def record_guardrail_finding(self, *, kind: str, severity: str) -> None:
83
+ if not self.enabled or self._guardrail_findings_total is None:
84
+ return
85
+ self._guardrail_findings_total.labels(kind, severity).inc()
86
+
87
+ def record_guardrail_block(self, reason: str) -> None:
88
+ if not self.enabled or self._guardrail_blocked_total is None:
89
+ return
90
+ self._guardrail_blocked_total.labels(reason).inc()
91
+
92
+ @staticmethod
93
+ def _route_path(request: Request) -> str:
94
+ route = request.scope.get("route")
95
+ if route is not None and hasattr(route, "path"):
96
+ return str(route.path)
97
+ return request.url.path
98
+
99
+
100
+ def configure_opentelemetry(app: FastAPI, settings: Settings) -> dict[str, Any]:
101
+ if not settings.audit_enable_otel:
102
+ return {"enabled": False, "reason": "disabled"}
103
+
104
+ try:
105
+ from opentelemetry import trace
106
+ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
107
+ from opentelemetry.sdk.resources import SERVICE_NAME, Resource
108
+ from opentelemetry.sdk.trace import TracerProvider
109
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
110
+ except Exception as exc:
111
+ return {"enabled": False, "reason": f"missing_dependencies:{exc}"}
112
+
113
+ exporter = None
114
+ protocol = settings.audit_otel_exporter_protocol.lower().strip()
115
+ endpoint = settings.audit_otel_exporter_endpoint
116
+
117
+ try:
118
+ if protocol == "http":
119
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
120
+
121
+ exporter = OTLPSpanExporter(endpoint=endpoint) if endpoint else OTLPSpanExporter()
122
+ else:
123
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
124
+
125
+ exporter = OTLPSpanExporter(endpoint=endpoint, insecure=True) if endpoint else OTLPSpanExporter(insecure=True)
126
+ except Exception as exc:
127
+ return {"enabled": False, "reason": f"exporter_init_failed:{exc}"}
128
+
129
+ resource = Resource(attributes={SERVICE_NAME: settings.audit_otel_service_name})
130
+ tracer_provider = TracerProvider(resource=resource)
131
+ tracer_provider.add_span_processor(BatchSpanProcessor(exporter))
132
+ trace.set_tracer_provider(tracer_provider)
133
+ FastAPIInstrumentor.instrument_app(app)
134
+
135
+ return {
136
+ "enabled": True,
137
+ "service_name": settings.audit_otel_service_name,
138
+ "protocol": protocol,
139
+ "endpoint": endpoint,
140
+ }
@@ -0,0 +1,547 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from openai import OpenAI
9
+
10
+ from trace_agent_server.config import Settings
11
+ from trace_agent_sdk.redaction import summarize_value
12
+ from trace_agent_server.schemas import ToolDefinition
13
+
14
+
15
+ try:
16
+ from anthropic import Anthropic
17
+ except ImportError: # pragma: no cover
18
+ Anthropic = None # type: ignore[misc,assignment]
19
+
20
+ try:
21
+ import google.generativeai as genai
22
+ from google.generativeai import protos
23
+ except ImportError: # pragma: no cover
24
+ genai = None # type: ignore[assignment]
25
+ protos = None # type: ignore[assignment]
26
+
27
+
28
+ @dataclass
29
+ class ProviderToolCall:
30
+ id: str
31
+ name: str
32
+ arguments: dict[str, Any]
33
+ step_id: str
34
+ parent_step_id: str | None = None
35
+
36
+
37
+ @dataclass
38
+ class ProviderTurnRequest:
39
+ model: str
40
+ messages: list[dict[str, Any]]
41
+ tools: list[ToolDefinition]
42
+ tool_choice: str | dict[str, Any] = "auto"
43
+
44
+
45
+ @dataclass
46
+ class ProviderTurnResponse:
47
+ assistant_message: dict[str, Any] | None = None
48
+ tool_calls: list[ProviderToolCall] = field(default_factory=list)
49
+ summary: str = ""
50
+ metadata: dict[str, Any] = field(default_factory=dict)
51
+
52
+
53
+ class BaseProviderAdapter(ABC):
54
+ provider_name: str
55
+
56
+ @abstractmethod
57
+ def generate_turn(self, request: ProviderTurnRequest) -> ProviderTurnResponse:
58
+ raise NotImplementedError
59
+
60
+
61
+ class OpenAIProviderAdapter(BaseProviderAdapter):
62
+ provider_name = "openai"
63
+
64
+ def __init__(self, settings: Settings) -> None:
65
+ self.settings = settings
66
+
67
+ def generate_turn(self, request: ProviderTurnRequest) -> ProviderTurnResponse:
68
+ if not self.settings.openai_api_key and not self.settings.openai_base_url:
69
+ raise RuntimeError("TRACE_AGENT_OPENAI_API_KEY is required for provider 'openai'.")
70
+
71
+ api_key = self.settings.openai_api_key or "local-dev"
72
+ client = OpenAI(api_key=api_key, base_url=self.settings.openai_base_url)
73
+ completion = client.chat.completions.create(
74
+ model=request.model,
75
+ messages=request.messages,
76
+ tools=[self._tool_to_openai(tool) for tool in request.tools] or None,
77
+ tool_choice=request.tool_choice,
78
+ )
79
+ message = completion.choices[0].message
80
+ assistant_message = message.model_dump(exclude_none=True)
81
+ tool_calls: list[ProviderToolCall] = []
82
+
83
+ for tool_call in message.tool_calls or []:
84
+ arguments = {}
85
+ if tool_call.function.arguments:
86
+ arguments = json.loads(tool_call.function.arguments)
87
+ tool_calls.append(
88
+ ProviderToolCall(
89
+ id=tool_call.id,
90
+ name=tool_call.function.name,
91
+ arguments=arguments,
92
+ step_id=tool_call.id,
93
+ )
94
+ )
95
+
96
+ summary = (
97
+ f"Model requested {len(tool_calls)} tool call(s)."
98
+ if tool_calls
99
+ else summarize_value(message.content or "")
100
+ )
101
+ usage = completion.usage.model_dump(exclude_none=True) if completion.usage else {}
102
+ metadata = {
103
+ "response_id": completion.id,
104
+ "finish_reason": completion.choices[0].finish_reason,
105
+ "tool_call_count": len(tool_calls),
106
+ "usage": usage,
107
+ }
108
+ if getattr(completion, "service_tier", None):
109
+ metadata["service_tier"] = completion.service_tier
110
+ return ProviderTurnResponse(
111
+ assistant_message=assistant_message,
112
+ tool_calls=tool_calls,
113
+ summary=summary,
114
+ metadata=metadata,
115
+ )
116
+
117
+ @staticmethod
118
+ def _tool_to_openai(tool: ToolDefinition) -> dict[str, Any]:
119
+ return {
120
+ "type": "function",
121
+ "function": {
122
+ "name": tool.name,
123
+ "description": tool.description,
124
+ "parameters": tool.input_schema or {"type": "object", "properties": {}},
125
+ },
126
+ }
127
+
128
+
129
+ class AnthropicProviderAdapter(BaseProviderAdapter):
130
+ provider_name = "anthropic"
131
+
132
+ def __init__(self, settings: Settings) -> None:
133
+ self.settings = settings
134
+
135
+ def generate_turn(self, request: ProviderTurnRequest) -> ProviderTurnResponse:
136
+ if Anthropic is None:
137
+ raise RuntimeError("anthropic SDK is not installed. Install it with: uv pip install anthropic")
138
+ if not self.settings.anthropic_api_key and not self.settings.anthropic_base_url:
139
+ raise RuntimeError("TRACE_AGENT_ANTHROPIC_API_KEY is required for provider 'anthropic'.")
140
+
141
+ api_key = self.settings.anthropic_api_key or "local-dev"
142
+ client = Anthropic(api_key=api_key, base_url=self.settings.anthropic_base_url)
143
+
144
+ tools = [self._tool_to_anthropic(tool) for tool in request.tools]
145
+ tool_choice = self._map_tool_choice(request.tool_choice)
146
+
147
+ completion = client.messages.create(
148
+ model=request.model,
149
+ max_tokens=4096,
150
+ messages=request.messages, # type: ignore[arg-type]
151
+ tools=tools or None,
152
+ tool_choice=tool_choice,
153
+ )
154
+
155
+ content_texts: list[str] = []
156
+ tool_calls: list[ProviderToolCall] = []
157
+ for block in completion.content:
158
+ if block.type == "text":
159
+ content_texts.append(block.text)
160
+ elif block.type == "tool_use":
161
+ tool_calls.append(
162
+ ProviderToolCall(
163
+ id=block.id,
164
+ name=block.name,
165
+ arguments=block.input,
166
+ step_id=block.id,
167
+ )
168
+ )
169
+
170
+ assistant_message: dict[str, Any] = {"role": "assistant"}
171
+ if content_texts:
172
+ assistant_message["content"] = "\n".join(content_texts)
173
+ if tool_calls:
174
+ assistant_message["tool_calls"] = [
175
+ {"id": tc.id, "type": "function", "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)}}
176
+ for tc in tool_calls
177
+ ]
178
+
179
+ summary = (
180
+ f"Model requested {len(tool_calls)} tool call(s)."
181
+ if tool_calls
182
+ else summarize_value("\n".join(content_texts))
183
+ )
184
+ usage: dict[str, Any] = {}
185
+ if completion.usage:
186
+ usage = {
187
+ "prompt_tokens": completion.usage.input_tokens,
188
+ "completion_tokens": completion.usage.output_tokens,
189
+ "total_tokens": completion.usage.input_tokens + completion.usage.output_tokens,
190
+ }
191
+ metadata = {
192
+ "response_id": completion.id,
193
+ "finish_reason": completion.stop_reason,
194
+ "tool_call_count": len(tool_calls),
195
+ "usage": usage,
196
+ }
197
+ return ProviderTurnResponse(
198
+ assistant_message=assistant_message,
199
+ tool_calls=tool_calls,
200
+ summary=summary,
201
+ metadata=metadata,
202
+ )
203
+
204
+ @staticmethod
205
+ def _tool_to_anthropic(tool: ToolDefinition) -> dict[str, Any]:
206
+ return {
207
+ "name": tool.name,
208
+ "description": tool.description,
209
+ "input_schema": tool.input_schema or {"type": "object", "properties": {}},
210
+ }
211
+
212
+ @staticmethod
213
+ def _map_tool_choice(tool_choice: str | dict[str, Any]) -> dict[str, Any] | None:
214
+ if tool_choice == "auto":
215
+ return {"type": "auto"}
216
+ if tool_choice == "none":
217
+ return {"type": "none"}
218
+ if tool_choice == "required":
219
+ return {"type": "any"}
220
+ if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
221
+ return {"type": "tool", "name": tool_choice["function"]["name"]}
222
+ return None
223
+
224
+
225
+ class GoogleProviderAdapter(BaseProviderAdapter):
226
+ provider_name = "google"
227
+
228
+ def __init__(self, settings: Settings) -> None:
229
+ self.settings = settings
230
+
231
+ def generate_turn(self, request: ProviderTurnRequest) -> ProviderTurnResponse:
232
+ if genai is None or protos is None:
233
+ raise RuntimeError(
234
+ "google-generativeai SDK is not installed. Install it with: uv pip install google-generativeai"
235
+ )
236
+ if not self.settings.google_api_key:
237
+ raise RuntimeError("TRACE_AGENT_GOOGLE_API_KEY is required for provider 'google'.")
238
+
239
+ genai.configure(api_key=self.settings.google_api_key)
240
+ model = genai.GenerativeModel(model_name=request.model)
241
+
242
+ contents = self._messages_to_google_contents(request.messages)
243
+ tools = None
244
+ if request.tools:
245
+ decls = [self._tool_to_google(tool) for tool in request.tools]
246
+ tools = [protos.Tool(function_declarations=decls)]
247
+
248
+ response = model.generate_content(contents=contents, tools=tools)
249
+
250
+ content_texts: list[str] = []
251
+ tool_calls: list[ProviderToolCall] = []
252
+ candidate = response.candidates[0] if response.candidates else None
253
+ if candidate and candidate.content and candidate.content.parts:
254
+ for part in candidate.content.parts:
255
+ if part.text:
256
+ content_texts.append(part.text)
257
+ if part.function_call:
258
+ tool_calls.append(
259
+ ProviderToolCall(
260
+ id=part.function_call.name,
261
+ name=part.function_call.name,
262
+ arguments=dict(part.function_call.args) if part.function_call.args else {},
263
+ step_id=part.function_call.name,
264
+ )
265
+ )
266
+
267
+ assistant_message: dict[str, Any] = {"role": "assistant"}
268
+ if content_texts:
269
+ assistant_message["content"] = "\n".join(content_texts)
270
+ if tool_calls:
271
+ assistant_message["tool_calls"] = [
272
+ {"id": tc.id, "type": "function", "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)}}
273
+ for tc in tool_calls
274
+ ]
275
+
276
+ summary = (
277
+ f"Model requested {len(tool_calls)} tool call(s)."
278
+ if tool_calls
279
+ else summarize_value("\n".join(content_texts))
280
+ )
281
+ usage: dict[str, Any] = {}
282
+ if response.usage_metadata:
283
+ usage = {
284
+ "prompt_tokens": response.usage_metadata.prompt_token_count,
285
+ "completion_tokens": response.usage_metadata.candidates_token_count,
286
+ "total_tokens": (
287
+ (response.usage_metadata.prompt_token_count or 0)
288
+ + (response.usage_metadata.candidates_token_count or 0)
289
+ ),
290
+ }
291
+ metadata = {
292
+ "response_id": getattr(response, "prompt_feedback", None) and getattr(response.prompt_feedback, "block_reason", None) or None,
293
+ "finish_reason": candidate.finish_reason.name if candidate and candidate.finish_reason else None,
294
+ "tool_call_count": len(tool_calls),
295
+ "usage": usage,
296
+ }
297
+ return ProviderTurnResponse(
298
+ assistant_message=assistant_message,
299
+ tool_calls=tool_calls,
300
+ summary=summary,
301
+ metadata=metadata,
302
+ )
303
+
304
+ @staticmethod
305
+ def _messages_to_google_contents(messages: list[dict[str, Any]]) -> list[Any]:
306
+ contents: list[Any] = []
307
+ for msg in messages:
308
+ role = msg.get("role", "user")
309
+ text = msg.get("content", "")
310
+ if role == "system":
311
+ # Gemini handles system instructions differently; for simplicity we treat them as user messages
312
+ role = "user"
313
+ contents.append({"role": role, "parts": [text]})
314
+ return contents
315
+
316
+ @staticmethod
317
+ def _tool_to_google(tool: ToolDefinition) -> Any:
318
+ if protos is None:
319
+ raise RuntimeError("google-generativeai SDK is not installed.")
320
+ kwargs: dict[str, Any] = {
321
+ "name": tool.name,
322
+ "description": tool.description,
323
+ }
324
+ if tool.input_schema:
325
+ kwargs["parameters"] = tool.input_schema
326
+ return protos.FunctionDeclaration(**kwargs)
327
+
328
+
329
+ class KimiProviderAdapter(OpenAIProviderAdapter):
330
+ provider_name = "kimi"
331
+
332
+ def generate_turn(self, request: ProviderTurnRequest) -> ProviderTurnResponse:
333
+ if not self.settings.kimi_api_key and not self.settings.kimi_base_url:
334
+ raise RuntimeError("TRACE_AGENT_KIMI_API_KEY is required for provider 'kimi'.")
335
+
336
+ api_key = self.settings.kimi_api_key or "local-dev"
337
+ client = OpenAI(api_key=api_key, base_url=self.settings.kimi_base_url)
338
+ completion = client.chat.completions.create(
339
+ model=request.model,
340
+ messages=request.messages,
341
+ tools=[self._tool_to_openai(tool) for tool in request.tools] or None,
342
+ tool_choice=request.tool_choice,
343
+ )
344
+ message = completion.choices[0].message
345
+ assistant_message = message.model_dump(exclude_none=True)
346
+ tool_calls: list[ProviderToolCall] = []
347
+
348
+ for tool_call in message.tool_calls or []:
349
+ arguments = {}
350
+ if tool_call.function.arguments:
351
+ arguments = json.loads(tool_call.function.arguments)
352
+ tool_calls.append(
353
+ ProviderToolCall(
354
+ id=tool_call.id,
355
+ name=tool_call.function.name,
356
+ arguments=arguments,
357
+ step_id=tool_call.id,
358
+ )
359
+ )
360
+
361
+ summary = (
362
+ f"Model requested {len(tool_calls)} tool call(s)."
363
+ if tool_calls
364
+ else summarize_value(message.content or "")
365
+ )
366
+ usage = completion.usage.model_dump(exclude_none=True) if completion.usage else {}
367
+ metadata = {
368
+ "response_id": completion.id,
369
+ "finish_reason": completion.choices[0].finish_reason,
370
+ "tool_call_count": len(tool_calls),
371
+ "usage": usage,
372
+ }
373
+ return ProviderTurnResponse(
374
+ assistant_message=assistant_message,
375
+ tool_calls=tool_calls,
376
+ summary=summary,
377
+ metadata=metadata,
378
+ )
379
+
380
+
381
+ # ── LM Studio (multi-mode) ──────────────────────────────────────────────────
382
+
383
+ class _LMStudioSettingsProxy:
384
+ """Inject LM Studio URLs/keys into the fields that existing adapters read."""
385
+
386
+ def __init__(self, settings: Settings, mode: str) -> None:
387
+ self._settings = settings
388
+ self._mode = mode
389
+ base = (settings.lmstudio_base_url or "http://localhost:1234").rstrip("/")
390
+ if mode == "openai":
391
+ self._api_key = settings.lmstudio_api_key or "lm-studio"
392
+ self._base_url = f"{base}/v1"
393
+ elif mode == "anthropic":
394
+ self._api_key = settings.lmstudio_api_key or "lm-studio"
395
+ self._base_url = f"{base}/v1"
396
+ else:
397
+ self._api_key = None
398
+ self._base_url = base
399
+
400
+ @property
401
+ def openai_api_key(self) -> str | None:
402
+ return self._api_key if self._mode == "openai" else self._settings.openai_api_key
403
+
404
+ @property
405
+ def openai_base_url(self) -> str | None:
406
+ return self._base_url if self._mode == "openai" else self._settings.openai_base_url
407
+
408
+ @property
409
+ def anthropic_api_key(self) -> str | None:
410
+ return self._api_key if self._mode == "anthropic" else self._settings.anthropic_api_key
411
+
412
+ @property
413
+ def anthropic_base_url(self) -> str | None:
414
+ return self._base_url if self._mode == "anthropic" else self._settings.anthropic_base_url
415
+
416
+ def __getattr__(self, name: str) -> Any:
417
+ return getattr(self._settings, name)
418
+
419
+
420
+ class _LMStudioNativeAdapter(BaseProviderAdapter):
421
+ """Talk to LM Studio's native /api/v1/chat endpoint via plain HTTP."""
422
+
423
+ provider_name = "lmstudio"
424
+
425
+ def __init__(self, settings: Settings) -> None:
426
+ self.settings = settings
427
+
428
+ def generate_turn(self, request: ProviderTurnRequest) -> ProviderTurnResponse:
429
+ import httpx
430
+
431
+ base_url = (self.settings.lmstudio_base_url or "http://localhost:1234").rstrip("/")
432
+ api_key = self.settings.lmstudio_api_key
433
+
434
+ payload: dict[str, Any] = {
435
+ "model": request.model,
436
+ "input": self._messages_to_input(request.messages),
437
+ }
438
+ if request.tools:
439
+ # Native API does not accept custom tools in the request;
440
+ # tools must be pre-configured in LM Studio (MCPs / plugins).
441
+ # We still record them in metadata for observability.
442
+ payload["_TRACE_AGENT_tools"] = [t.name for t in request.tools]
443
+
444
+ headers: dict[str, str] = {"Content-Type": "application/json"}
445
+ if api_key:
446
+ headers["Authorization"] = f"Bearer {api_key}"
447
+
448
+ try:
449
+ response = httpx.post(
450
+ f"{base_url}/api/v1/chat",
451
+ json=payload,
452
+ headers=headers,
453
+ timeout=120,
454
+ )
455
+ response.raise_for_status()
456
+ data = response.json()
457
+ except Exception as exc:
458
+ raise RuntimeError(f"LM Studio native API request failed: {exc}") from exc
459
+
460
+ output = data.get("output", [])
461
+ content_texts: list[str] = []
462
+ tool_calls: list[ProviderToolCall] = []
463
+
464
+ for idx, item in enumerate(output):
465
+ if item.get("type") == "message":
466
+ content_texts.append(item.get("content", ""))
467
+ elif item.get("type") == "tool_call":
468
+ tool_name = item.get("tool", "")
469
+ tc_id = f"tc-{tool_name}-{idx}"
470
+ tool_calls.append(
471
+ ProviderToolCall(
472
+ id=tc_id,
473
+ name=tool_name,
474
+ arguments=item.get("arguments") or {},
475
+ step_id=tc_id,
476
+ )
477
+ )
478
+
479
+ assistant_message: dict[str, Any] = {"role": "assistant"}
480
+ if content_texts:
481
+ assistant_message["content"] = "\n".join(content_texts)
482
+ if tool_calls:
483
+ assistant_message["tool_calls"] = [
484
+ {"id": tc.id, "type": "function", "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)}}
485
+ for tc in tool_calls
486
+ ]
487
+
488
+ summary = (
489
+ f"Model requested {len(tool_calls)} tool call(s)."
490
+ if tool_calls
491
+ else summarize_value("\n".join(content_texts))
492
+ )
493
+
494
+ metadata: dict[str, Any] = {
495
+ "model_instance_id": data.get("model_instance_id"),
496
+ "finish_reason": None,
497
+ "tool_call_count": len(tool_calls),
498
+ "usage": {},
499
+ }
500
+ return ProviderTurnResponse(
501
+ assistant_message=assistant_message,
502
+ tool_calls=tool_calls,
503
+ summary=summary,
504
+ metadata=metadata,
505
+ )
506
+
507
+ @staticmethod
508
+ def _messages_to_input(messages: list[dict[str, Any]]) -> str:
509
+ parts: list[str] = []
510
+ for msg in messages:
511
+ role = msg.get("role", "user")
512
+ content = msg.get("content", "")
513
+ if role == "system":
514
+ parts.append(f"System: {content}")
515
+ elif role == "user":
516
+ parts.append(f"User: {content}")
517
+ elif role == "assistant":
518
+ parts.append(f"Assistant: {content}")
519
+ elif role == "tool":
520
+ parts.append(f"Tool result: {content}")
521
+ return "\n\n".join(parts)
522
+
523
+
524
+ class LMStudioProviderAdapter(BaseProviderAdapter):
525
+ """Facade that selects the concrete adapter based on lmstudio_mode."""
526
+
527
+ provider_name = "lmstudio"
528
+
529
+ def __init__(self, settings: Settings) -> None:
530
+ self.settings = settings
531
+ mode = (settings.lmstudio_mode or "openai").lower()
532
+
533
+ if mode == "openai":
534
+ proxy = _LMStudioSettingsProxy(settings, "openai")
535
+ self._delegate: BaseProviderAdapter = OpenAIProviderAdapter(proxy)
536
+ elif mode == "anthropic":
537
+ proxy = _LMStudioSettingsProxy(settings, "anthropic")
538
+ self._delegate = AnthropicProviderAdapter(proxy)
539
+ elif mode == "native":
540
+ self._delegate = _LMStudioNativeAdapter(settings)
541
+ else:
542
+ raise ValueError(
543
+ f"Invalid lmstudio_mode '{mode}'. Must be one of: openai, anthropic, native"
544
+ )
545
+
546
+ def generate_turn(self, request: ProviderTurnRequest) -> ProviderTurnResponse:
547
+ return self._delegate.generate_turn(request)