main
py 689 lines 25.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Cohere Command parser.
4
5 Uses ``cohere_melody`` as a stateful parser filter.
6 """
7
8 from __future__ import annotations
9
10 import json
11 from collections.abc import Mapping, Sequence
12 from typing import TYPE_CHECKING, Any, NamedTuple, TypedDict, TypeGuard
13
14 import regex as re
15 import xgrammar as xgr
16
17 try:
18 from cohere_melody import (
19 AccumulatedToolCall,
20 FilterAggregatedResult,
21 PyFilter,
22 PyFilterOptions,
23 )
24 except ImportError as e:
25 raise ImportError(
26 "The Cohere Command parser requires the `cohere_melody` package. "
27 "Install it with:\n pip install 'cohere-melody>=0.14.0'"
28 ) from e
29
30 from vllm.entrypoints.cohere.cohere_chat_message import (
31 Citation,
32 CitationSource,
33 CohereDeltaMessage,
34 )
35 from vllm.entrypoints.generate.base.protocol import (
36 AnyResponseFormat,
37 DeltaFunctionCall,
38 DeltaMessage,
39 DeltaToolCall,
40 FunctionCall,
41 )
42 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
43 from vllm.parser.abstract_parser import DelegatingParser
44 from vllm.reasoning.cohere_command_reasoning_parser import (
45 BaseCohereCommandReasoningParser,
46 )
47 from vllm.renderers.cohere import POSITION_TO_SOURCE_KEY
48 from vllm.sampling_params import StructuredOutputsParams
49 from vllm.tool_parsers.cohere_command_tool_parser import BaseCohereCommandToolParser
50
51 if TYPE_CHECKING:
52 # Declared in melody's stub only; not exported at runtime.
53 from cohere_melody import FilterCitation, Source
54
55 from vllm.config import ModelConfig
56 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
57 from vllm.tokenizers import TokenizerLike
58 from vllm.tool_parsers.utils import Tool
59
60 PositionToSource = Mapping[tuple[int, int], CitationSource]
61
62
63 class CohereTagRegistry(NamedTuple):
64 """A single ``structural_tag`` trigger / end pair (``begin`` uses ``trigger``)."""
65
66 trigger: str
67 end: str
68
69
70 class CohereTagStyle(NamedTuple):
71 """The structural tags style for a given model architecture.
72
73 ``json_tags`` lists every JSON-schema wrapper the model may emit (MOE uses
74 both response and text delimiters). ``tools`` is the tool-call wrapper.
75 """
76
77 json_tags: tuple[CohereTagRegistry, ...]
78 tools: CohereTagRegistry
79
80
81 class CohereNormalizedTool(TypedDict):
82 """A tool definition normalized to the shape ``collect_tool_schema`` expects.
83
84 ``parameters`` is a JSON Schema object (possibly empty) describing the tool's
85 call signature.
86 """
87
88 name: str
89 parameters: dict[str, Any]
90
91
92 COMMAND_A_TOOLS_TAG = CohereTagRegistry(
93 trigger="<|START_ACTION|>",
94 end="<|END_ACTION|>",
95 )
96 COMMAND_A_JSON_TAG = CohereTagRegistry(
97 trigger="<|START_RESPONSE|>",
98 end="<|END_RESPONSE|>",
99 )
100 COMMAND_A_PLUS_JSON_TAG = CohereTagRegistry(
101 trigger="<|START_TEXT|>",
102 end="<|END_TEXT|>",
103 )
104
105 MODEL_TO_TAG_STYLE: dict[str, CohereTagStyle] = {
106 "Cohere2ForCausalLM": CohereTagStyle(
107 json_tags=(COMMAND_A_JSON_TAG,),
108 tools=COMMAND_A_TOOLS_TAG,
109 ),
110 "Cohere2VisionForConditionalGeneration": CohereTagStyle(
111 json_tags=(COMMAND_A_JSON_TAG, COMMAND_A_PLUS_JSON_TAG),
112 tools=COMMAND_A_TOOLS_TAG,
113 ),
114 "Cohere2MoeForCausalLM": CohereTagStyle(
115 json_tags=(COMMAND_A_JSON_TAG, COMMAND_A_PLUS_JSON_TAG),
116 tools=COMMAND_A_TOOLS_TAG,
117 ),
118 }
119
120
121 def collect_tool_schema(tool_schema: list[CohereNormalizedTool]) -> str:
122 """Build an xgrammar EBNF grammar that matches a JSON array of tool calls.
123
124 The grammar shape is architecture-independent; callers are responsible for
125 wrapping it in the correct structural tag (see ``CohereTagStyle.tools``).
126 """
127 tool_dictionary: dict[str, str] = {}
128 for tool in tool_schema:
129 tool_name = tool["name"]
130 tool_parameters = dict(tool["parameters"])
131 json_schema: dict[str, Any] = {
132 "type": "object",
133 "properties": {
134 "tool_call_id": {"type": "string", "pattern": "^[0-9]+$"},
135 "tool_name": {"type": "string", "const": tool_name},
136 "parameters": tool_parameters,
137 },
138 }
139 for defs_key in ("$defs", "definitions"):
140 if defs_key in tool_parameters:
141 json_schema[defs_key] = tool_parameters.pop(defs_key)
142 tool_grammar = str(xgr.Grammar.from_json_schema(json.dumps(json_schema)))
143 for match in re.findall(r"\b(\w+)\s*::=", tool_grammar):
144 tool_grammar = re.sub(
145 rf"\b{re.escape(match)}\b", tool_name + match, tool_grammar
146 )
147 tool_dictionary[tool_name] = f"{tool_name} ::= {tool_name}root\n{tool_grammar}"
148 # Emitted grammar shape:
149 # root ::= tools
150 # tools ::= ws "[" ws tool (ws "," ws tool)* ws "]" ws
151 # ws ::= (" " | "\t" | "\n")*
152 # tool ::= <tool_a> | <tool_b> | ... (one alternative per input)
153 # <tool_x> ::= <tool_x>root (per-tool xgrammar rules)
154 # <tool_x>root ::= ... (from xgr.Grammar.from_json_schema)
155 tool_alternatives = "tool ::= " + " | ".join(tool_dictionary.keys())
156 tool_rules = "\n ".join(tool_dictionary.values())
157 grammar = f"""root ::= tools
158 tools ::= ws "[" ws tool (ws "," ws tool)* ws "]" ws
159 ws ::= (" " | "\\t" | "\\n")*
160 {tool_alternatives}
161 {tool_rules}
162 """
163 return grammar
164
165
166 def _tool_definitions_to_schema_list(
167 tools: str | list[Any],
168 ) -> list[CohereNormalizedTool]:
169 """Build the list of ``CohereNormalizedTool`` dicts expected by
170 ``collect_tool_schema``.
171
172 Accepts:
173 - JSON string
174 - list of dicts with top-level ``name`` / ``parameters``
175 - list of Chat Completions-style ``{"type": "function", "function": {...}}``
176 - list of Pydantic models with ``model_dump()``
177 """
178 if isinstance(tools, str):
179 try:
180 parsed = json.loads(tools)
181 except json.JSONDecodeError:
182 return []
183 if not isinstance(parsed, list):
184 return []
185 else:
186 parsed = list(tools)
187
188 out: list[CohereNormalizedTool] = []
189 for raw in parsed:
190 t = raw.model_dump() if hasattr(raw, "model_dump") else raw
191 if not isinstance(t, dict):
192 continue
193 # Unwrap Chat Completions' ``{"type": "function", "function": {...}}``
194 # shape; otherwise take the dict as-is.
195 if t.get("type") == "function" and isinstance(t.get("function"), dict):
196 t = t["function"]
197 name = t.get("name")
198 if not isinstance(name, str):
199 continue
200 params = t.get("parameters")
201 out.append(
202 CohereNormalizedTool(
203 name=name,
204 parameters=params if isinstance(params, dict) else {},
205 )
206 )
207 return out
208
209
210 def _has_effective_tools(
211 tools: str | list[Any] | None,
212 ) -> TypeGuard[str | list[Any]]:
213 """True when ``tools`` contains at least one tool definition to convert.
214
215 ``ResponsesRequest`` defaults ``tools`` to ``[]``; ``ChatCompletionRequest``
216 uses ``None``. Both mean "no tools" here. Strings (e.g. a JSON blob) are
217 treated as effective only when non-blank.
218 """
219 if tools is None:
220 return False
221 if isinstance(tools, str):
222 return bool(tools.strip())
223 return len(tools) > 0
224
225
226 # Builder: produces vLLM response_format in xgrammar's canonical format.
227 # See xgrammar docs: type "structural_tag" with "format" = triggered_tags
228 # and tag content type = json_schema | grammar.
229 def convert_schema_to_structural_tags(
230 schema: dict | None = None,
231 tools: str | list[Any] | None = None,
232 model_architecture: str | None = None,
233 ) -> str | None:
234 """Returns a response_format string accepted by xgrammar's structural tag format.
235 Uses the canonical shape: {"type": "structural_tag", "format": {...}} with
236 format.type "triggered_tags" and tag content type "json_schema" or "grammar".
237
238 Callers that are not on an engine path (e.g. the reasoning parser) must pass
239 ``model_architecture`` explicitly.
240 """
241 if model_architecture is None or model_architecture not in MODEL_TO_TAG_STYLE:
242 return None
243 style = MODEL_TO_TAG_STYLE[model_architecture]
244
245 tags: list[dict] = []
246 triggers: list[str] = []
247
248 def _add_tag(tag: CohereTagRegistry, content: dict) -> None:
249 tags.append({"begin": tag.trigger, "content": content, "end": tag.end})
250 triggers.append(tag.trigger)
251
252 if schema is not None:
253 # One structural tag per JSON wrapper (e.g. MOE: response + text).
254 # Same for schema-only and "tools plus JSON mode" (North: schema when
255 # the model does not call tools).
256 for jt in style.json_tags:
257 _add_tag(jt, {"type": "json_schema", "json_schema": schema})
258
259 if _has_effective_tools(tools):
260 # ``tools`` may be a JSON string (poseidon / RESPONSE_FORMAT_TOOL_DEFINITIONS)
261 # or a list (Chat Completions ``request.tools`` as Pydantic models or dicts).
262 tool_schema_list = _tool_definitions_to_schema_list(tools)
263 if not tool_schema_list:
264 raise ValueError(
265 "No valid tool definitions could be parsed from the request for "
266 "structural tag conversion."
267 )
268 tool_grammar = collect_tool_schema(tool_schema_list)
269 _add_tag(style.tools, {"type": "grammar", "grammar": tool_grammar})
270
271 if not tags:
272 return None
273 return json.dumps(
274 {
275 "type": "structural_tag",
276 "format": {
277 "type": "triggered_tags",
278 "triggers": triggers,
279 "tags": tags,
280 },
281 }
282 )
283
284
285 def _response_format_type(
286 response_format: AnyResponseFormat | dict | None,
287 ) -> str | None:
288 if response_format is None:
289 return None
290 if isinstance(response_format, dict):
291 t = response_format.get("type")
292 return t if isinstance(t, str) else None
293 return response_format.type
294
295
296 def _maybe_parse_json_dict(value: Any) -> dict | None:
297 """If value is a JSON string, parse to dict; otherwise require dict."""
298 if isinstance(value, dict):
299 return value
300 if isinstance(value, str):
301 try:
302 parsed = json.loads(value)
303 except (TypeError, json.JSONDecodeError):
304 return None
305 return parsed if isinstance(parsed, dict) else None
306 return None
307
308
309 def _unwrap_nested_schema(candidate: Any) -> dict | None:
310 """Return ``candidate`` as a dict, unwrapping a nested ``schema`` if present.
311
312 Returns ``None`` if ``candidate`` is not (and cannot be parsed into) a dict.
313 """
314 cand = _maybe_parse_json_dict(candidate)
315 if not isinstance(cand, dict):
316 return None
317 nested = cand.get("schema")
318 return nested if isinstance(nested, dict) else cand
319
320
321 def _schema_from_json_schema_field(js_wr: Any) -> dict | None:
322 """Extract the JSON Schema object from Chat Completions ``json_schema`` payload.
323
324 Accepts:
325 - ``JsonSchemaResponseFormat`` (Pydantic) with ``schema`` / ``json_schema`` field
326 - dict in OpenAI shape ``{"name": ..., "schema": {...}}``
327 - dict with ``json_schema`` key holding either the schema or a nested wrapper
328 - dict that is already a JSON Schema document (some clients omit the wrapper)
329 - JSON strings for any of the above
330 """
331 if js_wr is None:
332 return None
333
334 parsed_wr = _maybe_parse_json_dict(js_wr)
335 if parsed_wr is not None:
336 js_wr = parsed_wr
337
338 if hasattr(js_wr, "model_dump"):
339 for by_alias in (True, False):
340 try:
341 data = js_wr.model_dump(by_alias=by_alias, exclude_none=False)
342 except TypeError:
343 data = js_wr.model_dump(by_alias=by_alias)
344 out = _unwrap_nested_schema(data.get("schema") or data.get("json_schema"))
345 if out is not None:
346 return out
347 inner_attr = getattr(js_wr, "json_schema", None)
348 return inner_attr if isinstance(inner_attr, dict) else None
349
350 if isinstance(js_wr, dict):
351 for key in ("schema", "json_schema"):
352 out = _unwrap_nested_schema(js_wr.get(key))
353 if out is not None:
354 return out
355 return js_wr
356
357 return None
358
359
360 def _schema_dict_from_chat_response_format(
361 rf: AnyResponseFormat | dict | None,
362 ) -> dict | None:
363 """JSON schema dict from Chat Completions ``request.response_format`` only."""
364 if rf is None:
365 return None
366 rf_type = _response_format_type(rf)
367 if rf_type == "json_object":
368 return {"type": "object"}
369 if rf_type != "json_schema":
370 return None
371 js_wr = (
372 rf.get("json_schema")
373 if isinstance(rf, dict)
374 else getattr(rf, "json_schema", None)
375 )
376 return _schema_from_json_schema_field(js_wr)
377
378
379 def _schema_dict_from_structured_outputs(
380 so: StructuredOutputsParams | None,
381 ) -> dict | None:
382 """Schema dict from ``structured_outputs`` (``json`` / ``json_object``).
383
384 Same unwrapping as ``json_schema``. ``json`` is expected to be ``str`` or
385 ``dict`` (enforced by ``StructuredOutputsParams`` / request models); other
386 types raise ``ValueError`` only if a caller bypasses that validation.
387 """
388 if so is None:
389 return None
390 if so.json_object:
391 return {"type": "object"}
392 raw: Any = so.json
393 if raw is None:
394 return None
395
396 if hasattr(raw, "model_dump"):
397 out = _schema_from_json_schema_field(raw)
398 if out is None:
399 raise ValueError(
400 "structured_outputs.json model has no extractable JSON Schema."
401 )
402 return out
403
404 if isinstance(raw, str):
405 if not raw.strip():
406 raise ValueError("structured_outputs.json cannot be empty.")
407 try:
408 raw = json.loads(raw)
409 except json.JSONDecodeError as e:
410 raise ValueError("structured_outputs.json must be valid JSON.") from e
411 if not isinstance(raw, dict):
412 raise ValueError("structured_outputs.json must decode to a JSON object.")
413
414 if isinstance(raw, Mapping):
415 body = raw if isinstance(raw, dict) else dict(raw)
416 return _schema_from_json_schema_field(body) or body
417
418 raise ValueError(
419 f"structured_outputs.json has unsupported type {type(raw).__name__}."
420 )
421
422
423 def _melody_sources_to_vllm(
424 sources: list[Source], position_to_source: PositionToSource | None
425 ) -> list[CitationSource]:
426 """Resolve melody ``Source`` addresses into :class:`CitationSource` objects.
427
428 Melody reports each source as ``(tool_call_index, tool_result_indices)``.
429 ``position_to_source`` is built by
430 :meth:`vllm.entrypoints.cohere.serving.CohereServingChatV2._build_position_to_source`
431 and forwarded through ``chat_template_kwargs``; each index fans out to one
432 resolved source, and unresolved positions are skipped. Without a map (a
433 request that did not come through the Cohere v2 handler) every source is
434 dropped and the serving layer discards the citation at wire-coercion time.
435 """
436 if not position_to_source:
437 return []
438 out: list[CitationSource] = []
439 for source in sources:
440 for idx in source.tool_result_indices:
441 info = position_to_source.get((source.tool_call_index, idx))
442 if info is not None:
443 out.append(info)
444 return out
445
446
447 def _melody_citations_to_vllm(
448 citations: list[FilterCitation], position_to_source: PositionToSource | None
449 ) -> list[Citation] | None:
450 """Convert melody ``FilterCitation`` objects into :class:`Citation`.
451
452 Citations whose sources all fail to resolve are still returned with
453 ``sources=[]``; the serving layer drops them so the fail-closed policy
454 lives in one place.
455 """
456 if not citations:
457 return None
458 return [
459 Citation(
460 start=c.start_index,
461 end=c.end_index,
462 text=c.text,
463 sources=_melody_sources_to_vllm(c.sources, position_to_source),
464 type="THINKING_CONTENT" if c.is_thinking else "TEXT_CONTENT",
465 )
466 for c in citations
467 ]
468
469
470 class CohereCommandParser(DelegatingParser):
471 def __init__(
472 self,
473 tokenizer: TokenizerLike,
474 tools: list[Tool] | None = None,
475 *args,
476 model_config: ModelConfig | None = None,
477 **kwargs,
478 ):
479 super().__init__(tokenizer, tools, *args, model_config=model_config, **kwargs)
480 self._model_config = model_config
481 self.start_token_id = tokenizer.convert_tokens_to_ids("<|START_THINKING|>")
482 self.end_token_id = tokenizer.convert_tokens_to_ids("<|END_THINKING|>")
483 shim = self._reasoning_parser or self._tool_parser
484 assert isinstance(
485 shim, (BaseCohereCommandReasoningParser, BaseCohereCommandToolParser)
486 )
487 options = getattr(PyFilterOptions(), shim.melody_preset)()
488 self._streaming = PyFilter(options)
489 self._unary = PyFilter(options)
490 # Citations from the most recent ``parse`` call, surfaced on
491 # ``CohereChatMessage`` by ``CohereServingChatV2``.
492 self.last_unary_citations: list[Citation] | None = None
493 # Melody may emit a tool-call id before the function name; hold it until
494 # the first name delta so clients receive both together.
495 self._pending_tool_ids: dict[int, str] = {}
496 ctk = kwargs.get("chat_template_kwargs") or {}
497 raw_map = ctk.get(POSITION_TO_SOURCE_KEY)
498 self._position_to_source: PositionToSource | None = (
499 raw_map if isinstance(raw_map, Mapping) else None
500 )
501
502 def adjust_request(
503 self, request: ChatCompletionRequest | ResponsesRequest
504 ) -> ChatCompletionRequest | ResponsesRequest:
505 # keep special tokens to ensure melody can correctly identify boundaries.
506 request.skip_special_tokens = False
507 request = self._apply_structural_tags(request)
508 return super().adjust_request(request)
509
510 def _apply_structural_tags(
511 self, request: ChatCompletionRequest | ResponsesRequest
512 ) -> ChatCompletionRequest | ResponsesRequest:
513 """Fold ``tools`` and any JSON schema into a Cohere ``structural_tag``."""
514 so = request.structured_outputs
515 if so is not None and so.structural_tag:
516 return request
517 # Schema: prefer ``response_format`` (OpenAI Chat Completions), then
518 # ``structured_outputs.json`` / ``json_object`` (vLLM direct). Tools stay
519 # on ``request.tools``.
520 rf = (
521 request.response_format
522 if isinstance(request, ChatCompletionRequest)
523 else None
524 )
525 if rf is not None and _response_format_type(rf) == "structural_tag":
526 return request
527 model_architecture = (
528 self._model_config.architecture if self._model_config is not None else None
529 )
530 tools = request.tools
531 # ``response_format`` wins if both it and ``structured_outputs`` supply JSON.
532 schema = _schema_dict_from_chat_response_format(rf)
533 if schema is None:
534 schema = _schema_dict_from_structured_outputs(so)
535 if schema is None and not _has_effective_tools(tools):
536 return request
537 if model_architecture is None:
538 return request
539 result = convert_schema_to_structural_tags(
540 schema=schema,
541 tools=tools,
542 model_architecture=model_architecture,
543 )
544 if result is None:
545 # Unsupported architectures are not in ``MODEL_TO_TAG_STYLE``.
546 raise ValueError(
547 "Failed to build structural_tag guided decoding constraints from "
548 "this request's JSON schema and/or tools. The configured model "
549 f"architecture ({model_architecture!r}) does not support Cohere "
550 "command structural tags, or the schema cannot be expressed in "
551 "that format."
552 )
553 request.structured_outputs = StructuredOutputsParams(structural_tag=result)
554 # Folded JSON constraints into ``structural_tag``; drop ``response_format``
555 # when it was the source so ``to_sampling_params`` does not also set ``json`` /
556 # ``json_object`` (mutually exclusive in ``StructuredOutputsParams``).
557 if isinstance(request, ChatCompletionRequest) and rf is not None:
558 rf_type = _response_format_type(rf)
559 if rf_type in ("json_schema", "json_object"):
560 request.response_format = None
561 return request
562
563 def _apply_structural_tag(
564 self, request: ChatCompletionRequest | ResponsesRequest
565 ) -> ChatCompletionRequest | ResponsesRequest:
566 # Note(arpera):
567 # No need to go through _apply_structural_tag in abstract_parser.py
568 # since we have already go through _apply_structural_tags here
569 # So, override this method to return request as is
570 return request
571
572 def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
573 count = 0
574 depth = 0
575 for token_id in token_ids:
576 if token_id == self.start_token_id:
577 depth += 1
578 continue
579 if token_id == self.end_token_id:
580 if depth > 0:
581 depth -= 1
582 continue
583 if depth > 0:
584 count += 1
585 return count
586
587 def parse(
588 self,
589 model_output: str,
590 request: ChatCompletionRequest | ResponsesRequest,
591 enable_auto_tools: bool = False,
592 model_output_token_ids: Sequence[int] = (),
593 ) -> tuple[str | None, str | None, list[FunctionCall] | None]:
594 result = self._unary.process_full_text(model_output)
595 self.last_unary_citations = _melody_citations_to_vllm(
596 result.citations, self._position_to_source
597 )
598 tool_calls: list[FunctionCall] | None = None
599 if self._tools_allowed(request):
600 tool_calls = [
601 FunctionCall(id=tc.id, name=tc.name, arguments=tc.arguments)
602 for tc in result.tool_calls
603 ] or None
604 return result.reasoning, result.content, tool_calls
605
606 def parse_delta(
607 self,
608 delta_text: str,
609 delta_token_ids: list[int],
610 request: ChatCompletionRequest | ResponsesRequest,
611 prompt_token_ids: list[int] | None = None,
612 *,
613 finished: bool,
614 ) -> DeltaMessage | None:
615 results = [self._streaming.write_decoded(delta_text)]
616 if finished:
617 results.append(self._streaming.flush_partials())
618 return self._to_delta(results, request)
619
620 def _tools_allowed(self, request: ChatCompletionRequest | ResponsesRequest) -> bool:
621 return self._tool_parser is not None and request.tool_choice != "none"
622
623 def _to_delta(
624 self,
625 results: list[FilterAggregatedResult],
626 request: ChatCompletionRequest | ResponsesRequest,
627 ) -> CohereDeltaMessage | None:
628 content = self._join(r.content for r in results)
629 reasoning = (
630 self._join(r.reasoning for r in results)
631 if request.include_reasoning
632 else None
633 )
634 tool_calls: list[DeltaToolCall] = []
635 citations: list[Citation] = []
636 for result in results:
637 if result.tool_calls and self._tools_allowed(request):
638 tool_calls.extend(self._tool_call_deltas(result.tool_calls))
639 citations.extend(
640 _melody_citations_to_vllm(result.citations, self._position_to_source)
641 or []
642 )
643 if not (content or reasoning or tool_calls or citations):
644 return None
645 # ``CohereDeltaMessage`` carries citations to the wire via
646 # ``SerializeAsAny[DeltaMessage]``; it drops the field when unset so the
647 # shape matches a plain ``DeltaMessage``.
648 message = CohereDeltaMessage()
649 if content is not None:
650 message.content = content
651 if reasoning is not None:
652 message.reasoning = reasoning
653 if tool_calls:
654 message.tool_calls = tool_calls
655 if citations:
656 message.citations = citations
657 return message
658
659 @staticmethod
660 def _join(parts: Any) -> str | None:
661 present = [p for p in parts if p is not None]
662 return "".join(present) if present else None
663
664 def _tool_call_deltas(
665 self, melody_tool_calls: list[AccumulatedToolCall]
666 ) -> list[DeltaToolCall]:
667 tool_calls: list[DeltaToolCall] = []
668 for tc in melody_tool_calls:
669 name = tc.name or None
670 arguments = tc.arguments or None
671 if tc.id:
672 self._pending_tool_ids[tc.index] = tc.id
673 # Empty strings are melody streaming placeholders; skip fragments
674 # that carry neither a name nor argument text.
675 if name is None and arguments is None:
676 continue
677 function = DeltaFunctionCall()
678 if name is not None:
679 function.name = name
680 if arguments is not None:
681 function.arguments = arguments
682 delta = DeltaToolCall(index=tc.index, function=function)
683 if name is not None:
684 resolved_id = tc.id or self._pending_tool_ids.pop(tc.index, None)
685 if resolved_id is not None:
686 delta.id = resolved_id
687 delta.type = "function"
688 tool_calls.append(delta)
689 return tool_calls