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.
- trace_agent_server/__init__.py +5 -0
- trace_agent_server/api/__init__.py +1 -0
- trace_agent_server/api/runs.py +1214 -0
- trace_agent_server/cli.py +29 -0
- trace_agent_server/config.py +57 -0
- trace_agent_server/db.py +24 -0
- trace_agent_server/main.py +73 -0
- trace_agent_server/models.py +131 -0
- trace_agent_server/schemas.py +549 -0
- trace_agent_server/services/__init__.py +1 -0
- trace_agent_server/services/analytics.py +305 -0
- trace_agent_server/services/audit_guardrails.py +303 -0
- trace_agent_server/services/compare.py +851 -0
- trace_agent_server/services/dashboard.py +125 -0
- trace_agent_server/services/events.py +131 -0
- trace_agent_server/services/explanation.py +230 -0
- trace_agent_server/services/graph.py +103 -0
- trace_agent_server/services/observability.py +140 -0
- trace_agent_server/services/provider.py +547 -0
- trace_agent_server/services/run_summary.py +363 -0
- trace_agent_server-0.0.1.dist-info/METADATA +21 -0
- trace_agent_server-0.0.1.dist-info/RECORD +25 -0
- trace_agent_server-0.0.1.dist-info/WHEEL +5 -0
- trace_agent_server-0.0.1.dist-info/entry_points.txt +2 -0
- trace_agent_server-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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)
|