|
7 | 7 | from datetime import timedelta |
8 | 8 | from pathlib import Path |
9 | 9 | from typing import Callable, Optional, Sequence, Union |
| 10 | +from collections import defaultdict |
10 | 11 | import json |
11 | 12 | import aiohttp |
12 | 13 | import mimeparse |
@@ -129,6 +130,11 @@ def get_content_from_message(message: dict) -> Optional[str]: |
129 | 130 | return None |
130 | 131 |
|
131 | 132 |
|
| 133 | +def output_id(prefix: str) -> str: |
| 134 | + """Generate OR-style ID: prefix + 24-char hex UUID.""" |
| 135 | + return f'{prefix}_{uuid.uuid4().hex[:24]}' |
| 136 | + |
| 137 | + |
132 | 138 | def convert_output_to_messages(output: list, raw: bool = False) -> list[dict]: |
133 | 139 | """ |
134 | 140 | Convert OR-aligned output items to OpenAI Chat Completion-format messages. |
@@ -300,6 +306,181 @@ def flush_pending(): |
300 | 306 | return messages |
301 | 307 |
|
302 | 308 |
|
| 309 | +# --------------------------------------------------------------------------- |
| 310 | +# Assistant output reconciliation |
| 311 | +# --------------------------------------------------------------------------- |
| 312 | + |
| 313 | +# Mapping from <details type="..."> values to structured output item types. |
| 314 | +DETAIL_TYPE_TO_OUTPUT_TYPES: dict[str, tuple[str, ...]] = { |
| 315 | + 'tool_calls': ('function_call', 'function_call_output'), |
| 316 | + 'reasoning': ('reasoning',), |
| 317 | + 'code_interpreter': ('open_webui:code_interpreter',), |
| 318 | +} |
| 319 | + |
| 320 | +# Regex to split content into alternating text / <details> segments. |
| 321 | +_DETAILS_SPLIT_RE = re.compile(r'(<details\b[^>]*>[\s\S]*?</details>)', re.IGNORECASE) |
| 322 | + |
| 323 | +# Regexes to extract type and id attributes from a <details> opening tag. |
| 324 | +# Using two independent patterns so attribute order does not matter. |
| 325 | +_DETAIL_TYPE_RE = re.compile(r'\btype=["\']([^"\']*)["\']', re.IGNORECASE) |
| 326 | +_DETAIL_ID_RE = re.compile(r'\bid=["\']([^"\']*)["\']', re.IGNORECASE) |
| 327 | + |
| 328 | + |
| 329 | +def _parse_content_segments(content: str) -> tuple[list[tuple[str, Optional[str], Optional[str]]], list[str]]: |
| 330 | + """Parse content into ordered segments of text and <details> blocks.""" |
| 331 | + parts = _DETAILS_SPLIT_RE.split(content) |
| 332 | + segments = [] |
| 333 | + for part in parts: |
| 334 | + if part and part.lstrip().startswith('<details'): |
| 335 | + # Search only the opening tag to avoid false positives from |
| 336 | + # id= or type= appearing inside code/body content. |
| 337 | + opening = re.match(r'<details\b[^>]*>', part.lstrip()) |
| 338 | + tag = opening.group(0) if opening else '' |
| 339 | + tm = _DETAIL_TYPE_RE.search(tag) |
| 340 | + im = _DETAIL_ID_RE.search(tag) |
| 341 | + segments.append(('details', tm.group(1) if tm else '', im.group(1) if im else '')) |
| 342 | + else: |
| 343 | + segments.append(('text', None, None)) |
| 344 | + return segments, parts |
| 345 | + |
| 346 | + |
| 347 | +def _build_lookup(output: list) -> tuple[dict, dict]: |
| 348 | + """Build grouped lookup tables from structured output items.""" |
| 349 | + lookup: dict[str, dict[str, list]] = {} |
| 350 | + ordinal: dict[str, list[list]] = defaultdict(list) |
| 351 | + |
| 352 | + for detail_type in DETAIL_TYPE_TO_OUTPUT_TYPES: |
| 353 | + lookup[detail_type] = {} |
| 354 | + |
| 355 | + fc_by_call_id: dict[str, dict] = {} |
| 356 | + fco_by_call_id: dict[str, dict] = {} |
| 357 | + for item in output: |
| 358 | + item_type = item.get('type', '') |
| 359 | + if item_type == 'function_call': |
| 360 | + call_id = item.get('call_id', '') |
| 361 | + if call_id: |
| 362 | + fc_by_call_id[call_id] = item |
| 363 | + elif item_type == 'function_call_output': |
| 364 | + call_id = item.get('call_id', '') |
| 365 | + if call_id: |
| 366 | + fco_by_call_id[call_id] = item |
| 367 | + |
| 368 | + for call_id, fc_item in fc_by_call_id.items(): |
| 369 | + bundle = [fc_item] |
| 370 | + fco = fco_by_call_id.get(call_id) |
| 371 | + if fco: |
| 372 | + bundle.append(fco) |
| 373 | + lookup['tool_calls'][call_id] = bundle |
| 374 | + ordinal['tool_calls'].append(bundle) |
| 375 | + |
| 376 | + for item in output: |
| 377 | + item_type = item.get('type', '') |
| 378 | + item_id = item.get('id', '') |
| 379 | + |
| 380 | + if item_type == 'reasoning': |
| 381 | + bundle = [item] |
| 382 | + if item_id: |
| 383 | + lookup['reasoning'][item_id] = bundle |
| 384 | + ordinal['reasoning'].append(bundle) |
| 385 | + |
| 386 | + elif item_type == 'open_webui:code_interpreter': |
| 387 | + bundle = [item] |
| 388 | + if item_id: |
| 389 | + lookup['code_interpreter'][item_id] = bundle |
| 390 | + ordinal['code_interpreter'].append(bundle) |
| 391 | + |
| 392 | + return lookup, ordinal |
| 393 | + |
| 394 | + |
| 395 | +def _make_message_item(text: str) -> dict: |
| 396 | + """Create a message output item from plain text.""" |
| 397 | + return { |
| 398 | + 'type': 'message', |
| 399 | + 'id': output_id('msg'), |
| 400 | + 'status': 'completed', |
| 401 | + 'role': 'assistant', |
| 402 | + 'content': [{'type': 'output_text', 'text': text}], |
| 403 | + } |
| 404 | + |
| 405 | + |
| 406 | +def reconcile_assistant_output(message: dict) -> Optional[list]: |
| 407 | + """Reconcile an assistant message's output with its edited content. |
| 408 | +
|
| 409 | + Returns None if no output exists, otherwise a list of effective output |
| 410 | + items that match the blocks still present in the edited content. |
| 411 | + """ |
| 412 | + output = message.get('output') |
| 413 | + if not output or not isinstance(output, list): |
| 414 | + return None |
| 415 | + |
| 416 | + content = message.get('content') |
| 417 | + if not isinstance(content, str) or not content.strip(): |
| 418 | + return None |
| 419 | + |
| 420 | + segments, raw_parts = _parse_content_segments(content) |
| 421 | + |
| 422 | + lookup, ordinal = _build_lookup(output) |
| 423 | + |
| 424 | + emitted_ids: set[str] = set() |
| 425 | + ordinal_counters: dict[str, int] = defaultdict(int) |
| 426 | + |
| 427 | + effective_output: list = [] |
| 428 | + pending_text: list[str] = [] |
| 429 | + raw_idx = 0 |
| 430 | + |
| 431 | + def flush_text(): |
| 432 | + if pending_text: |
| 433 | + joined = '\n'.join(pending_text).strip() |
| 434 | + if joined: |
| 435 | + effective_output.append(_make_message_item(joined)) |
| 436 | + pending_text.clear() |
| 437 | + |
| 438 | + for seg_type, detail_type, detail_id in segments: |
| 439 | + raw_part = raw_parts[raw_idx] if raw_idx < len(raw_parts) else '' |
| 440 | + raw_idx += 1 |
| 441 | + |
| 442 | + if seg_type == 'text': |
| 443 | + text = raw_part.strip() |
| 444 | + if text: |
| 445 | + pending_text.append(text) |
| 446 | + continue |
| 447 | + |
| 448 | + if detail_type not in DETAIL_TYPE_TO_OUTPUT_TYPES: |
| 449 | + pending_text.append(raw_part) |
| 450 | + continue |
| 451 | + |
| 452 | + bundle = None |
| 453 | + match_key = None |
| 454 | + |
| 455 | + if detail_id and detail_id in lookup.get(detail_type, {}): |
| 456 | + if detail_id not in emitted_ids: |
| 457 | + bundle = lookup[detail_type][detail_id] |
| 458 | + match_key = detail_id |
| 459 | + |
| 460 | + if bundle is None and not detail_id: |
| 461 | + idx = ordinal_counters[detail_type] |
| 462 | + ordinal_list = ordinal.get(detail_type, []) |
| 463 | + if idx < len(ordinal_list): |
| 464 | + candidate = ordinal_list[idx] |
| 465 | + candidate_id = candidate[0].get('id', '') or candidate[0].get('call_id', '') |
| 466 | + if candidate_id not in emitted_ids: |
| 467 | + bundle = candidate |
| 468 | + match_key = candidate_id |
| 469 | + ordinal_counters[detail_type] = idx + 1 |
| 470 | + |
| 471 | + if bundle is not None: |
| 472 | + flush_text() |
| 473 | + effective_output.extend(bundle) |
| 474 | + if match_key: |
| 475 | + emitted_ids.add(match_key) |
| 476 | + else: |
| 477 | + pending_text.append(raw_part) |
| 478 | + |
| 479 | + flush_text() |
| 480 | + |
| 481 | + return effective_output |
| 482 | + |
| 483 | + |
303 | 484 | def get_last_user_message(messages: list[dict]) -> Optional[str]: |
304 | 485 | message = get_last_user_message_item(messages) |
305 | 486 | if message is None: |
|
0 commit comments