abstractcore 2.6.9__py3-none-any.whl → 2.9.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.
Files changed (46) hide show
  1. abstractcore/apps/summarizer.py +69 -27
  2. abstractcore/architectures/detection.py +190 -25
  3. abstractcore/assets/architecture_formats.json +129 -6
  4. abstractcore/assets/model_capabilities.json +803 -141
  5. abstractcore/config/main.py +2 -2
  6. abstractcore/config/manager.py +3 -1
  7. abstractcore/events/__init__.py +7 -1
  8. abstractcore/mcp/__init__.py +30 -0
  9. abstractcore/mcp/client.py +213 -0
  10. abstractcore/mcp/factory.py +64 -0
  11. abstractcore/mcp/naming.py +28 -0
  12. abstractcore/mcp/stdio_client.py +336 -0
  13. abstractcore/mcp/tool_source.py +164 -0
  14. abstractcore/processing/__init__.py +2 -2
  15. abstractcore/processing/basic_deepsearch.py +1 -1
  16. abstractcore/processing/basic_summarizer.py +379 -93
  17. abstractcore/providers/anthropic_provider.py +91 -10
  18. abstractcore/providers/base.py +540 -16
  19. abstractcore/providers/huggingface_provider.py +17 -8
  20. abstractcore/providers/lmstudio_provider.py +170 -25
  21. abstractcore/providers/mlx_provider.py +13 -10
  22. abstractcore/providers/ollama_provider.py +42 -26
  23. abstractcore/providers/openai_compatible_provider.py +87 -22
  24. abstractcore/providers/openai_provider.py +12 -9
  25. abstractcore/providers/streaming.py +201 -39
  26. abstractcore/providers/vllm_provider.py +78 -21
  27. abstractcore/server/app.py +116 -30
  28. abstractcore/structured/retry.py +20 -7
  29. abstractcore/tools/__init__.py +46 -24
  30. abstractcore/tools/abstractignore.py +166 -0
  31. abstractcore/tools/arg_canonicalizer.py +61 -0
  32. abstractcore/tools/common_tools.py +2443 -742
  33. abstractcore/tools/core.py +109 -13
  34. abstractcore/tools/handler.py +17 -3
  35. abstractcore/tools/parser.py +894 -159
  36. abstractcore/tools/registry.py +122 -18
  37. abstractcore/tools/syntax_rewriter.py +68 -6
  38. abstractcore/tools/tag_rewriter.py +186 -1
  39. abstractcore/utils/jsonish.py +111 -0
  40. abstractcore/utils/version.py +1 -1
  41. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/METADATA +55 -2
  42. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/RECORD +46 -37
  43. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/WHEEL +0 -0
  44. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/entry_points.txt +0 -0
  45. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/licenses/LICENSE +0 -0
  46. {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/top_level.txt +0 -0
@@ -91,7 +91,16 @@ class OpenAICompatibleProvider(BaseProvider):
91
91
  except Exception as e:
92
92
  # Fallback with default timeout if client creation fails
93
93
  try:
94
- self.client = httpx.Client(timeout=300.0)
94
+ fallback_timeout = None
95
+ try:
96
+ from ..config.manager import get_config_manager
97
+
98
+ fallback_timeout = float(get_config_manager().get_default_timeout())
99
+ except Exception:
100
+ fallback_timeout = 7200.0
101
+ if isinstance(fallback_timeout, (int, float)) and float(fallback_timeout) <= 0:
102
+ fallback_timeout = None
103
+ self.client = httpx.Client(timeout=fallback_timeout)
95
104
  except Exception:
96
105
  raise RuntimeError(f"Failed to create HTTP client for OpenAI-compatible provider: {e}")
97
106
 
@@ -193,19 +202,24 @@ class OpenAICompatibleProvider(BaseProvider):
193
202
  chat_messages = []
194
203
 
195
204
  # Add tools to system prompt if provided
196
- enhanced_system_prompt = system_prompt
197
- if tools and self.tool_handler.supports_prompted:
198
- tool_prompt = self.tool_handler.format_tools_prompt(tools)
199
- if enhanced_system_prompt:
200
- enhanced_system_prompt += f"\n\n{tool_prompt}"
205
+ final_system_prompt = system_prompt
206
+ # Prefer native tools when the model supports them. Only inject a prompted tool list
207
+ # when native tool calling is not available.
208
+ if tools and self.tool_handler.supports_prompted and not self.tool_handler.supports_native:
209
+ include_tool_list = True
210
+ if final_system_prompt and "## Tools (session)" in final_system_prompt:
211
+ include_tool_list = False
212
+ tool_prompt = self.tool_handler.format_tools_prompt(tools, include_tool_list=include_tool_list)
213
+ if final_system_prompt:
214
+ final_system_prompt += f"\n\n{tool_prompt}"
201
215
  else:
202
- enhanced_system_prompt = tool_prompt
216
+ final_system_prompt = tool_prompt
203
217
 
204
218
  # Add system message if provided
205
- if enhanced_system_prompt:
219
+ if final_system_prompt:
206
220
  chat_messages.append({
207
221
  "role": "system",
208
- "content": enhanced_system_prompt
222
+ "content": final_system_prompt
209
223
  })
210
224
 
211
225
  # Add conversation history
@@ -283,6 +297,11 @@ class OpenAICompatibleProvider(BaseProvider):
283
297
  "top_p": kwargs.get("top_p", 0.9),
284
298
  }
285
299
 
300
+ # Native tools (OpenAI-compatible): send structured tools/tool_choice when supported.
301
+ if tools and self.tool_handler.supports_native:
302
+ payload["tools"] = self.tool_handler.prepare_tools_for_native(tools)
303
+ payload["tool_choice"] = kwargs.get("tool_choice", "auto")
304
+
286
305
  # Add additional generation parameters if provided (OpenAI-compatible)
287
306
  if "frequency_penalty" in kwargs:
288
307
  payload["frequency_penalty"] = kwargs["frequency_penalty"]
@@ -330,8 +349,9 @@ class OpenAICompatibleProvider(BaseProvider):
330
349
 
331
350
  # Track generation time
332
351
  start_time = time.time()
352
+ request_url = f"{self.base_url}/chat/completions"
333
353
  response = self.client.post(
334
- f"{self.base_url}/chat/completions",
354
+ request_url,
335
355
  json=payload,
336
356
  headers=self._get_headers()
337
357
  )
@@ -343,10 +363,19 @@ class OpenAICompatibleProvider(BaseProvider):
343
363
  # Extract response from OpenAI format
344
364
  if "choices" in result and len(result["choices"]) > 0:
345
365
  choice = result["choices"][0]
346
- content = choice.get("message", {}).get("content", "")
366
+ message = choice.get("message") or {}
367
+ if not isinstance(message, dict):
368
+ message = {}
369
+
370
+ content = message.get("content", "")
371
+ tool_calls = message.get("tool_calls")
372
+ if tool_calls is None:
373
+ # Some servers surface tool calls at the choice level.
374
+ tool_calls = choice.get("tool_calls")
347
375
  finish_reason = choice.get("finish_reason", "stop")
348
376
  else:
349
377
  content = "No response generated"
378
+ tool_calls = None
350
379
  finish_reason = "error"
351
380
 
352
381
  # Extract usage info
@@ -357,6 +386,13 @@ class OpenAICompatibleProvider(BaseProvider):
357
386
  model=self.model,
358
387
  finish_reason=finish_reason,
359
388
  raw_response=result,
389
+ tool_calls=tool_calls if isinstance(tool_calls, list) else None,
390
+ metadata={
391
+ "_provider_request": {
392
+ "url": request_url,
393
+ "payload": payload,
394
+ }
395
+ },
360
396
  usage={
361
397
  "input_tokens": usage.get("prompt_tokens", 0),
362
398
  "output_tokens": usage.get("completion_tokens", 0),
@@ -386,7 +422,7 @@ class OpenAICompatibleProvider(BaseProvider):
386
422
  # If model discovery also fails, provide a generic error
387
423
  raise ModelNotFoundError(f"Model '{self.model}' not found on OpenAI-compatible server and could not fetch available models")
388
424
  else:
389
- raise ProviderAPIError(f"OpenAI-compatible server API error: {str(e)}")
425
+ raise
390
426
 
391
427
  def _stream_generate(self, payload: Dict[str, Any]) -> Iterator[GenerateResponse]:
392
428
  """Generate streaming response"""
@@ -418,13 +454,17 @@ class OpenAICompatibleProvider(BaseProvider):
418
454
  if "choices" in chunk and len(chunk["choices"]) > 0:
419
455
  choice = chunk["choices"][0]
420
456
  delta = choice.get("delta", {})
457
+ if not isinstance(delta, dict):
458
+ delta = {}
421
459
  content = delta.get("content", "")
460
+ tool_calls = delta.get("tool_calls") or choice.get("tool_calls")
422
461
  finish_reason = choice.get("finish_reason")
423
462
 
424
463
  yield GenerateResponse(
425
464
  content=content,
426
465
  model=self.model,
427
466
  finish_reason=finish_reason,
467
+ tool_calls=tool_calls if isinstance(tool_calls, list) else None,
428
468
  raw_response=chunk
429
469
  )
430
470
 
@@ -455,19 +495,23 @@ class OpenAICompatibleProvider(BaseProvider):
455
495
  chat_messages = []
456
496
 
457
497
  # Add tools to system prompt if provided
458
- enhanced_system_prompt = system_prompt
459
- if tools and self.tool_handler.supports_prompted:
460
- tool_prompt = self.tool_handler.format_tools_prompt(tools)
461
- if enhanced_system_prompt:
462
- enhanced_system_prompt += f"\n\n{tool_prompt}"
498
+ final_system_prompt = system_prompt
499
+ # Prefer native tools when available; only inject prompted tool syntax as fallback.
500
+ if tools and self.tool_handler.supports_prompted and not self.tool_handler.supports_native:
501
+ include_tool_list = True
502
+ if final_system_prompt and "## Tools (session)" in final_system_prompt:
503
+ include_tool_list = False
504
+ tool_prompt = self.tool_handler.format_tools_prompt(tools, include_tool_list=include_tool_list)
505
+ if final_system_prompt:
506
+ final_system_prompt += f"\n\n{tool_prompt}"
463
507
  else:
464
- enhanced_system_prompt = tool_prompt
508
+ final_system_prompt = tool_prompt
465
509
 
466
510
  # Add system message if provided
467
- if enhanced_system_prompt:
511
+ if final_system_prompt:
468
512
  chat_messages.append({
469
513
  "role": "system",
470
- "content": enhanced_system_prompt
514
+ "content": final_system_prompt
471
515
  })
472
516
 
473
517
  # Add conversation history
@@ -523,6 +567,11 @@ class OpenAICompatibleProvider(BaseProvider):
523
567
  "top_p": kwargs.get("top_p", 0.9),
524
568
  }
525
569
 
570
+ # Native tools (OpenAI-compatible): send structured tools/tool_choice when supported.
571
+ if tools and self.tool_handler.supports_native:
572
+ payload["tools"] = self.tool_handler.prepare_tools_for_native(tools)
573
+ payload["tool_choice"] = kwargs.get("tool_choice", "auto")
574
+
526
575
  # Add additional parameters
527
576
  if "frequency_penalty" in kwargs:
528
577
  payload["frequency_penalty"] = kwargs["frequency_penalty"]
@@ -563,8 +612,9 @@ class OpenAICompatibleProvider(BaseProvider):
563
612
  try:
564
613
  # Track generation time
565
614
  start_time = time.time()
615
+ request_url = f"{self.base_url}/chat/completions"
566
616
  response = await self.async_client.post(
567
- f"{self.base_url}/chat/completions",
617
+ request_url,
568
618
  json=payload,
569
619
  headers=self._get_headers()
570
620
  )
@@ -590,6 +640,12 @@ class OpenAICompatibleProvider(BaseProvider):
590
640
  model=self.model,
591
641
  finish_reason=finish_reason,
592
642
  raw_response=result,
643
+ metadata={
644
+ "_provider_request": {
645
+ "url": request_url,
646
+ "payload": payload,
647
+ }
648
+ },
593
649
  usage={
594
650
  "input_tokens": usage.get("prompt_tokens", 0),
595
651
  "output_tokens": usage.get("completion_tokens", 0),
@@ -696,7 +752,16 @@ class OpenAICompatibleProvider(BaseProvider):
696
752
  self.logger.warning(f"Failed to update HTTP client timeout: {e}")
697
753
  # Try to create a new client with default timeout
698
754
  try:
699
- self.client = httpx.Client(timeout=300.0)
755
+ fallback_timeout = None
756
+ try:
757
+ from ..config.manager import get_config_manager
758
+
759
+ fallback_timeout = float(get_config_manager().get_default_timeout())
760
+ except Exception:
761
+ fallback_timeout = 7200.0
762
+ if isinstance(fallback_timeout, (int, float)) and float(fallback_timeout) <= 0:
763
+ fallback_timeout = None
764
+ self.client = httpx.Client(timeout=fallback_timeout)
700
765
  except Exception:
701
766
  pass # Best effort - don't fail the operation
702
767
 
@@ -196,15 +196,18 @@ class OpenAIProvider(BaseProvider):
196
196
  formatted = self._format_response(response)
197
197
  # Add generation time to response
198
198
  formatted.gen_time = gen_time
199
+ # Runtime observability: capture the exact client payload we sent.
200
+ formatted.metadata = dict(formatted.metadata or {})
201
+ formatted.metadata["_provider_request"] = {"call_params": call_params}
199
202
 
200
203
  # Handle tool execution for OpenAI native responses
201
204
  if tools and formatted.has_tool_calls():
202
205
  formatted = self._handle_tool_execution(formatted, tools)
203
206
 
204
207
  return formatted
205
- except Exception as e:
206
- # Model validation is done at initialization, so this is likely an API error
207
- raise ProviderAPIError(f"OpenAI API error: {str(e)}")
208
+ except Exception:
209
+ # Let BaseProvider normalize (timeouts/auth/rate limits) consistently.
210
+ raise
208
211
 
209
212
  async def _agenerate_internal(self,
210
213
  prompt: str,
@@ -324,23 +327,23 @@ class OpenAIProvider(BaseProvider):
324
327
  formatted = self._format_response(response)
325
328
  # Add generation time to response
326
329
  formatted.gen_time = gen_time
330
+ formatted.metadata = dict(formatted.metadata or {})
331
+ formatted.metadata["_provider_request"] = {"call_params": call_params}
327
332
 
328
333
  # Handle tool execution for OpenAI native responses
329
334
  if tools and formatted.has_tool_calls():
330
335
  formatted = self._handle_tool_execution(formatted, tools)
331
336
 
332
337
  return formatted
333
- except Exception as e:
334
- # Model validation is done at initialization, so this is likely an API error
335
- raise ProviderAPIError(f"OpenAI API error: {str(e)}")
338
+ except Exception:
339
+ raise
336
340
 
337
341
  async def _async_stream_response(self, call_params: Dict[str, Any], tools: Optional[List[Dict[str, Any]]] = None) -> AsyncIterator[GenerateResponse]:
338
342
  """Native async streaming responses from OpenAI."""
339
343
  try:
340
344
  stream = await self.async_client.chat.completions.create(**call_params)
341
- except Exception as e:
342
- # Model validation is done at initialization, so this is likely an API error
343
- raise ProviderAPIError(f"OpenAI API error: {str(e)}")
345
+ except Exception:
346
+ raise
344
347
 
345
348
  # For streaming with tools, we need to collect the complete response
346
349
  collected_content = ""
@@ -13,6 +13,7 @@ from enum import Enum
13
13
 
14
14
  from ..core.types import GenerateResponse
15
15
  from ..tools.core import ToolCall
16
+ from ..utils.jsonish import loads_dict_like
16
17
  from ..utils.structured_logging import get_logger
17
18
 
18
19
  logger = get_logger(__name__)
@@ -56,6 +57,12 @@ class IncrementalToolDetector:
56
57
  'start': r'<\|tool_call\|>',
57
58
  'end': r'</\|tool_call\|>',
58
59
  },
60
+ # Harmony/ChatML-style tool transcript (no explicit closing tag; ends at end of JSON after <|message|>).
61
+ 'harmony': {
62
+ 'start': r'<\|channel\|>',
63
+ 'end': None,
64
+ 'kind': 'harmony',
65
+ },
59
66
  'llama': {
60
67
  'start': r'<function_call>',
61
68
  'end': r'</function_call>',
@@ -86,18 +93,42 @@ class IncrementalToolDetector:
86
93
  if not model_name:
87
94
  return list(self.patterns.values())
88
95
 
89
- model_lower = model_name.lower()
96
+ # Centralized model capability/syntax lookup.
97
+ # Use `architecture_formats.json` as the source of truth instead of string heuristics.
98
+ try:
99
+ from ..architectures import detect_architecture, get_architecture_format
90
100
 
91
- if 'gemma' in model_lower:
92
- return [self.patterns['gemma']]
93
- elif 'llama' in model_lower:
94
- return [self.patterns['llama'], self.patterns['xml']]
95
- else:
96
- return [
97
- self.patterns['qwen'],
98
- self.patterns['llama'],
99
- self.patterns['xml']
100
- ]
101
+ architecture = detect_architecture(model_name)
102
+ arch_format = get_architecture_format(architecture)
103
+ tool_format = str(arch_format.get("tool_format", "") or "").strip().lower()
104
+ message_format = str(arch_format.get("message_format", "") or "").strip().lower()
105
+ except Exception:
106
+ tool_format = ""
107
+ message_format = ""
108
+ architecture = "generic"
109
+
110
+ # Harmony/ChatML tool transcripts (GPT-OSS).
111
+ if message_format == "harmony":
112
+ return [self.patterns["harmony"]]
113
+
114
+ # Pythonic tool blocks (Gemma-style).
115
+ if tool_format == "pythonic":
116
+ return [self.patterns["gemma"]]
117
+
118
+ # Special-token tools (Qwen-style). Some "prompted" models share this convention.
119
+ if tool_format == "special_token" or (tool_format == "prompted" and message_format == "im_start_end"):
120
+ return [self.patterns["qwen"], self.patterns["llama"], self.patterns["xml"]]
121
+
122
+ # XML-wrapped tools.
123
+ if tool_format == "xml":
124
+ return [self.patterns["xml"], self.patterns["llama"], self.patterns["qwen"]]
125
+
126
+ # LLaMA-style prompted tools.
127
+ if tool_format == "prompted" and "llama" in str(architecture):
128
+ return [self.patterns["llama"], self.patterns["xml"]]
129
+
130
+ # Default: try the common tag-based formats as fallbacks.
131
+ return [self.patterns["qwen"], self.patterns["llama"], self.patterns["xml"]]
101
132
 
102
133
  def process_chunk(self, chunk_content: str) -> Tuple[str, List[ToolCall]]:
103
134
  """
@@ -181,6 +212,10 @@ class IncrementalToolDetector:
181
212
  # Add new content to tool content
182
213
  self.current_tool_content += chunk_content
183
214
 
215
+ # Harmony/ChatML tool transcript: detect completion by balanced JSON after <|message|>.
216
+ if self.current_pattern and self.current_pattern.get('kind') == 'harmony':
217
+ return self._collect_harmony_tool_content()
218
+
184
219
  # Check for tool end pattern
185
220
  end_pattern = self.current_pattern['end']
186
221
  end_match = re.search(end_pattern, self.current_tool_content, re.IGNORECASE)
@@ -217,6 +252,107 @@ class IncrementalToolDetector:
217
252
 
218
253
  return streamable_content, completed_tools
219
254
 
255
+ def _collect_harmony_tool_content(self) -> Tuple[str, List[ToolCall]]:
256
+ """Collect and parse a Harmony/ChatML tool transcript block."""
257
+ streamable_content = ""
258
+ completed_tools: List[ToolCall] = []
259
+
260
+ msg_tag = "<|message|>"
261
+ msg_idx = self.current_tool_content.find(msg_tag)
262
+ if msg_idx == -1:
263
+ return streamable_content, completed_tools
264
+
265
+ # Extract tool name from the header (between <|channel|> and <|message|>).
266
+ header = self.current_tool_content[:msg_idx]
267
+ name_match = re.search(r"\bto=([a-zA-Z0-9_\-\.]+)\b", header)
268
+ if not name_match:
269
+ return streamable_content, completed_tools
270
+ tool_name = str(name_match.group(1) or "").strip()
271
+ if tool_name.startswith("functions."):
272
+ tool_name = tool_name.split(".", 1)[1].strip()
273
+ if not tool_name:
274
+ return streamable_content, completed_tools
275
+
276
+ brace_start = self.current_tool_content.find("{", msg_idx + len(msg_tag))
277
+ if brace_start == -1:
278
+ return streamable_content, completed_tools
279
+ between = self.current_tool_content[msg_idx + len(msg_tag) : brace_start]
280
+ if between and any(not c.isspace() for c in between):
281
+ return streamable_content, completed_tools
282
+
283
+ def _find_matching_brace(text: str, start: int) -> int:
284
+ depth = 0
285
+ in_string = False
286
+ quote = ""
287
+ escaped = False
288
+ for i in range(start, len(text)):
289
+ ch = text[i]
290
+ if in_string:
291
+ if escaped:
292
+ escaped = False
293
+ continue
294
+ if ch == "\\":
295
+ escaped = True
296
+ continue
297
+ if ch == quote:
298
+ in_string = False
299
+ quote = ""
300
+ continue
301
+ if ch in ("'", '"'):
302
+ in_string = True
303
+ quote = ch
304
+ continue
305
+ if ch == "{":
306
+ depth += 1
307
+ continue
308
+ if ch == "}":
309
+ depth -= 1
310
+ if depth == 0:
311
+ return i
312
+ return -1
313
+
314
+ brace_end = _find_matching_brace(self.current_tool_content, brace_start)
315
+ if brace_end == -1:
316
+ return streamable_content, completed_tools
317
+
318
+ raw_args = self.current_tool_content[brace_start : brace_end + 1]
319
+ args: Dict[str, Any] = {}
320
+ call_id: Optional[str] = None
321
+ loaded = loads_dict_like(raw_args)
322
+ if isinstance(loaded, dict):
323
+ # Some models emit a wrapper payload:
324
+ # {"name":"tool","arguments":{...},"call_id": "..."}
325
+ inner_args = loaded.get("arguments")
326
+ if isinstance(inner_args, dict):
327
+ args = inner_args
328
+ elif isinstance(inner_args, str):
329
+ parsed_inner = loads_dict_like(inner_args)
330
+ args = parsed_inner if isinstance(parsed_inner, dict) else loaded
331
+ else:
332
+ args = loaded
333
+
334
+ call_id_value = loaded.get("call_id") or loaded.get("id")
335
+ if isinstance(call_id_value, str) and call_id_value.strip():
336
+ call_id = call_id_value.strip()
337
+
338
+ completed_tools.append(ToolCall(name=tool_name, arguments=args, call_id=call_id))
339
+
340
+ if self.rewrite_tags:
341
+ # When rewriting, stream the full accumulated content (including the tool transcript).
342
+ streamable_content = self.accumulated_content
343
+ self.accumulated_content = ""
344
+
345
+ remaining_content = self.current_tool_content[brace_end + 1 :]
346
+ self.reset()
347
+
348
+ if remaining_content:
349
+ self.accumulated_content = remaining_content
350
+ additional_streamable, additional_tools = self._scan_for_tool_start("")
351
+ streamable_content += additional_streamable
352
+ completed_tools.extend(additional_tools)
353
+
354
+ return streamable_content, completed_tools
355
+
220
356
  def _might_have_partial_tool_call(self) -> bool:
221
357
  """Check if accumulated content might contain start of a tool call."""
222
358
  # Check for partial tool tags more aggressively to handle character-by-character streaming
@@ -227,7 +363,9 @@ class IncrementalToolDetector:
227
363
  '<', '<|', '<f', '</', '<t', '`', '``',
228
364
  '<fu', '<fun', '<func', '<funct', '<functi', '<functio', '<function', # <function_call>
229
365
  '<tool', '<tool_', '<tool_c', '<tool_ca', '<tool_cal', # <tool_call>
230
- '<|t', '<|to', '<|too', '<|tool', '<|tool_', '<|tool_c' # <|tool_call|>
366
+ '<|t', '<|to', '<|too', '<|tool', '<|tool_', '<|tool_c', # <|tool_call|>
367
+ '<|c', '<|ch', '<|cha', '<|chan', '<|chann', '<|channe', '<|channel', # <|channel|>
368
+ '<|m', '<|me', '<|mes', '<|mess', '<|messa', '<|messag', '<|message', # <|message|>
231
369
  ]
232
370
 
233
371
  # Check if tail ends with any potential partial start
@@ -239,6 +377,8 @@ class IncrementalToolDetector:
239
377
  for pattern_partial in ['<function', '<tool_call', '<|tool', '```tool']:
240
378
  if pattern_partial in tail:
241
379
  return True
380
+ if '<|channel' in tail or '<|message' in tail:
381
+ return True
242
382
 
243
383
  # Check if we have an incomplete tool call (start tag but no end tag)
244
384
  for pattern_info in self.active_patterns:
@@ -347,16 +487,17 @@ class UnifiedStreamProcessor:
347
487
  """
348
488
 
349
489
  def __init__(self, model_name: str, execute_tools: bool = False,
350
- tool_call_tags: Optional[str] = None,
490
+ tool_call_tags: Optional[object] = None,
351
491
  default_target_format: str = "qwen3"):
352
492
  """Initialize the stream processor."""
353
493
  self.model_name = model_name
354
- # Note: execute_tools parameter is kept for backward compatibility but ignored
355
- # Tool execution is now handled by the client (CLI)
494
+ # Note: execute_tools is kept for backward compatibility and introspection,
495
+ # but tool execution is handled by the client/runtime (AbstractRuntime).
496
+ self.execute_tools = execute_tools
356
497
  self.tool_call_tags = tool_call_tags
357
498
  self.default_target_format = default_target_format
358
499
 
359
- # Always initialize tag rewriter - either custom tags or default target format
500
+ # Initialize tag rewriter only when explicit format conversion is requested.
360
501
  self.tag_rewriter = None
361
502
  # Backwards compatibility: tag_rewrite_buffer attribute (unused in current implementation)
362
503
  self.tag_rewrite_buffer = ""
@@ -364,31 +505,35 @@ class UnifiedStreamProcessor:
364
505
  # Flag to indicate if we're converting to OpenAI JSON format (not text rewriting)
365
506
  self.convert_to_openai_json = False
366
507
 
367
- # Determine whether tool_call_tags contains predefined format or custom tags
508
+ # Determine whether tool_call_tags contains predefined format or custom tags.
368
509
  if tool_call_tags:
369
- # Check if tool_call_tags is a predefined format name
370
- predefined_formats = ["qwen3", "openai", "llama3", "xml", "gemma"]
371
-
372
- if tool_call_tags in predefined_formats:
373
- # It's a predefined format - use default rewriter
374
- self._initialize_default_rewriter(tool_call_tags)
375
- logger.debug(f"Treating tool_call_tags '{tool_call_tags}' as predefined format")
376
- elif ',' in tool_call_tags:
377
- # It contains comma - likely custom tags like "START,END"
510
+ # Accept pre-built tag rewriters/config objects (used by some internal callers/tests).
511
+ if not isinstance(tool_call_tags, str):
378
512
  self._initialize_tag_rewriter(tool_call_tags)
379
- logger.debug(f"Treating tool_call_tags '{tool_call_tags}' as custom comma-separated tags")
380
513
  else:
381
- # Single string that's not a predefined format - could be custom single tag
382
- # Try as custom first, fall back to treating as predefined format
383
- try:
384
- self._initialize_tag_rewriter(tool_call_tags)
385
- logger.debug(f"Treating tool_call_tags '{tool_call_tags}' as custom single tag")
386
- except Exception as e:
387
- logger.debug(f"Failed to initialize as custom tag, trying as predefined format: {e}")
514
+ # Check if tool_call_tags is a predefined format name
515
+ predefined_formats = ["qwen3", "openai", "llama3", "xml", "gemma"]
516
+
517
+ if tool_call_tags in predefined_formats:
518
+ # It's a predefined format - use default rewriter
388
519
  self._initialize_default_rewriter(tool_call_tags)
520
+ logger.debug(f"Treating tool_call_tags '{tool_call_tags}' as predefined format")
521
+ elif ',' in tool_call_tags:
522
+ # It contains comma - likely custom tags like "START,END"
523
+ self._initialize_tag_rewriter(tool_call_tags)
524
+ logger.debug(f"Treating tool_call_tags '{tool_call_tags}' as custom comma-separated tags")
525
+ else:
526
+ # Single string that's not a predefined format - could be custom single tag
527
+ # Try as custom first, fall back to treating as predefined format
528
+ try:
529
+ self._initialize_tag_rewriter(tool_call_tags)
530
+ logger.debug(f"Treating tool_call_tags '{tool_call_tags}' as custom single tag")
531
+ except Exception as e:
532
+ logger.debug(f"Failed to initialize as custom tag, trying as predefined format: {e}")
533
+ self._initialize_default_rewriter(tool_call_tags)
389
534
  else:
390
- # No custom tags - initialize default rewriter to target format
391
- self._initialize_default_rewriter(default_target_format)
535
+ # No explicit format conversion requested - no text rewriting.
536
+ self.tag_rewriter = None
392
537
 
393
538
  # Create detector - preserve tool calls when user explicitly wants format conversion
394
539
  # Filter out tool calls for clean UX when no explicit format conversion is requested
@@ -447,9 +592,18 @@ class UnifiedStreamProcessor:
447
592
  # Yield tool calls for server processing
448
593
  if completed_tools:
449
594
  logger.debug(f"Detected {len(completed_tools)} tools - yielding for server processing")
595
+ tool_payload = [
596
+ {
597
+ "name": tc.name,
598
+ "arguments": tc.arguments,
599
+ "call_id": tc.call_id,
600
+ }
601
+ for tc in completed_tools
602
+ if getattr(tc, "name", None)
603
+ ]
450
604
  yield GenerateResponse(
451
605
  content="",
452
- tool_calls=completed_tools,
606
+ tool_calls=tool_payload,
453
607
  model=chunk.model,
454
608
  finish_reason=chunk.finish_reason,
455
609
  usage=chunk.usage,
@@ -477,9 +631,18 @@ class UnifiedStreamProcessor:
477
631
 
478
632
  if final_tools:
479
633
  logger.debug(f"Finalized {len(final_tools)} tools - yielding for server processing")
634
+ tool_payload = [
635
+ {
636
+ "name": tc.name,
637
+ "arguments": tc.arguments,
638
+ "call_id": tc.call_id,
639
+ }
640
+ for tc in final_tools
641
+ if getattr(tc, "name", None)
642
+ ]
480
643
  yield GenerateResponse(
481
644
  content="",
482
- tool_calls=final_tools,
645
+ tool_calls=tool_payload,
483
646
  model=self.model_name,
484
647
  finish_reason="tool_calls"
485
648
  )
@@ -705,4 +868,3 @@ class UnifiedStreamProcessor:
705
868
  break
706
869
 
707
870
  return converted_content
708
-