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.
- abstractcore/apps/summarizer.py +69 -27
- abstractcore/architectures/detection.py +190 -25
- abstractcore/assets/architecture_formats.json +129 -6
- abstractcore/assets/model_capabilities.json +803 -141
- abstractcore/config/main.py +2 -2
- abstractcore/config/manager.py +3 -1
- abstractcore/events/__init__.py +7 -1
- abstractcore/mcp/__init__.py +30 -0
- abstractcore/mcp/client.py +213 -0
- abstractcore/mcp/factory.py +64 -0
- abstractcore/mcp/naming.py +28 -0
- abstractcore/mcp/stdio_client.py +336 -0
- abstractcore/mcp/tool_source.py +164 -0
- abstractcore/processing/__init__.py +2 -2
- abstractcore/processing/basic_deepsearch.py +1 -1
- abstractcore/processing/basic_summarizer.py +379 -93
- abstractcore/providers/anthropic_provider.py +91 -10
- abstractcore/providers/base.py +540 -16
- abstractcore/providers/huggingface_provider.py +17 -8
- abstractcore/providers/lmstudio_provider.py +170 -25
- abstractcore/providers/mlx_provider.py +13 -10
- abstractcore/providers/ollama_provider.py +42 -26
- abstractcore/providers/openai_compatible_provider.py +87 -22
- abstractcore/providers/openai_provider.py +12 -9
- abstractcore/providers/streaming.py +201 -39
- abstractcore/providers/vllm_provider.py +78 -21
- abstractcore/server/app.py +116 -30
- abstractcore/structured/retry.py +20 -7
- abstractcore/tools/__init__.py +46 -24
- abstractcore/tools/abstractignore.py +166 -0
- abstractcore/tools/arg_canonicalizer.py +61 -0
- abstractcore/tools/common_tools.py +2443 -742
- abstractcore/tools/core.py +109 -13
- abstractcore/tools/handler.py +17 -3
- abstractcore/tools/parser.py +894 -159
- abstractcore/tools/registry.py +122 -18
- abstractcore/tools/syntax_rewriter.py +68 -6
- abstractcore/tools/tag_rewriter.py +186 -1
- abstractcore/utils/jsonish.py +111 -0
- abstractcore/utils/version.py +1 -1
- {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/METADATA +55 -2
- {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/RECORD +46 -37
- {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/WHEEL +0 -0
- {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/entry_points.txt +0 -0
- {abstractcore-2.6.9.dist-info → abstractcore-2.9.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
216
|
+
final_system_prompt = tool_prompt
|
|
203
217
|
|
|
204
218
|
# Add system message if provided
|
|
205
|
-
if
|
|
219
|
+
if final_system_prompt:
|
|
206
220
|
chat_messages.append({
|
|
207
221
|
"role": "system",
|
|
208
|
-
"content":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
508
|
+
final_system_prompt = tool_prompt
|
|
465
509
|
|
|
466
510
|
# Add system message if provided
|
|
467
|
-
if
|
|
511
|
+
if final_system_prompt:
|
|
468
512
|
chat_messages.append({
|
|
469
513
|
"role": "system",
|
|
470
|
-
"content":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
#
|
|
207
|
-
raise
|
|
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
|
|
334
|
-
|
|
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
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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[
|
|
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
|
|
355
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
370
|
-
|
|
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
|
-
#
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
391
|
-
self.
|
|
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=
|
|
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=
|
|
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
|
-
|