This commit is contained in:
hupenglong1
2026-05-22 19:48:47 +08:00
parent 20690cdef3
commit 5311e6d97c
31 changed files with 4620 additions and 472 deletions
+104 -14
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json
import os
import sys
from typing import Any, Iterator
from urllib import error, request
@@ -31,7 +32,7 @@ ANTHROPIC_MESSAGES_MODEL_PREFIXES = (
)
ANTHROPIC_VERSION = '2023-06-01'
ANTHROPIC_MAX_TOKENS = 4096
ANTHROPIC_MAX_TOKENS = 16384
DEFAULT_MODEL_IDLE_TIMEOUT_SECONDS = 60.0
@@ -218,6 +219,57 @@ def _uses_anthropic_messages_api(model: str, base_url: str) -> bool:
)
_UNKNOWN_EVENT_LOG_LIMIT = 8
_unknown_event_seen: dict[str, int] = {}
def _log_unknown_anthropic_event(kind: str, payload: dict[str, Any]) -> None:
"""Emit a one-line stderr warning the first few times we see an unknown event.
The streaming handler used to silently drop any block/delta type it didn't
recognise. When the upstream proxy started emitting frames we didn't decode,
the model's output budget was burned with no observable effect. This log
surfaces the situation without flooding logs on every turn.
"""
seen = _unknown_event_seen.get(kind, 0)
if seen >= _UNKNOWN_EVENT_LOG_LIMIT:
return
_unknown_event_seen[kind] = seen + 1
try:
snippet = json.dumps(payload, ensure_ascii=False)[:400]
except Exception: # pragma: no cover - defensive
snippet = repr(payload)[:400]
print(
f'[openai_compat] unknown anthropic stream event {kind!r}: {snippet}',
file=sys.stderr,
flush=True,
)
def _mark_last_message_cache_breakpoint(messages: list[dict[str, Any]]) -> None:
"""Attach cache_control to the last content block of the last message.
Anthropic prompt cache requires the breakpoint at the *end* of the cacheable
prefix. The next turn appends a new message → the previous last becomes the
second-to-last with its breakpoint still in the prefix, so everything up to
that point is reused on the next call.
"""
if not messages:
return
last = messages[-1]
content = last.get('content')
if not isinstance(content, list) or not content:
return
last_block = content[-1]
if not isinstance(last_block, dict):
return
new_block = dict(last_block)
new_block['cache_control'] = {'type': 'ephemeral'}
new_content = list(content)
new_content[-1] = new_block
last['content'] = new_content
def _anthropic_base_url(base_url: str) -> str:
base = base_url.rstrip('/')
if base.lower().endswith('/anthropic'):
@@ -486,25 +538,46 @@ class OpenAICompatClient:
if blocks:
self._append_anthropic_message(anthropic_messages, 'user', blocks)
system_text = '\n\n'.join(system_parts) if system_parts else ''
if output_schema is not None:
schema_hint = (
f'请严格输出符合 JSON Schema `{output_schema.name}` 的 JSON'
'不要输出额外解释。'
)
system_text = f'{system_text}\n\n{schema_hint}'.strip() if system_text else schema_hint
cache_enabled = (
os.environ.get('CLAW_DISABLE_PROMPT_CACHE', '').strip().lower()
not in {'1', 'true', 'yes', 'on'}
)
payload: dict[str, Any] = {
'model': self.config.model,
'messages': anthropic_messages,
'max_tokens': ANTHROPIC_MAX_TOKENS,
'stream': stream,
}
if system_parts:
payload['system'] = '\n\n'.join(system_parts)
if system_text:
if cache_enabled:
payload['system'] = [
{
'type': 'text',
'text': system_text,
'cache_control': {'type': 'ephemeral'},
}
]
else:
payload['system'] = system_text
converted_tools = self._anthropic_tools(tools)
if converted_tools:
if cache_enabled:
converted_tools = list(converted_tools)
last_tool = dict(converted_tools[-1])
last_tool['cache_control'] = {'type': 'ephemeral'}
converted_tools[-1] = last_tool
payload['tools'] = converted_tools
if output_schema is not None:
schema_hint = (
f'请严格输出符合 JSON Schema `{output_schema.name}` 的 JSON'
'不要输出额外解释。'
)
payload['system'] = (
f'{payload.get("system", "")}\n\n{schema_hint}'.strip()
)
if cache_enabled and anthropic_messages:
_mark_last_message_cache_breakpoint(anthropic_messages)
return payload
def _anthropic_headers(self) -> dict[str, str]:
@@ -604,7 +677,12 @@ class OpenAICompatClient:
content_block = event_payload.get('content_block')
if not isinstance(block_index, int) or not isinstance(content_block, dict):
continue
if content_block.get('type') != 'tool_use':
block_type = content_block.get('type')
if block_type != 'tool_use':
if block_type not in ('text', 'thinking', 'redacted_thinking'):
_log_unknown_anthropic_event(
'content_block_start', event_payload
)
continue
tool_index = next_tool_index
next_tool_index += 1
@@ -636,7 +714,8 @@ class OpenAICompatClient:
delta = event_payload.get('delta')
if not isinstance(delta, dict):
continue
if delta.get('type') == 'text_delta':
delta_type = delta.get('type')
if delta_type == 'text_delta':
text = delta.get('text')
if isinstance(text, str) and text:
yield StreamEvent(
@@ -645,7 +724,7 @@ class OpenAICompatClient:
raw_event=event_payload,
)
continue
if delta.get('type') == 'input_json_delta':
if delta_type == 'input_json_delta':
block_index = event_payload.get('index')
partial_json = delta.get('partial_json')
if (
@@ -661,6 +740,17 @@ class OpenAICompatClient:
raw_event=event_payload,
)
continue
if delta_type in ('thinking_delta', 'signature_delta'):
# Extended-thinking blocks: we don't surface them as
# content (the runtime has nowhere to put them), but
# they previously vanished silently and burned the
# whole 4k output budget before tool_use could start.
# Logging once is enough to confirm they're flowing.
continue
_log_unknown_anthropic_event(
f'content_block_delta:{delta_type}', event_payload
)
continue
if event_type == 'message_delta':
delta = event_payload.get('delta')
if isinstance(delta, dict) and isinstance(delta.get('stop_reason'), str):