修改
This commit is contained in:
+104
-14
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user