main
py 1,263 lines 53.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 # Adapted from
4 # https://github.com/vllm-project/vllm/blob/main/vllm/entrypoints/openai/chat_completion/serving.py
5
6 """Anthropic Messages API serving handler."""
7
8 import json
9 import logging
10 import time
11 import uuid
12 from collections.abc import AsyncGenerator
13 from typing import Any, Literal, get_args
14
15 import jinja2
16 from fastapi import Request
17
18 from vllm.engine.protocol import EngineClient
19 from vllm.entrypoints.anthropic.protocol import (
20 AnthropicContentBlock,
21 AnthropicContextManagement,
22 AnthropicCountTokensRequest,
23 AnthropicCountTokensResponse,
24 AnthropicDelta,
25 AnthropicDisabledThinkingEffort,
26 AnthropicDisabledThinkingEffortOption,
27 AnthropicEffort,
28 AnthropicError,
29 AnthropicMessagesRequest,
30 AnthropicMessagesResponse,
31 AnthropicOutputConfig,
32 AnthropicStreamEvent,
33 AnthropicThinkingConfig,
34 AnthropicTool,
35 AnthropicToolChangeDefinition,
36 AnthropicUsage,
37 )
38 from vllm.entrypoints.chat_utils import ChatTemplateContentFormatOption
39 from vllm.entrypoints.generate.base.protocol import (
40 JsonSchemaResponseFormat,
41 ResponseFormat,
42 StreamOptions,
43 )
44 from vllm.entrypoints.openai.chat_completion.protocol import (
45 ChatCompletionNamedToolChoiceParam,
46 ChatCompletionRequest,
47 ChatCompletionResponse,
48 ChatCompletionStreamResponse,
49 ChatCompletionToolsParam,
50 )
51 from vllm.entrypoints.openai.chat_completion.serving import OpenAIServingChat
52 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
53 from vllm.entrypoints.serve.engine.protocol import ErrorResponse, UsageInfo
54 from vllm.entrypoints.serve.exception_handling.utils import sanitize_message
55 from vllm.entrypoints.serve.utils.request_logger import RequestLogger
56 from vllm.logger import init_logger
57 from vllm.renderers.hf import HfRenderer, resolve_chat_template
58 from vllm.renderers.online_renderer import OnlineRenderer
59
60 logger = init_logger(__name__)
61
62
63 def _build_anthropic_usage(
64 usage: UsageInfo | None,
65 ) -> AnthropicUsage:
66 """Build an AnthropicUsage from UsageInfo.
67
68 Anthropic defines ``total_input == input_tokens + cache_read +
69 cache_creation``. vLLM's ``prompt_tokens`` is the total, so
70 ``input_tokens = prompt_tokens - cache_read - cache_creation``.
71
72 Cache fields are taken from ``UsageInfo.prompt_tokens_details``.
73 When cache info is absent (e.g. ``--enable-prompt-tokens-details``
74 off, or a streaming chunk that hasn't carried it yet), cache fields
75 are left **unset** so ``exclude_unset=True`` serialization omits them
76 entirely.
77
78 ``completion_tokens`` follows ``UsageInfo`` and may be ``None`` on
79 intermediate stream chunks; we coerce to ``0`` for the wire format.
80 """
81 kwargs = {}
82 if usage is None:
83 kwargs["input_tokens"] = 0
84 kwargs["output_tokens"] = 0
85 else:
86 kwargs["output_tokens"] = usage.completion_tokens or 0
87 input_tokens = usage.prompt_tokens
88
89 if (details := usage.prompt_tokens_details) is not None:
90 if (cache_read := details.cached_tokens) is not None:
91 input_tokens -= cache_read
92 kwargs["cache_read_input_tokens"] = cache_read
93
94 if (cache_creation := details.created_cache_tokens) is not None:
95 input_tokens -= cache_creation
96 kwargs["cache_creation_input_tokens"] = cache_creation
97
98 kwargs["input_tokens"] = max(0, input_tokens)
99 return AnthropicUsage(**kwargs)
100
101
102 def _to_anthropic_stop_reason(
103 finish_reason: str | None,
104 stop_reason: int | str | None,
105 tool_used: bool,
106 tool_complete: bool,
107 ) -> tuple[
108 Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"] | None,
109 str | None,
110 ]:
111 """Map an OpenAI finish reason to Anthropic ``(stop_reason, stop_sequence)``.
112
113 Anthropic reports ``tool_use`` whenever the model invoked a tool. The chat
114 layer reports ``finish_reason="stop"`` for a named (forced) tool call, so
115 ``tool_used`` records whether a ``tool_use`` block was produced.
116 ``max_tokens`` always wins. A matched stop string wins unless the tool
117 calls are complete (``tool_complete``): a call it cut short must not run.
118 """
119 if finish_reason == "length":
120 return "max_tokens", None
121 if finish_reason not in ("stop", "tool_calls"):
122 return None, None
123 if tool_complete:
124 return "tool_use", None
125 # vLLM reports the matched stop string in stop_reason (a str);
126 # an int stop-token-id or None (natural EOS) is not a stop sequence.
127 if isinstance(stop_reason, str):
128 return "stop_sequence", stop_reason
129 if finish_reason == "tool_calls" or tool_used:
130 return "tool_use", None
131 return "end_turn", None
132
133
134 def _is_json(text: str) -> bool:
135 try:
136 json.loads(text)
137 except json.JSONDecodeError:
138 return False
139 return True
140
141
142 def wrap_data_with_event(data: str, event: str):
143 return f"event: {event}\ndata: {data}\n\n"
144
145
146 class AnthropicServingMessages(OpenAIServingChat):
147 """Handler for Anthropic Messages API requests."""
148
149 def __init__(
150 self,
151 engine_client: EngineClient,
152 models: OpenAIServingModels,
153 response_role: str,
154 *,
155 online_renderer: "OnlineRenderer",
156 request_logger: RequestLogger | None,
157 chat_template: str | None,
158 chat_template_content_format: ChatTemplateContentFormatOption,
159 return_tokens_as_token_ids: bool = False,
160 reasoning_parser: str = "",
161 enable_auto_tools: bool = False,
162 tool_parser: str | None = None,
163 tool_strict_level: str = "auto",
164 enable_prompt_tokens_details: bool = False,
165 enable_force_include_usage: bool = False,
166 default_chat_template_kwargs: dict[str, Any] | None = None,
167 disabled_thinking_effort: AnthropicDisabledThinkingEffortOption = "auto",
168 ):
169 super().__init__(
170 engine_client=engine_client,
171 models=models,
172 response_role=response_role,
173 online_renderer=online_renderer,
174 request_logger=request_logger,
175 chat_template=chat_template,
176 chat_template_content_format=chat_template_content_format,
177 return_tokens_as_token_ids=return_tokens_as_token_ids,
178 reasoning_parser=reasoning_parser,
179 enable_auto_tools=enable_auto_tools,
180 tool_strict_level=tool_strict_level,
181 tool_parser=tool_parser,
182 enable_prompt_tokens_details=enable_prompt_tokens_details,
183 enable_force_include_usage=enable_force_include_usage,
184 default_chat_template_kwargs=default_chat_template_kwargs,
185 )
186 self._merge_inline_system = self._should_merge_inline_system(online_renderer)
187 # Resolved lazily from the renderer when "auto".
188 self._disabled_thinking_effort: AnthropicDisabledThinkingEffort | None = (
189 None if disabled_thinking_effort == "auto" else disabled_thinking_effort
190 )
191
192 async def _get_disabled_thinking_effort(self) -> AnthropicDisabledThinkingEffort:
193 if self._disabled_thinking_effort is None:
194 self._disabled_thinking_effort = (
195 await self._probe_disabled_thinking_effort()
196 )
197 logger.info(
198 "Anthropic thinking.type=disabled maps to reasoning_effort=%r",
199 self._disabled_thinking_effort,
200 )
201 return self._disabled_thinking_effort
202
203 async def _probe_disabled_thinking_effort(self) -> AnthropicDisabledThinkingEffort:
204 """Use ``low`` if the renderer rejects or ignores ``none``.
205
206 ``none`` is ignored when it renders the same prompt as a thinking
207 effort, e.g. GLM-5.3 treats unknown efforts as ``max``.
208 """
209 none_prompt = await self._render_probe_prompt("none")
210 if none_prompt is None:
211 return "low"
212 for effort in get_args(AnthropicEffort):
213 if await self._render_probe_prompt(effort) == none_prompt:
214 return "low"
215 return "none"
216
217 async def _render_probe_prompt(
218 self, effort: AnthropicDisabledThinkingEffort
219 ) -> tuple[list[int] | None, str | None] | None:
220 request = ChatCompletionRequest(
221 messages=[{"role": "user", "content": "Hi"}],
222 reasoning_effort=effort,
223 )
224 try:
225 result = await self.online_renderer.render_chat(request)
226 except Exception:
227 logger.debug("Rendering reasoning_effort=%r failed", effort, exc_info=True)
228 return None
229 if isinstance(result, ErrorResponse):
230 return None
231 _, (engine_input,) = result
232 components = self._extract_prompt_components(engine_input)
233 return components.token_ids, components.text
234
235 @classmethod
236 def _should_merge_inline_system(cls, online_renderer: OnlineRenderer) -> bool:
237 """Probe the chat templates the renderer applies, not the CLI override.
238
239 Both the tool and non-tool templates are checked since they may differ.
240 """
241 renderer = online_renderer.renderer
242 tool_variants: tuple[list[dict[str, Any]] | None, ...] = (None, [])
243 if not isinstance(renderer, HfRenderer) or renderer.tokenizer is None:
244 merge = cls._detect_merge_inline_system(online_renderer.chat_template)
245 else:
246 merge = any(
247 cls._detect_merge_inline_system(
248 resolve_chat_template(
249 renderer.tokenizer,
250 online_renderer.chat_template,
251 tools,
252 model_config=online_renderer.model_config,
253 )
254 )
255 for tools in tool_variants
256 )
257 if merge:
258 logger.warning_once(
259 "The chat template requires system-first ordering, so inline "
260 "system messages in /v1/messages requests (e.g. Claude Code's "
261 "per-turn reminders) are merged into the leading system prompt. "
262 "Each new one changes the prompt prefix, so the rest of the "
263 "conversation misses the prefix cache. Pass a --chat-template "
264 "that accepts non-leading system messages to avoid this."
265 )
266 return merge
267
268 @staticmethod
269 def _detect_merge_inline_system(chat_template: str | None) -> bool:
270 """Auto-detect whether the chat template requires system-first ordering.
271
272 Renders a [system, user, system, user] conversation against the
273 template; if it raises (e.g. Qwen's ``loop.first`` guard), the
274 model needs inline system messages merged into the leading block.
275 """
276 if not chat_template:
277 return True
278 try:
279 env = jinja2.sandbox.ImmutableSandboxedEnvironment(
280 trim_blocks=True,
281 lstrip_blocks=True,
282 extensions=[jinja2.ext.loopcontrols],
283 )
284 env.from_string(chat_template).render(
285 messages=[
286 {"role": "system", "content": "t"},
287 {"role": "user", "content": "t"},
288 {"role": "system", "content": "t"},
289 {"role": "user", "content": "t"},
290 ],
291 add_generation_prompt=False,
292 )
293 return False
294 except jinja2.TemplateError:
295 return True
296
297 @staticmethod
298 def _convert_image_source_to_url(source: dict[str, Any]) -> str:
299 """Convert an Anthropic image source to an OpenAI-compatible URL.
300
301 Anthropic supports two image source types:
302 - base64: {"type": "base64", "media_type": "image/jpeg", "data": "..."}
303 - url: {"type": "url", "url": "https://..."}
304
305 For base64 sources, this constructs a proper data URI that
306 downstream processors (e.g. vLLM's media connector) can handle.
307 """
308 source_type = source.get("type")
309 if source_type == "url":
310 return source.get("url", "")
311 # Default to base64 processing if type is "base64"
312 # or missing, ensuring a proper data URI is always
313 # constructed for non-URL sources.
314 media_type = source.get("media_type", "image/jpeg")
315 data = source.get("data", "")
316 return f"data:{media_type};base64,{data}"
317
318 @classmethod
319 def to_chat_completion_request(
320 cls,
321 anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest,
322 *,
323 merge_inline_system: bool = False,
324 disabled_thinking_effort: AnthropicDisabledThinkingEffort = "none",
325 ) -> ChatCompletionRequest:
326 """Convert Anthropic message format to OpenAI format."""
327 openai_messages: list[dict[str, Any]] = []
328
329 cls._convert_system_message(
330 anthropic_request,
331 openai_messages,
332 merge_inline_system=merge_inline_system,
333 )
334 cls._convert_messages(
335 anthropic_request.messages,
336 openai_messages,
337 merge_inline_system=merge_inline_system,
338 )
339 req = cls._build_base_request(anthropic_request, openai_messages)
340 cls._handle_streaming_options(req, anthropic_request)
341 cls._handle_output_config(req, anthropic_request)
342 cls._handle_thinking(req, anthropic_request, disabled_thinking_effort)
343 cls._convert_tool_choice(anthropic_request, req)
344 cls._convert_tools(anthropic_request, req)
345 return req
346
347 @classmethod
348 def _convert_system_message(
349 cls,
350 anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest,
351 openai_messages: list[dict[str, Any]],
352 *,
353 merge_inline_system: bool = False,
354 ) -> None:
355 """Convert Anthropic system message to OpenAI format."""
356 system_parts: list[str] = []
357
358 # Top-level system field
359 if anthropic_request.system:
360 if isinstance(anthropic_request.system, str):
361 system_parts.append(anthropic_request.system)
362 else:
363 for block in anthropic_request.system:
364 if block.type == "text" and block.text:
365 # Strip Claude Code's attribution header which contains
366 # a per-request hash that defeats prefix caching.
367 if block.text.startswith("x-anthropic-billing-header"):
368 continue
369 system_parts.append(block.text)
370
371 # When the template requires system-first ordering, extract inline
372 # system messages from the messages array and merge them into the
373 # top-level block so the template doesn't reject them.
374 if merge_inline_system:
375 for msg in anthropic_request.messages:
376 if msg.role != "system":
377 continue
378 text = cls._extract_system_text(msg)
379 if text:
380 system_parts.append(text)
381
382 if system_parts:
383 openai_messages.append({"role": "system", "content": "".join(system_parts)})
384
385 @classmethod
386 def _extract_system_text(cls, msg) -> str | None:
387 """Extract text from a system message, stripping billing headers."""
388 if isinstance(msg.content, str):
389 text = msg.content
390 if text.startswith("x-anthropic-billing-header"):
391 return None
392 return text
393 parts: list[str] = []
394 for block in msg.content:
395 if block.type == "text" and block.text:
396 if block.text.startswith("x-anthropic-billing-header"):
397 continue
398 parts.append(block.text)
399 return "".join(parts) if parts else None
400
401 @classmethod
402 def _convert_messages(
403 cls,
404 messages: list,
405 openai_messages: list[dict[str, Any]],
406 *,
407 merge_inline_system: bool = False,
408 ) -> None:
409 """Convert Anthropic messages to OpenAI format."""
410 for msg in messages:
411 # Handle system messages in-place: extract text, strip billing
412 # headers, and only emit if there is real content. This avoids
413 # going through _convert_block / _convert_message_content which
414 # doesn't strip billing headers and may produce messages with
415 # no "content" key.
416 if msg.role == "system":
417 if merge_inline_system:
418 continue # already merged into top-level by _convert_system_message
419 text = cls._extract_system_text(msg)
420 if text:
421 openai_messages.append({"role": "system", "content": text})
422 continue
423
424 openai_msg: dict[str, Any] = {"role": msg.role} # type: ignore
425
426 if isinstance(msg.content, str):
427 openai_msg["content"] = msg.content
428 else:
429 cls._convert_message_content(msg, openai_msg, openai_messages)
430
431 if not (msg.role == "user" and "content" not in openai_msg):
432 openai_messages.append(openai_msg)
433
434 @classmethod
435 def _convert_message_content(
436 cls,
437 msg,
438 openai_msg: dict[str, Any],
439 openai_messages: list[dict[str, Any]],
440 ) -> None:
441 """Convert complex message content blocks."""
442 content_parts: list[dict[str, Any]] = []
443 tool_calls: list[dict[str, Any]] = []
444 reasoning_parts: list[str] = []
445
446 for block in msg.content:
447 cls._convert_block(
448 block,
449 msg.role,
450 content_parts,
451 tool_calls,
452 reasoning_parts,
453 openai_messages,
454 )
455
456 if reasoning_parts:
457 openai_msg["reasoning"] = "".join(reasoning_parts)
458
459 if tool_calls:
460 openai_msg["tool_calls"] = tool_calls # type: ignore
461
462 if content_parts:
463 if len(content_parts) == 1 and content_parts[0]["type"] == "text":
464 openai_msg["content"] = content_parts[0]["text"]
465 else:
466 openai_msg["content"] = content_parts # type: ignore
467 elif not tool_calls and not reasoning_parts:
468 return
469
470 @classmethod
471 def _convert_block(
472 cls,
473 block,
474 role: str,
475 content_parts: list[dict[str, Any]],
476 tool_calls: list[dict[str, Any]],
477 reasoning_parts: list[str],
478 openai_messages: list[dict[str, Any]],
479 ) -> None:
480 """Convert individual content block."""
481 if block.type == "text" and block.text:
482 content_parts.append({"type": "text", "text": block.text})
483 elif block.type == "image" and block.source:
484 image_url = cls._convert_image_source_to_url(block.source)
485 content_parts.append({"type": "image_url", "image_url": {"url": image_url}})
486 elif block.type == "thinking" and block.thinking is not None:
487 reasoning_parts.append(block.thinking)
488 elif block.type == "redacted_thinking":
489 # Redacted thinking blocks contain safety-filtered reasoning.
490 # We skip them as the content is opaque (base64 'data' field),
491 # but accepting the block prevents a validation error when the
492 # client echoes back the full assistant message.
493 pass
494 elif block.type == "tool_use":
495 cls._convert_tool_use_block(block, tool_calls)
496 elif block.type == "tool_result":
497 cls._convert_tool_result_block(block, role, openai_messages, content_parts)
498 elif block.type == "tool_reference":
499 # Tool references are expanded during tool_result processing
500 # when they appear inside tool_result content.
501 pass
502
503 @classmethod
504 def _convert_tool_use_block(cls, block, tool_calls: list[dict[str, Any]]) -> None:
505 """Convert tool_use block to OpenAI function call format."""
506 tool_call = {
507 "id": block.id or f"call_{int(time.time())}",
508 "type": "function",
509 "function": {
510 "name": block.name or "",
511 "arguments": json.dumps(block.input or {}),
512 },
513 }
514 tool_calls.append(tool_call)
515
516 @classmethod
517 def _convert_tool_result_block(
518 cls,
519 block,
520 role: str,
521 openai_messages: list[dict[str, Any]],
522 content_parts: list[dict[str, Any]],
523 ) -> None:
524 """Convert tool_result block to OpenAI format."""
525 if role == "user":
526 cls._convert_user_tool_result(block, openai_messages)
527 else:
528 tool_result_text = str(block.content) if block.content else ""
529 content_parts.append(
530 {"type": "text", "text": f"Tool result: {tool_result_text}"}
531 )
532
533 @classmethod
534 def _convert_user_tool_result(
535 cls, block, openai_messages: list[dict[str, Any]]
536 ) -> None:
537 """Convert user tool_result with text and image support."""
538 tool_text = ""
539 tool_image_urls: list[str] = []
540 tool_reference: list[dict[str, Any]] = []
541
542 if isinstance(block.content, str):
543 tool_text = block.content
544 elif isinstance(block.content, list):
545 text_parts: list[str] = []
546 for item in block.content:
547 if not isinstance(item, dict):
548 continue
549 item_type = item.get("type")
550 if item_type == "text":
551 text_parts.append(item.get("text", ""))
552 elif item_type == "image":
553 source = item.get("source", {})
554 url = cls._convert_image_source_to_url(source)
555 if url:
556 tool_image_urls.append(url)
557 elif item_type == "tool_reference":
558 ref_name = item.get("tool_name") or item.get("name")
559 if ref_name:
560 tool_reference.append(
561 {"type": "tool_reference", "name": ref_name}
562 )
563 tool_text = "\n".join(text_parts)
564
565 openai_messages.append(
566 {
567 "role": "tool",
568 "tool_call_id": block.tool_use_id or "",
569 "content": tool_text or "",
570 }
571 )
572
573 if tool_image_urls:
574 openai_messages.append(
575 {
576 "role": "user",
577 "content": [ # type: ignore[dict-item]
578 {"type": "image_url", "image_url": {"url": img}}
579 for img in tool_image_urls
580 ],
581 }
582 )
583
584 if tool_reference:
585 openai_messages.append(
586 {
587 "role": "tool",
588 "tool_call_id": block.tool_use_id or "",
589 "content": tool_reference, # type: ignore[dict-item]
590 }
591 )
592
593 @classmethod
594 def _build_base_request(
595 cls,
596 anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest,
597 openai_messages: list[dict[str, Any]],
598 ) -> ChatCompletionRequest:
599 """Build base ChatCompletionRequest."""
600 if isinstance(anthropic_request, AnthropicCountTokensRequest):
601 return ChatCompletionRequest(
602 model=anthropic_request.model,
603 messages=openai_messages,
604 chat_template_kwargs=anthropic_request.chat_template_kwargs,
605 )
606
607 return ChatCompletionRequest(
608 model=anthropic_request.model,
609 messages=openai_messages,
610 max_tokens=anthropic_request.max_tokens,
611 max_completion_tokens=anthropic_request.max_tokens,
612 stop=anthropic_request.stop_sequences,
613 temperature=anthropic_request.temperature,
614 top_p=anthropic_request.top_p,
615 top_k=anthropic_request.top_k,
616 cache_salt=anthropic_request.cache_salt,
617 kv_transfer_params=anthropic_request.kv_transfer_params,
618 ec_transfer_params=anthropic_request.ec_transfer_params,
619 vllm_xargs=anthropic_request.vllm_xargs,
620 chat_template_kwargs=anthropic_request.chat_template_kwargs,
621 )
622
623 @classmethod
624 def _handle_thinking(
625 cls,
626 req: ChatCompletionRequest,
627 anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest,
628 disabled_thinking_effort: AnthropicDisabledThinkingEffort = "none",
629 ) -> None:
630 """Handle extended-thinking configuration.
631
632 ``display`` is intentionally ignored: suppressing reasoning would mark
633 it ended for structured outputs and drop it from multi-turn history.
634 """
635 if isinstance(anthropic_request, AnthropicCountTokensRequest):
636 return
637 thinking: AnthropicThinkingConfig | None = anthropic_request.thinking
638 if thinking is None:
639 return
640
641 if thinking.type == "disabled":
642 # "none" clears enable_thinking for templates that honor it; models
643 # that cannot disable thinking are configured with a low effort.
644 req.reasoning_effort = disabled_thinking_effort
645 elif thinking.type == "enabled":
646 req.thinking_token_budget = thinking.budget_tokens
647 # "adaptive" pins nothing: the model chooses depth beneath the ceiling
648 # already set from output_config.effort.
649
650 @classmethod
651 def _handle_output_config(
652 cls,
653 req: ChatCompletionRequest,
654 anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest,
655 ) -> None:
656 """Handle output configuration such as output format and effort."""
657 if isinstance(anthropic_request, AnthropicCountTokensRequest):
658 return
659 output_config: AnthropicOutputConfig | None = anthropic_request.output_config
660 if output_config and output_config.format and output_config.format.json_schema:
661 req.response_format = ResponseFormat(
662 type=output_config.format.type,
663 json_schema=JsonSchemaResponseFormat(
664 schema=output_config.format.json_schema,
665 name=output_config.format.type,
666 ),
667 )
668 if output_config and output_config.effort is not None:
669 req.reasoning_effort = output_config.effort
670
671 @classmethod
672 def _handle_streaming_options(
673 cls,
674 req: ChatCompletionRequest,
675 anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest,
676 ) -> None:
677 """Handle streaming configuration."""
678 if isinstance(anthropic_request, AnthropicCountTokensRequest):
679 return
680 if anthropic_request.stream:
681 req.stream = anthropic_request.stream
682 req.stream_options = StreamOptions.model_validate(
683 {"include_usage": True, "continuous_usage_stats": True}
684 )
685
686 @classmethod
687 def _convert_tool_choice(
688 cls,
689 anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest,
690 req: ChatCompletionRequest,
691 ) -> None:
692 """Convert Anthropic tool_choice to OpenAI format."""
693 if anthropic_request.tool_choice is None:
694 req.tool_choice = None
695 return
696
697 req.parallel_tool_calls = (
698 not anthropic_request.tool_choice.disable_parallel_tool_use
699 )
700 tool_choice_type = anthropic_request.tool_choice.type
701 if tool_choice_type == "auto":
702 req.tool_choice = "auto"
703 elif tool_choice_type == "any":
704 req.tool_choice = "required"
705 elif tool_choice_type == "none":
706 req.tool_choice = "none"
707 elif tool_choice_type == "tool":
708 req.tool_choice = ChatCompletionNamedToolChoiceParam.model_validate(
709 {
710 "type": "function",
711 "function": {"name": anthropic_request.tool_choice.name},
712 }
713 )
714
715 @classmethod
716 def _resolve_tools(
717 cls,
718 anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest,
719 ) -> list[AnthropicTool] | None:
720 """Apply tool_addition/tool_removal blocks in ``messages`` to ``tools``.
721
722 Chat templates see a single tool list, so this resolves the tools in
723 effect at the end of the conversation: a tool added by reference is no
724 longer deferred, a tool added by value is declared, and a removed tool
725 is dropped.
726 """
727 changes = [
728 block
729 for msg in anthropic_request.messages
730 if not isinstance(msg.content, str)
731 for block in msg.content
732 if block.type in ("tool_addition", "tool_removal")
733 ]
734 if not changes:
735 return anthropic_request.tools
736
737 tools = {tool.name: tool for tool in anthropic_request.tools or []}
738 removed: set[str] = set()
739 for block in changes:
740 change = block.tool
741 assert change is not None
742 if isinstance(change, AnthropicToolChangeDefinition):
743 name = change.definition.name
744 tools[name] = change.definition
745 else:
746 name = change.name
747 if name not in tools:
748 raise ValueError(
749 f"{block.type} references tool {name!r}, "
750 "which is not declared in tools"
751 )
752 if block.type == "tool_addition":
753 tools[name] = tools[name].model_copy(update={"defer_loading": None})
754 if block.type == "tool_removal":
755 removed.add(name)
756 else:
757 removed.discard(name)
758 return [tool for name, tool in tools.items() if name not in removed]
759
760 @classmethod
761 def _convert_tools(
762 cls,
763 anthropic_request: AnthropicMessagesRequest | AnthropicCountTokensRequest,
764 req: ChatCompletionRequest,
765 ) -> None:
766 """Convert Anthropic tools to OpenAI format."""
767 anthropic_tools = cls._resolve_tools(anthropic_request)
768 if anthropic_tools is None:
769 return
770
771 tools = []
772 for tool in anthropic_tools:
773 tools.append(
774 ChatCompletionToolsParam.model_validate(
775 {
776 "type": "function",
777 "function": {
778 "name": tool.name,
779 "description": tool.description,
780 "parameters": tool.input_schema,
781 "strict": tool.strict,
782 "defer_loading": tool.defer_loading,
783 },
784 }
785 )
786 )
787
788 if req.tool_choice is None:
789 req.tool_choice = "auto"
790 req.tools = tools
791
792 async def create_messages(
793 self,
794 request: AnthropicMessagesRequest,
795 raw_request: Request | None = None,
796 ) -> AsyncGenerator[str, None] | AnthropicMessagesResponse | ErrorResponse:
797 """Messages API similar to Anthropic's API.
798
799 See https://docs.anthropic.com/en/api/messages
800 for the API specification. This API mimics the Anthropic messages API.
801 """
802 if logger.isEnabledFor(logging.DEBUG):
803 logger.debug("Received messages request %s", request.model_dump_json())
804 disabled_thinking_effort: AnthropicDisabledThinkingEffort = "none"
805 if request.thinking is not None and request.thinking.type == "disabled":
806 disabled_thinking_effort = await self._get_disabled_thinking_effort()
807 chat_req = self.to_chat_completion_request(
808 request,
809 merge_inline_system=self._merge_inline_system,
810 disabled_thinking_effort=disabled_thinking_effort,
811 )
812 if logger.isEnabledFor(logging.DEBUG):
813 logger.debug("Convert to OpenAI request %s", chat_req.model_dump_json())
814 generator = await self.create_chat_completion(chat_req, raw_request)
815
816 if isinstance(generator, ErrorResponse):
817 return generator
818
819 elif isinstance(generator, ChatCompletionResponse):
820 return self.messages_full_converter(generator)
821
822 return self.message_stream_converter(generator)
823
824 def messages_full_converter(
825 self,
826 generator: ChatCompletionResponse,
827 ) -> AnthropicMessagesResponse:
828 result = AnthropicMessagesResponse(
829 id=generator.id,
830 content=[],
831 model=generator.model,
832 usage=_build_anthropic_usage(generator.usage),
833 kv_transfer_params=generator.kv_transfer_params,
834 ec_transfer_params=generator.ec_transfer_params,
835 )
836 choice = generator.choices[0]
837 result.stop_reason, result.stop_sequence = _to_anthropic_stop_reason(
838 choice.finish_reason,
839 choice.stop_reason,
840 tool_used=bool(choice.message.tool_calls),
841 # Their arguments are json-decoded below, so parsed calls are complete.
842 tool_complete=bool(choice.message.tool_calls),
843 )
844
845 content: list[AnthropicContentBlock] = []
846 if choice.message.reasoning:
847 content.append(
848 AnthropicContentBlock(
849 type="thinking",
850 thinking=choice.message.reasoning,
851 signature=uuid.uuid4().hex,
852 )
853 )
854 if choice.message.content:
855 content.append(
856 AnthropicContentBlock(
857 type="text",
858 text=choice.message.content,
859 )
860 )
861
862 for tool_call in choice.message.tool_calls:
863 anthropic_tool_call = AnthropicContentBlock(
864 type="tool_use",
865 id=tool_call.id,
866 name=tool_call.function.name,
867 input=json.loads(tool_call.function.arguments),
868 )
869 content += [anthropic_tool_call]
870
871 # Anthropic's canonical shape for an empty completion is a single
872 # empty text block, not []. Some strict clients assume content[0]
873 # exists, so emit one here.
874 if not content:
875 content.append(AnthropicContentBlock(type="text", text=""))
876
877 result.content = content
878
879 return result
880
881 async def message_stream_converter(
882 self,
883 generator: AsyncGenerator[str, None],
884 ) -> AsyncGenerator[str, None]:
885 try:
886
887 class _ActiveBlockState:
888 def __init__(self) -> None:
889 self.content_block_index = 0
890 self.block_type: str | None = None
891 self.block_index: int | None = None
892 self.block_signature: str | None = None
893 self.signature_emitted: bool = False
894 self.tool_use_id: str | None = None
895 self.pending_content: list[str] = []
896
897 def reset(self) -> None:
898 self.block_type = None
899 self.block_index = None
900 self.block_signature = None
901 self.signature_emitted = False
902 self.tool_use_id = None
903 self.pending_content.clear()
904
905 def start(self, block: AnthropicContentBlock) -> None:
906 self.block_type = block.type
907 self.block_index = self.content_block_index
908 if block.type == "thinking":
909 self.block_signature = uuid.uuid4().hex
910 self.signature_emitted = False
911 self.tool_use_id = None
912 elif block.type == "tool_use":
913 self.block_signature = None
914 self.signature_emitted = True
915 self.tool_use_id = block.id
916 else:
917 self.block_signature = None
918 self.signature_emitted = True
919 self.tool_use_id = None
920
921 first_item = True
922 finish_reason = None
923 # Matched stop string, when generation stopped on one (a str);
924 # int stop-token-id / None are not stop sequences.
925 stop_sequence: int | str | None = None
926 tool_used = False
927 # Arguments of the latest tool_use block; only the last call can be
928 # cut short by a stop string.
929 last_tool_args: list[str] = []
930 state = _ActiveBlockState()
931 # Map from tool call index to tool_use_id
932 tool_index_to_id: dict[int, str] = {}
933
934 def stop_active_block():
935 events: list[str] = []
936 if state.block_type is None:
937 return events
938 if (
939 state.block_type == "thinking"
940 and state.block_signature is not None
941 and not state.signature_emitted
942 ):
943 chunk = AnthropicStreamEvent(
944 index=state.block_index,
945 type="content_block_delta",
946 delta=AnthropicDelta(
947 type="signature_delta",
948 signature=state.block_signature,
949 ),
950 )
951 data = chunk.model_dump_json(exclude_unset=True)
952 events.append(wrap_data_with_event(data, "content_block_delta"))
953 state.signature_emitted = True
954 stop_chunk = AnthropicStreamEvent(
955 index=state.block_index,
956 type="content_block_stop",
957 )
958 data = stop_chunk.model_dump_json(exclude_unset=True)
959 events.append(wrap_data_with_event(data, "content_block_stop"))
960 state.reset()
961 state.content_block_index += 1
962 return events
963
964 def start_block(block: AnthropicContentBlock):
965 chunk = AnthropicStreamEvent(
966 index=state.content_block_index,
967 type="content_block_start",
968 content_block=block,
969 )
970 data = chunk.model_dump_json(exclude_unset=True)
971 event = wrap_data_with_event(data, "content_block_start")
972 state.start(block)
973 return event
974
975 def stop_and_flush() -> list[str]:
976 buffered = list(state.pending_content)
977 state.pending_content.clear()
978 events = stop_active_block()
979 if not buffered:
980 return events
981 text = "".join(buffered)
982 events.append(start_block(AnthropicContentBlock(type="text", text="")))
983 pc_chunk = AnthropicStreamEvent(
984 index=state.block_index,
985 type="content_block_delta",
986 delta=AnthropicDelta(type="text_delta", text=text),
987 )
988 pc_data = pc_chunk.model_dump_json(exclude_unset=True)
989 events.append(wrap_data_with_event(pc_data, "content_block_delta"))
990 events.extend(stop_active_block())
991 return events
992
993 async for item in generator:
994 if item.startswith("data:"):
995 data_str = item[5:].strip().rstrip("\n")
996 if data_str == "[DONE]":
997 for event in stop_and_flush():
998 yield event
999 stop_message = AnthropicStreamEvent(
1000 type="message_stop",
1001 )
1002 data = stop_message.model_dump_json(
1003 exclude_unset=True, exclude_none=True
1004 )
1005 yield wrap_data_with_event(data, "message_stop")
1006 else:
1007 origin_chunk = ChatCompletionStreamResponse.model_validate_json(
1008 data_str
1009 )
1010
1011 if first_item:
1012 chunk = AnthropicStreamEvent(
1013 type="message_start",
1014 message=AnthropicMessagesResponse(
1015 id=origin_chunk.id,
1016 # Set explicitly: this event is serialized
1017 # with exclude_unset=True, which drops
1018 # default-valued fields, while strict
1019 # Anthropic SDK clients require
1020 # message.type/role (issue #45367).
1021 type="message",
1022 role="assistant",
1023 content=[],
1024 model=origin_chunk.model,
1025 stop_reason=None,
1026 stop_sequence=None,
1027 usage=_build_anthropic_usage(origin_chunk.usage),
1028 ),
1029 )
1030 first_item = False
1031 data = chunk.model_dump_json(exclude_unset=True)
1032 yield wrap_data_with_event(data, "message_start")
1033 continue
1034
1035 # last chunk including usage info
1036 if len(origin_chunk.choices) == 0:
1037 for event in stop_and_flush():
1038 yield event
1039 anthropic_stop_reason, anthropic_stop_sequence = (
1040 _to_anthropic_stop_reason(
1041 finish_reason or "stop",
1042 stop_sequence,
1043 tool_used=tool_used,
1044 tool_complete=(
1045 tool_used and _is_json("".join(last_tool_args))
1046 ),
1047 )
1048 )
1049 stop_delta = AnthropicDelta(
1050 stop_reason=anthropic_stop_reason,
1051 # Set explicitly so exclude_unset=True keeps it.
1052 stop_sequence=anthropic_stop_sequence,
1053 )
1054 chunk = AnthropicStreamEvent(
1055 type="message_delta",
1056 delta=stop_delta,
1057 usage=_build_anthropic_usage(origin_chunk.usage),
1058 )
1059 data = chunk.model_dump_json(exclude_unset=True)
1060 yield wrap_data_with_event(data, "message_delta")
1061 continue
1062
1063 if origin_chunk.choices[0].finish_reason is not None:
1064 finish_reason = origin_chunk.choices[0].finish_reason
1065 stop_sequence = origin_chunk.choices[0].stop_reason
1066 # continue
1067
1068 # thinking / text content
1069 reasoning_delta = origin_chunk.choices[0].delta.reasoning
1070 if reasoning_delta is not None:
1071 if reasoning_delta == "":
1072 pass
1073 else:
1074 if state.block_type != "thinking":
1075 for event in stop_and_flush():
1076 yield event
1077 start_event = start_block(
1078 AnthropicContentBlock(
1079 type="thinking", thinking=""
1080 )
1081 )
1082 yield start_event
1083 chunk = AnthropicStreamEvent(
1084 index=(
1085 state.block_index
1086 if state.block_index is not None
1087 else state.content_block_index
1088 ),
1089 type="content_block_delta",
1090 delta=AnthropicDelta(
1091 type="thinking_delta",
1092 thinking=reasoning_delta,
1093 ),
1094 )
1095 data = chunk.model_dump_json(exclude_unset=True)
1096 yield wrap_data_with_event(data, "content_block_delta")
1097
1098 if origin_chunk.choices[0].delta.content is not None:
1099 if origin_chunk.choices[0].delta.content == "":
1100 pass
1101 elif state.block_type == "tool_use":
1102 state.pending_content.append(
1103 origin_chunk.choices[0].delta.content
1104 )
1105 else:
1106 if state.block_type != "text":
1107 for event in stop_and_flush():
1108 yield event
1109 start_event = start_block(
1110 AnthropicContentBlock(type="text", text="")
1111 )
1112 yield start_event
1113 chunk = AnthropicStreamEvent(
1114 index=(
1115 state.block_index
1116 if state.block_index is not None
1117 else state.content_block_index
1118 ),
1119 type="content_block_delta",
1120 delta=AnthropicDelta(
1121 type="text_delta",
1122 text=origin_chunk.choices[0].delta.content,
1123 ),
1124 )
1125 data = chunk.model_dump_json(exclude_unset=True)
1126 yield wrap_data_with_event(data, "content_block_delta")
1127
1128 # tool calls - process all tool calls in the delta
1129 if len(origin_chunk.choices[0].delta.tool_calls) > 0:
1130 for tool_call in origin_chunk.choices[0].delta.tool_calls:
1131 if tool_call.id is not None:
1132 # Update mapping for incremental updates
1133 tool_index_to_id[tool_call.index] = tool_call.id
1134 # Only create new block if different tool call
1135 # AND has a name
1136 tool_name = (
1137 tool_call.function.name
1138 if tool_call.function
1139 else None
1140 )
1141 if (
1142 state.tool_use_id != tool_call.id
1143 and tool_name is not None
1144 ):
1145 for event in stop_and_flush():
1146 yield event
1147 tool_used = True
1148 last_tool_args = []
1149 start_event = start_block(
1150 AnthropicContentBlock(
1151 type="tool_use",
1152 id=tool_call.id,
1153 name=tool_name,
1154 input={},
1155 )
1156 )
1157 yield start_event
1158 # Handle initial arguments if present
1159 if (
1160 tool_call.function
1161 and tool_call.function.arguments
1162 and state.tool_use_id == tool_call.id
1163 ):
1164 last_tool_args.append(
1165 tool_call.function.arguments
1166 )
1167 chunk = AnthropicStreamEvent(
1168 index=(
1169 state.block_index
1170 if state.block_index is not None
1171 else state.content_block_index
1172 ),
1173 type="content_block_delta",
1174 delta=AnthropicDelta(
1175 type="input_json_delta",
1176 partial_json=tool_call.function.arguments,
1177 ),
1178 )
1179 data = chunk.model_dump_json(exclude_unset=True)
1180 yield wrap_data_with_event(
1181 data, "content_block_delta"
1182 )
1183 else:
1184 # Incremental update - use index to find tool_use_id
1185 tool_use_id = tool_index_to_id.get(tool_call.index)
1186 if (
1187 tool_use_id is not None
1188 and tool_call.function
1189 and tool_call.function.arguments
1190 and state.tool_use_id == tool_use_id
1191 ):
1192 last_tool_args.append(
1193 tool_call.function.arguments
1194 )
1195 chunk = AnthropicStreamEvent(
1196 index=(
1197 state.block_index
1198 if state.block_index is not None
1199 else state.content_block_index
1200 ),
1201 type="content_block_delta",
1202 delta=AnthropicDelta(
1203 type="input_json_delta",
1204 partial_json=tool_call.function.arguments,
1205 ),
1206 )
1207 data = chunk.model_dump_json(exclude_unset=True)
1208 yield wrap_data_with_event(
1209 data, "content_block_delta"
1210 )
1211 continue
1212 else:
1213 error_response = AnthropicStreamEvent(
1214 type="error",
1215 error=AnthropicError(
1216 type="internal_error",
1217 message="Invalid data format received",
1218 ),
1219 )
1220 data = error_response.model_dump_json(exclude_unset=True)
1221 yield wrap_data_with_event(data, "error")
1222
1223 except Exception as e:
1224 logger.exception("Error in message stream converter.")
1225 error_response = AnthropicStreamEvent(
1226 type="error",
1227 error=AnthropicError(
1228 type="internal_error", message=sanitize_message(str(e))
1229 ),
1230 )
1231 data = error_response.model_dump_json(exclude_unset=True)
1232 yield wrap_data_with_event(data, "error")
1233
1234 async def count_tokens(
1235 self,
1236 request: AnthropicCountTokensRequest,
1237 raw_request: Request | None = None,
1238 ) -> AnthropicCountTokensResponse | ErrorResponse:
1239 """Implements Anthropic's messages.count_tokens endpoint."""
1240 chat_req = self.to_chat_completion_request(
1241 request,
1242 merge_inline_system=self._merge_inline_system,
1243 )
1244 result = await self.render_chat_request(chat_req)
1245 if isinstance(result, ErrorResponse):
1246 return result
1247
1248 _, engine_inputs = result
1249
1250 input_tokens = sum( # type: ignore
1251 len(engine_input["prompt_token_ids"]) # type: ignore[typeddict-item, misc]
1252 for engine_input in engine_inputs
1253 if "prompt_token_ids" in engine_input
1254 )
1255
1256 response = AnthropicCountTokensResponse(
1257 input_tokens=input_tokens,
1258 context_management=AnthropicContextManagement(
1259 original_input_tokens=input_tokens
1260 ),
1261 )
1262
1263 return response