agent-runtime-core 0.8.0__py3-none-any.whl → 0.9.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.
@@ -24,11 +24,15 @@ import json
24
24
  import os
25
25
  from datetime import datetime
26
26
  from pathlib import Path
27
- from typing import Any, Callable, Optional
27
+ from typing import Any, Callable, Optional, TYPE_CHECKING
28
28
  from uuid import UUID, uuid4
29
29
 
30
30
  from agent_runtime_core.interfaces import EventType, Message, ToolRegistry
31
31
 
32
+ if TYPE_CHECKING:
33
+ from agent_runtime_core.multi_agent import SystemContext
34
+ from agent_runtime_core.privacy import PrivacyConfig, UserContext
35
+
32
36
 
33
37
  class InMemoryRunContext:
34
38
  """
@@ -65,10 +69,13 @@ class InMemoryRunContext:
65
69
  metadata: Optional[dict] = None,
66
70
  tool_registry: Optional[ToolRegistry] = None,
67
71
  on_event: Optional[Callable[[str, dict], None]] = None,
72
+ system_context: Optional["SystemContext"] = None,
73
+ user_context: Optional["UserContext"] = None,
74
+ privacy_config: Optional["PrivacyConfig"] = None,
68
75
  ):
69
76
  """
70
77
  Initialize an in-memory run context.
71
-
78
+
72
79
  Args:
73
80
  run_id: Unique identifier for this run (auto-generated if not provided)
74
81
  conversation_id: Associated conversation ID (optional)
@@ -77,6 +84,9 @@ class InMemoryRunContext:
77
84
  metadata: Run metadata
78
85
  tool_registry: Registry of available tools
79
86
  on_event: Optional callback for events (for testing/debugging)
87
+ system_context: Optional SystemContext for multi-agent systems with shared knowledge
88
+ user_context: Optional UserContext for user isolation and privacy
89
+ privacy_config: Optional PrivacyConfig for privacy settings (defaults to max privacy)
80
90
  """
81
91
  self._run_id = run_id or uuid4()
82
92
  self._conversation_id = conversation_id
@@ -88,7 +98,18 @@ class InMemoryRunContext:
88
98
  self._state: Optional[dict] = None
89
99
  self._events: list[dict] = []
90
100
  self._on_event = on_event
91
-
101
+ self._system_context = system_context
102
+
103
+ # Import here to avoid circular imports
104
+ from agent_runtime_core.privacy import (
105
+ DEFAULT_PRIVACY_CONFIG,
106
+ ANONYMOUS_USER,
107
+ )
108
+
109
+ # Default to secure settings: anonymous user + strict privacy
110
+ self._user_context = user_context if user_context is not None else ANONYMOUS_USER
111
+ self._privacy_config = privacy_config if privacy_config is not None else DEFAULT_PRIVACY_CONFIG
112
+
92
113
  @property
93
114
  def run_id(self) -> UUID:
94
115
  """Unique identifier for this run."""
@@ -118,7 +139,22 @@ class InMemoryRunContext:
118
139
  def tool_registry(self) -> ToolRegistry:
119
140
  """Registry of available tools for this agent."""
120
141
  return self._tool_registry
121
-
142
+
143
+ @property
144
+ def system_context(self) -> Optional["SystemContext"]:
145
+ """System context for multi-agent systems with shared knowledge."""
146
+ return self._system_context
147
+
148
+ @property
149
+ def user_context(self) -> "UserContext":
150
+ """User context for privacy and data isolation. Defaults to ANONYMOUS_USER."""
151
+ return self._user_context
152
+
153
+ @property
154
+ def privacy_config(self) -> "PrivacyConfig":
155
+ """Privacy configuration for this run. Defaults to DEFAULT_PRIVACY_CONFIG (strict)."""
156
+ return self._privacy_config
157
+
122
158
  async def emit(self, event_type: EventType | str, payload: dict) -> None:
123
159
  """Emit an event (stored in memory)."""
124
160
  event_type_str = event_type.value if hasattr(event_type, 'value') else str(event_type)
@@ -195,6 +231,9 @@ class FileRunContext:
195
231
  metadata: Optional[dict] = None,
196
232
  tool_registry: Optional[ToolRegistry] = None,
197
233
  on_event: Optional[Callable[[str, dict], None]] = None,
234
+ system_context: Optional["SystemContext"] = None,
235
+ user_context: Optional["UserContext"] = None,
236
+ privacy_config: Optional["PrivacyConfig"] = None,
198
237
  ):
199
238
  """
200
239
  Initialize a file-based run context.
@@ -208,6 +247,9 @@ class FileRunContext:
208
247
  metadata: Run metadata
209
248
  tool_registry: Registry of available tools
210
249
  on_event: Optional callback for events
250
+ system_context: Optional SystemContext for multi-agent systems with shared knowledge
251
+ user_context: Optional UserContext for user isolation and privacy
252
+ privacy_config: Optional PrivacyConfig for privacy settings (defaults to max privacy)
211
253
  """
212
254
  self._run_id = run_id or uuid4()
213
255
  self._checkpoint_dir = Path(checkpoint_dir)
@@ -219,6 +261,17 @@ class FileRunContext:
219
261
  self._cancelled = False
220
262
  self._on_event = on_event
221
263
  self._state_cache: Optional[dict] = None
264
+ self._system_context = system_context
265
+
266
+ # Import here to avoid circular imports
267
+ from agent_runtime_core.privacy import (
268
+ DEFAULT_PRIVACY_CONFIG,
269
+ ANONYMOUS_USER,
270
+ )
271
+
272
+ # Default to secure settings: anonymous user + strict privacy
273
+ self._user_context = user_context if user_context is not None else ANONYMOUS_USER
274
+ self._privacy_config = privacy_config if privacy_config is not None else DEFAULT_PRIVACY_CONFIG
222
275
 
223
276
  # Ensure checkpoint directory exists
224
277
  self._checkpoint_dir.mkdir(parents=True, exist_ok=True)
@@ -253,6 +306,21 @@ class FileRunContext:
253
306
  """Registry of available tools for this agent."""
254
307
  return self._tool_registry
255
308
 
309
+ @property
310
+ def system_context(self) -> Optional["SystemContext"]:
311
+ """System context for multi-agent systems with shared knowledge."""
312
+ return self._system_context
313
+
314
+ @property
315
+ def user_context(self) -> "UserContext":
316
+ """User context for privacy and data isolation. Defaults to ANONYMOUS_USER."""
317
+ return self._user_context
318
+
319
+ @property
320
+ def privacy_config(self) -> "PrivacyConfig":
321
+ """Privacy configuration for this run. Defaults to DEFAULT_PRIVACY_CONFIG (strict)."""
322
+ return self._privacy_config
323
+
256
324
  def _checkpoint_path(self) -> Path:
257
325
  """Get the path to the checkpoint file for this run."""
258
326
  return self._checkpoint_dir / f"{self._run_id}.json"
@@ -323,13 +323,21 @@ class ToolRegistry:
323
323
  """
324
324
  return self.to_openai_format()
325
325
 
326
- async def execute(self, name: str, arguments: dict) -> Any:
326
+ async def execute(
327
+ self,
328
+ name: str,
329
+ arguments: dict,
330
+ ctx: Optional["RunContext"] = None,
331
+ **kwargs
332
+ ) -> Any:
327
333
  """
328
334
  Execute a tool by name.
329
335
 
330
336
  Args:
331
337
  name: Tool name
332
338
  arguments: Tool arguments
339
+ ctx: Optional RunContext, passed to handlers with requires_context=True
340
+ **kwargs: Additional arguments to pass to the handler
333
341
 
334
342
  Returns:
335
343
  Tool result
@@ -340,7 +348,15 @@ class ToolRegistry:
340
348
  tool = self._tools.get(name)
341
349
  if not tool:
342
350
  raise KeyError(f"Tool not found: {name}")
343
- return await tool.handler(**arguments)
351
+
352
+ # Check if the tool requires context
353
+ requires_context = tool.metadata.get('requires_context', False) if tool.metadata else False
354
+
355
+ if requires_context and ctx is not None:
356
+ # Pass ctx to the handler for tools that need it (e.g., sub-agent tools)
357
+ return await tool.handler(**arguments, ctx=ctx, **kwargs)
358
+ else:
359
+ return await tool.handler(**arguments, **kwargs)
344
360
 
345
361
  async def execute_with_events(
346
362
  self,
@@ -350,19 +366,19 @@ class ToolRegistry:
350
366
  ) -> Any:
351
367
  """
352
368
  Execute a tool and automatically emit events.
353
-
369
+
354
370
  This is a convenience method that wraps execute() and handles
355
371
  event emission automatically. Use this in your agent loop to
356
372
  reduce boilerplate.
357
-
373
+
358
374
  Args:
359
375
  tool_call: Tool call object with name, arguments, and id
360
376
  ctx: Run context for emitting events
361
377
  **kwargs: Additional arguments to pass to the tool
362
-
378
+
363
379
  Returns:
364
380
  Tool result
365
-
381
+
366
382
  Example:
367
383
  for tool_call in response.tool_calls:
368
384
  result = await tools.execute_with_events(tool_call, ctx)
@@ -373,17 +389,17 @@ class ToolRegistry:
373
389
  "tool_args": tool_call.arguments,
374
390
  "tool_call_id": tool_call.id,
375
391
  })
376
-
377
- # Execute the tool
378
- result = await self.execute(tool_call.name, tool_call.arguments, **kwargs)
379
-
392
+
393
+ # Execute the tool, passing ctx for tools that require it
394
+ result = await self.execute(tool_call.name, tool_call.arguments, ctx=ctx, **kwargs)
395
+
380
396
  # Emit tool result event
381
397
  await ctx.emit(EventType.TOOL_RESULT, {
382
398
  "tool_name": tool_call.name,
383
399
  "tool_call_id": tool_call.id,
384
400
  "result": result,
385
401
  })
386
-
402
+
387
403
  return result
388
404
 
389
405
 
@@ -501,6 +517,7 @@ class LLMResponse:
501
517
  model: str = ""
502
518
  finish_reason: str = ""
503
519
  raw_response: Optional[Any] = None
520
+ thinking: Optional[str] = None # Extended thinking content (Anthropic)
504
521
 
505
522
  @property
506
523
  def tool_calls(self) -> Optional[list["LLMToolCall"]]:
@@ -528,6 +545,7 @@ class LLMStreamChunk:
528
545
  tool_calls: Optional[list] = None
529
546
  finish_reason: Optional[str] = None
530
547
  usage: Optional[dict] = None
548
+ thinking: Optional[str] = None # Extended thinking content (Anthropic)
531
549
 
532
550
 
533
551
  class TraceSink(ABC):
@@ -90,6 +90,83 @@ class AnthropicClient(LLMClient):
90
90
 
91
91
  return os.environ.get("ANTHROPIC_API_KEY")
92
92
 
93
+ def _validate_tool_call_pairs(self, messages: list[Message]) -> list[Message]:
94
+ """
95
+ Validate and repair tool_use/tool_result pairing in message history.
96
+
97
+ Anthropic requires that every tool_use block has a corresponding tool_result
98
+ block immediately after. This can be violated if a run fails mid-way through
99
+ tool execution (e.g., timeout, crash, API error during parallel tool calls).
100
+
101
+ This method removes orphaned tool_use blocks (assistant messages with tool_calls
102
+ that don't have corresponding tool results).
103
+
104
+ Args:
105
+ messages: List of messages in framework-neutral format
106
+
107
+ Returns:
108
+ Cleaned list of messages with orphaned tool_use blocks removed
109
+ """
110
+ if not messages:
111
+ return messages
112
+
113
+ # First pass: collect all tool_call_ids that have results
114
+ tool_result_ids = set()
115
+ for msg in messages:
116
+ if msg.get("role") == "tool" and msg.get("tool_call_id"):
117
+ tool_result_ids.add(msg["tool_call_id"])
118
+
119
+ # Second pass: check each assistant message with tool_calls
120
+ cleaned_messages = []
121
+ orphaned_count = 0
122
+
123
+ for msg in messages:
124
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
125
+ # Check which tool_calls have results
126
+ tool_calls = msg.get("tool_calls", [])
127
+ valid_tool_calls = []
128
+ orphaned_ids = []
129
+
130
+ for tc in tool_calls:
131
+ # Handle both formats: {"id": ...} and {"function": {...}, "id": ...}
132
+ tc_id = tc.get("id")
133
+ if tc_id in tool_result_ids:
134
+ valid_tool_calls.append(tc)
135
+ else:
136
+ orphaned_ids.append(tc_id)
137
+
138
+ if orphaned_ids:
139
+ orphaned_count += len(orphaned_ids)
140
+ print(
141
+ f"[anthropic] Removing {len(orphaned_ids)} orphaned tool_use blocks "
142
+ f"without results: {orphaned_ids[:3]}{'...' if len(orphaned_ids) > 3 else ''}",
143
+ flush=True,
144
+ )
145
+
146
+ if valid_tool_calls:
147
+ # Keep the message but only with valid tool_calls
148
+ cleaned_msg = msg.copy()
149
+ cleaned_msg["tool_calls"] = valid_tool_calls
150
+ cleaned_messages.append(cleaned_msg)
151
+ elif msg.get("content"):
152
+ # No valid tool_calls but has text content - keep as regular message
153
+ cleaned_msg = {
154
+ "role": "assistant",
155
+ "content": msg["content"],
156
+ }
157
+ cleaned_messages.append(cleaned_msg)
158
+ # else: skip the message entirely (no valid tool_calls, no content)
159
+ else:
160
+ cleaned_messages.append(msg)
161
+
162
+ if orphaned_count > 0:
163
+ print(
164
+ f"[anthropic] Cleaned {orphaned_count} orphaned tool_use blocks from message history",
165
+ flush=True,
166
+ )
167
+
168
+ return cleaned_messages
169
+
93
170
  async def generate(
94
171
  self,
95
172
  messages: list[Message],
@@ -99,11 +176,32 @@ class AnthropicClient(LLMClient):
99
176
  tools: Optional[list[dict]] = None,
100
177
  temperature: Optional[float] = None,
101
178
  max_tokens: Optional[int] = None,
179
+ thinking: bool = False,
180
+ thinking_budget: Optional[int] = None,
102
181
  **kwargs,
103
182
  ) -> LLMResponse:
104
- """Generate a completion from Anthropic."""
183
+ """
184
+ Generate a completion from Anthropic.
185
+
186
+ Args:
187
+ messages: List of messages in framework-neutral format
188
+ model: Model ID to use (defaults to self.default_model)
189
+ stream: Whether to stream the response (not used here, use stream() method)
190
+ tools: List of tools in OpenAI format
191
+ temperature: Sampling temperature (0.0 to 1.0)
192
+ max_tokens: Maximum tokens to generate
193
+ thinking: Enable extended thinking mode for deeper reasoning
194
+ thinking_budget: Max tokens for thinking (default: 10000, max: 128000)
195
+ **kwargs: Additional parameters passed to the API
196
+
197
+ Returns:
198
+ LLMResponse with the generated message
199
+ """
105
200
  model = model or self.default_model
106
201
 
202
+ # Validate and repair message history before processing
203
+ messages = self._validate_tool_call_pairs(messages)
204
+
107
205
  # Extract system message and convert other messages
108
206
  system_message = None
109
207
  converted_messages = []
@@ -126,15 +224,28 @@ class AnthropicClient(LLMClient):
126
224
  request_kwargs["system"] = system_message
127
225
  if tools:
128
226
  request_kwargs["tools"] = self._convert_tools(tools)
129
- if temperature is not None:
227
+
228
+ # Handle extended thinking mode
229
+ if thinking:
230
+ # Extended thinking requires specific configuration
231
+ # Temperature must be 1.0 when using thinking
232
+ request_kwargs["thinking"] = {
233
+ "type": "enabled",
234
+ "budget_tokens": thinking_budget or 10000,
235
+ }
236
+ # Temperature must be exactly 1.0 for extended thinking
237
+ request_kwargs["temperature"] = 1.0
238
+ elif temperature is not None:
130
239
  request_kwargs["temperature"] = temperature
131
240
 
132
241
  request_kwargs.update(kwargs)
133
242
 
134
243
  response = await self._client.messages.create(**request_kwargs)
135
244
 
245
+ message, thinking_content = self._convert_response(response)
246
+
136
247
  return LLMResponse(
137
- message=self._convert_response(response),
248
+ message=message,
138
249
  usage={
139
250
  "prompt_tokens": response.usage.input_tokens,
140
251
  "completion_tokens": response.usage.output_tokens,
@@ -143,6 +254,7 @@ class AnthropicClient(LLMClient):
143
254
  model=response.model,
144
255
  finish_reason=response.stop_reason or "",
145
256
  raw_response=response,
257
+ thinking=thinking_content,
146
258
  )
147
259
 
148
260
  async def stream(
@@ -151,11 +263,31 @@ class AnthropicClient(LLMClient):
151
263
  *,
152
264
  model: Optional[str] = None,
153
265
  tools: Optional[list[dict]] = None,
266
+ thinking: bool = False,
267
+ thinking_budget: Optional[int] = None,
268
+ temperature: Optional[float] = None,
154
269
  **kwargs,
155
270
  ) -> AsyncIterator[LLMStreamChunk]:
156
- """Stream a completion from Anthropic."""
271
+ """
272
+ Stream a completion from Anthropic.
273
+
274
+ Args:
275
+ messages: List of messages in framework-neutral format
276
+ model: Model ID to use
277
+ tools: List of tools in OpenAI format
278
+ thinking: Enable extended thinking mode
279
+ thinking_budget: Max tokens for thinking (default: 10000)
280
+ temperature: Sampling temperature (ignored if thinking=True)
281
+ **kwargs: Additional parameters
282
+
283
+ Yields:
284
+ LLMStreamChunk with delta content and thinking content
285
+ """
157
286
  model = model or self.default_model
158
287
 
288
+ # Validate and repair message history before processing
289
+ messages = self._validate_tool_call_pairs(messages)
290
+
159
291
  # Extract system message and convert other messages
160
292
  system_message = None
161
293
  converted_messages = []
@@ -179,6 +311,16 @@ class AnthropicClient(LLMClient):
179
311
  if tools:
180
312
  request_kwargs["tools"] = self._convert_tools(tools)
181
313
 
314
+ # Handle extended thinking mode
315
+ if thinking:
316
+ request_kwargs["thinking"] = {
317
+ "type": "enabled",
318
+ "budget_tokens": thinking_budget or 10000,
319
+ }
320
+ request_kwargs["temperature"] = 1.0
321
+ elif temperature is not None:
322
+ request_kwargs["temperature"] = temperature
323
+
182
324
  request_kwargs.update(kwargs)
183
325
 
184
326
  async with self._client.messages.stream(**request_kwargs) as stream:
@@ -186,6 +328,9 @@ class AnthropicClient(LLMClient):
186
328
  if event.type == "content_block_delta":
187
329
  if hasattr(event.delta, "text"):
188
330
  yield LLMStreamChunk(delta=event.delta.text)
331
+ elif hasattr(event.delta, "thinking"):
332
+ # Extended thinking content
333
+ yield LLMStreamChunk(delta="", thinking=event.delta.thinking)
189
334
  elif event.type == "message_stop":
190
335
  yield LLMStreamChunk(finish_reason="stop")
191
336
 
@@ -327,14 +472,23 @@ class AnthropicClient(LLMClient):
327
472
  })
328
473
  return result
329
474
 
330
- def _convert_response(self, response) -> Message:
331
- """Convert Anthropic response to our format."""
475
+ def _convert_response(self, response) -> tuple[Message, Optional[str]]:
476
+ """
477
+ Convert Anthropic response to our format.
478
+
479
+ Returns:
480
+ Tuple of (message, thinking_content)
481
+ """
332
482
  content = ""
333
483
  tool_calls = []
484
+ thinking_content = None
334
485
 
335
486
  for block in response.content:
336
487
  if block.type == "text":
337
488
  content += block.text
489
+ elif block.type == "thinking":
490
+ # Extended thinking block
491
+ thinking_content = block.thinking
338
492
  elif block.type == "tool_use":
339
493
  # Convert input to JSON string (not Python str() which gives wrong format)
340
494
  arguments = json.dumps(block.input) if isinstance(block.input, dict) else str(block.input)
@@ -355,4 +509,4 @@ class AnthropicClient(LLMClient):
355
509
  if tool_calls:
356
510
  result["tool_calls"] = tool_calls
357
511
 
358
- return result
512
+ return result, thinking_content
@@ -22,19 +22,48 @@ class ModelInfo:
22
22
  supports_tools: bool = True
23
23
  supports_vision: bool = False
24
24
  supports_streaming: bool = True
25
+ supports_thinking: bool = False # Extended thinking (Anthropic) or reasoning (OpenAI)
25
26
  description: str = ""
26
27
 
27
28
 
28
29
  # Registry of supported models
29
30
  SUPPORTED_MODELS: dict[str, ModelInfo] = {
30
- # OpenAI Models
31
+ # OpenAI Models - GPT-5 (latest)
32
+ "gpt-5": ModelInfo(
33
+ id="gpt-5",
34
+ name="GPT-5",
35
+ provider="openai",
36
+ context_window=256000,
37
+ supports_vision=True,
38
+ supports_thinking=True,
39
+ description="Most capable OpenAI model with reasoning",
40
+ ),
41
+ "gpt-5-mini": ModelInfo(
42
+ id="gpt-5-mini",
43
+ name="GPT-5 Mini",
44
+ provider="openai",
45
+ context_window=256000,
46
+ supports_vision=True,
47
+ supports_thinking=True,
48
+ description="Fast GPT-5 variant with reasoning",
49
+ ),
50
+ "gpt-5-turbo": ModelInfo(
51
+ id="gpt-5-turbo",
52
+ name="GPT-5 Turbo",
53
+ provider="openai",
54
+ context_window=256000,
55
+ supports_vision=True,
56
+ supports_thinking=True,
57
+ description="Balanced GPT-5 variant for production",
58
+ ),
59
+ # OpenAI Models - GPT-4o
31
60
  "gpt-4o": ModelInfo(
32
61
  id="gpt-4o",
33
62
  name="GPT-4o",
34
63
  provider="openai",
35
64
  context_window=128000,
36
65
  supports_vision=True,
37
- description="Most capable OpenAI model, multimodal",
66
+ description="Most capable GPT-4 model, multimodal",
38
67
  ),
39
68
  "gpt-4o-mini": ModelInfo(
40
69
  id="gpt-4o-mini",
@@ -52,12 +81,14 @@ SUPPORTED_MODELS: dict[str, ModelInfo] = {
52
81
  supports_vision=True,
53
82
  description="Previous generation flagship",
54
83
  ),
84
+ # OpenAI Models - o-series (reasoning)
55
85
  "o1": ModelInfo(
56
86
  id="o1",
57
87
  name="o1",
58
88
  provider="openai",
59
89
  context_window=200000,
60
90
  supports_tools=False,
91
+ supports_thinking=True,
61
92
  description="Advanced reasoning model",
62
93
  ),
63
94
  "o1-mini": ModelInfo(
@@ -66,6 +97,7 @@ SUPPORTED_MODELS: dict[str, ModelInfo] = {
66
97
  provider="openai",
67
98
  context_window=128000,
68
99
  supports_tools=False,
100
+ supports_thinking=True,
69
101
  description="Fast reasoning model",
70
102
  ),
71
103
  "o3-mini": ModelInfo(
@@ -74,6 +106,7 @@ SUPPORTED_MODELS: dict[str, ModelInfo] = {
74
106
  provider="openai",
75
107
  context_window=200000,
76
108
  supports_tools=True,
109
+ supports_thinking=True,
77
110
  description="Latest reasoning model with tool use",
78
111
  ),
79
112
 
@@ -84,6 +117,7 @@ SUPPORTED_MODELS: dict[str, ModelInfo] = {
84
117
  provider="anthropic",
85
118
  context_window=200000,
86
119
  supports_vision=True,
120
+ supports_thinking=True,
87
121
  description="Best balance of speed and capability for agents and coding",
88
122
  ),
89
123
  "claude-opus-4-5-20251101": ModelInfo(
@@ -92,6 +126,7 @@ SUPPORTED_MODELS: dict[str, ModelInfo] = {
92
126
  provider="anthropic",
93
127
  context_window=200000,
94
128
  supports_vision=True,
129
+ supports_thinking=True,
95
130
  description="Premium model - maximum intelligence with practical performance",
96
131
  ),
97
132
  "claude-haiku-4-5-20251001": ModelInfo(
@@ -100,6 +135,7 @@ SUPPORTED_MODELS: dict[str, ModelInfo] = {
100
135
  provider="anthropic",
101
136
  context_window=200000,
102
137
  supports_vision=True,
138
+ supports_thinking=True,
103
139
  description="Fastest model with near-frontier intelligence",
104
140
  ),
105
141
  # Anthropic Models - Claude 4 (previous generation)
@@ -109,6 +145,7 @@ SUPPORTED_MODELS: dict[str, ModelInfo] = {
109
145
  provider="anthropic",
110
146
  context_window=200000,
111
147
  supports_vision=True,
148
+ supports_thinking=True,
112
149
  description="Previous generation Sonnet",
113
150
  ),
114
151
  "claude-opus-4-20250514": ModelInfo(
@@ -117,6 +154,7 @@ SUPPORTED_MODELS: dict[str, ModelInfo] = {
117
154
  provider="anthropic",
118
155
  context_window=200000,
119
156
  supports_vision=True,
157
+ supports_thinking=True,
120
158
  description="Previous generation Opus",
121
159
  ),
122
160
  # Anthropic Models - Claude 3.5 (legacy)
@@ -126,6 +164,7 @@ SUPPORTED_MODELS: dict[str, ModelInfo] = {
126
164
  provider="anthropic",
127
165
  context_window=200000,
128
166
  supports_vision=True,
167
+ supports_thinking=True,
129
168
  description="Legacy model, still excellent",
130
169
  ),
131
170
  "claude-3-5-haiku-20241022": ModelInfo(
@@ -134,6 +173,7 @@ SUPPORTED_MODELS: dict[str, ModelInfo] = {
134
173
  provider="anthropic",
135
174
  context_window=200000,
136
175
  supports_vision=True,
176
+ supports_thinking=True,
137
177
  description="Legacy fast model",
138
178
  ),
139
179
  }
@@ -150,19 +190,20 @@ def get_model_info(model_id: str) -> Optional[ModelInfo]:
150
190
  def get_provider_for_model(model_id: str) -> Optional[str]:
151
191
  """
152
192
  Detect the provider for a model ID.
153
-
193
+
154
194
  Returns "openai", "anthropic", or None if unknown.
155
195
  """
156
196
  # Check registry first
157
197
  if model_id in SUPPORTED_MODELS:
158
198
  return SUPPORTED_MODELS[model_id].provider
159
-
199
+
160
200
  # Fallback heuristics for unlisted models
161
- if model_id.startswith("gpt-") or model_id.startswith("o1") or model_id.startswith("o3"):
201
+ if (model_id.startswith("gpt-") or model_id.startswith("o1") or
202
+ model_id.startswith("o3") or model_id.startswith("gpt5")):
162
203
  return "openai"
163
204
  if model_id.startswith("claude"):
164
205
  return "anthropic"
165
-
206
+
166
207
  return None
167
208
 
168
209
 
@@ -174,6 +215,9 @@ def list_models_for_ui() -> list[dict]:
174
215
  "name": m.name,
175
216
  "provider": m.provider,
176
217
  "description": m.description,
218
+ "supports_thinking": m.supports_thinking,
219
+ "supports_tools": m.supports_tools,
220
+ "supports_vision": m.supports_vision,
177
221
  }
178
222
  for m in SUPPORTED_MODELS.values()
179
223
  ]