evidor 1.10.4.dev1__tar.gz → 1.10.5.dev1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/CHANGELOG.md +14 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/PKG-INFO +1 -1
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/pyproject.toml +1 -1
- evidor-1.10.5.dev1/src/evidor/agent/providers/anthropic/builder.py +209 -0
- evidor-1.10.5.dev1/src/evidor/agent/providers/anthropic/parser.py +178 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/anthropic/provider.py +33 -21
- evidor-1.10.5.dev1/src/evidor/agent/providers/anthropic/stream.py +239 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/base.py +4 -1
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/models/generation.py +1 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_providers.py +399 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_streaming.py +163 -0
- evidor-1.10.4.dev1/src/evidor/agent/providers/anthropic/builder.py +0 -120
- evidor-1.10.4.dev1/src/evidor/agent/providers/anthropic/parser.py +0 -102
- evidor-1.10.4.dev1/src/evidor/agent/providers/anthropic/stream.py +0 -67
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/.github/workflows/release.yml +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/.gitignore +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/AGENTS.md +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/CODE_OF_CONDUCT.md +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/LICENSE +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/README.md +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/requirements.txt +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/core/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/core/agent.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/core/context.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/core/runner.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/core/telemetry_bridge.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/anthropic/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/client_pool.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/gemini/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/gemini/builder.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/gemini/parser.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/gemini/provider.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/gemini/stream.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/openai/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/openai/builder.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/openai/parser.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/openai/provider.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/openai/stream.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/constants.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/errors.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/mcp/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/mcp/client.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/mcp/config.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/mcp/runner.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/mcp/session.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/models/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/models/streaming.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/models/thinking.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/adapters/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/adapters/langfuse.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/adapters/opentelemetry.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/adapters/phoenix.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/adapters/prometheus.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/context.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/events.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/runtime.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/semconv.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/sink.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/core/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/core/tool_executor.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/core/tools.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/core/utils.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/calculator.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/evidence_store.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/_common.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/list_files.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/mutate_files.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/read_file.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/search_files.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/get_current_time.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/read_webpage.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/_base.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/http.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/providers/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/providers/brave.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/providers/exa.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/providers/tavily.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/tool.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/utils/__init__.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/utils/execution.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/utils/schema.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_agent.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_builtin_tools.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_conversation.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_filesystem_tools.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_langfuse_adapter.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_mcp.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_models.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_native_tools.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_opentelemetry_adapter.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_phoenix_adapter.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_prometheus_adapter.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_telemetry_events.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_telemetry_integration.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_thinking.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_tools.py +0 -0
- {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_web_search.py +0 -0
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v1.10.5-dev.1 (2026-10-29)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- **anthropic**: Fixing parser and request builder issues
|
|
10
|
+
([`5bd9e5d`](https://github.com/vedangiitb/evidor-core/commit/5bd9e5d3a100371ad8656cf097f369cf6cffd469))
|
|
11
|
+
|
|
12
|
+
- **anthropic**: Fixing streaming issues
|
|
13
|
+
([`f8f0267`](https://github.com/vedangiitb/evidor-core/commit/f8f0267bd20a9613dd72aac6a1cd52957a47d106))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## v1.10.4 (2026-10-29)
|
|
17
|
+
|
|
18
|
+
|
|
5
19
|
## v1.10.4-dev.1 (2026-10-29)
|
|
6
20
|
|
|
7
21
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: evidor
|
|
3
|
-
Version: 1.10.
|
|
3
|
+
Version: 1.10.5-dev.1
|
|
4
4
|
Summary: A small, provider-agnostic harness for LLM input and output.
|
|
5
5
|
Project-URL: Homepage, https://github.com/evidor/evidor-core
|
|
6
6
|
Project-URL: Repository, https://github.com/evidor/evidor-core
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Request payload builder for Anthropic Claude."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from evidor.models import GenerationRequest, Message
|
|
8
|
+
from ..base import extract_system_prompt, sort_tools
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AnthropicPayloadBuilder:
|
|
12
|
+
"""Builds and formats API request payloads for Anthropic's Messages API."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
model: str,
|
|
17
|
+
max_tokens: int,
|
|
18
|
+
enable_prompt_caching: bool = True,
|
|
19
|
+
thinking: Any = None,
|
|
20
|
+
compact_threshold: int | None = None,
|
|
21
|
+
compact_instructions: str | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.model = model
|
|
24
|
+
self.max_tokens = max_tokens
|
|
25
|
+
self.enable_prompt_caching = enable_prompt_caching
|
|
26
|
+
self.thinking = thinking
|
|
27
|
+
self.compact_threshold = compact_threshold
|
|
28
|
+
self.compact_instructions = compact_instructions
|
|
29
|
+
|
|
30
|
+
def build(self, request: GenerationRequest) -> dict[str, Any]:
|
|
31
|
+
"""Construct the full request dictionary for Anthropic Messages API."""
|
|
32
|
+
system_prompt = extract_system_prompt(request)
|
|
33
|
+
formatted_messages = self.format_messages(request.messages)
|
|
34
|
+
|
|
35
|
+
payload: dict[str, Any] = {
|
|
36
|
+
"model": self.model,
|
|
37
|
+
"max_tokens": self.max_tokens,
|
|
38
|
+
"system": system_prompt or None,
|
|
39
|
+
"messages": formatted_messages,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if self.compact_threshold is not None or self.compact_instructions is not None:
|
|
43
|
+
edit: dict[str, Any] = {"type": "compact_20260112"}
|
|
44
|
+
if self.compact_threshold is not None:
|
|
45
|
+
edit["trigger"] = {"type": "input_tokens", "value": self.compact_threshold}
|
|
46
|
+
if self.compact_instructions is not None:
|
|
47
|
+
edit["instructions"] = self.compact_instructions
|
|
48
|
+
payload["betas"] = ["compact-2026-01-12"]
|
|
49
|
+
payload["context_management"] = {"edits": [edit]}
|
|
50
|
+
|
|
51
|
+
if self.thinking and self.thinking.enabled:
|
|
52
|
+
if getattr(self.thinking, "type", "enabled") == "adaptive":
|
|
53
|
+
payload["thinking"] = {"type": "adaptive"}
|
|
54
|
+
if getattr(self.thinking, "effort", None):
|
|
55
|
+
payload["output_config"] = {"effort": self.thinking.effort}
|
|
56
|
+
else:
|
|
57
|
+
budget = getattr(self.thinking, "budget_tokens", None)
|
|
58
|
+
if budget is None or budget < 1024:
|
|
59
|
+
budget = 1024
|
|
60
|
+
payload["thinking"] = {
|
|
61
|
+
"type": "enabled",
|
|
62
|
+
"budget_tokens": budget,
|
|
63
|
+
}
|
|
64
|
+
# Anthropic Messages API requires max_tokens > budget_tokens
|
|
65
|
+
if payload["max_tokens"] <= budget:
|
|
66
|
+
payload["max_tokens"] = budget + 1024
|
|
67
|
+
|
|
68
|
+
if self.enable_prompt_caching:
|
|
69
|
+
payload["cache_control"] = {"type": "ephemeral"}
|
|
70
|
+
|
|
71
|
+
if request.tools:
|
|
72
|
+
sorted_tools = sort_tools(request.tools)
|
|
73
|
+
payload["tools"] = [self._format_tool(t) for t in sorted_tools]
|
|
74
|
+
|
|
75
|
+
return payload
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _format_tool(tool: Any) -> dict[str, Any]:
|
|
79
|
+
"""Format a tool object or dictionary into an Anthropic tool schema."""
|
|
80
|
+
if isinstance(tool, dict):
|
|
81
|
+
name = tool.get("name", "")
|
|
82
|
+
description = tool.get("description", "")
|
|
83
|
+
schema = tool.get("input_schema") or tool.get("parameters")
|
|
84
|
+
else:
|
|
85
|
+
name = getattr(tool, "name", "")
|
|
86
|
+
description = getattr(tool, "description", "")
|
|
87
|
+
schema = getattr(tool, "parameters", None) or getattr(tool, "input_schema", None)
|
|
88
|
+
|
|
89
|
+
if not isinstance(schema, dict) or not schema:
|
|
90
|
+
schema = {"type": "object", "properties": {}}
|
|
91
|
+
|
|
92
|
+
tool_dict: dict[str, Any] = {
|
|
93
|
+
"name": name,
|
|
94
|
+
"description": description or "",
|
|
95
|
+
"input_schema": schema,
|
|
96
|
+
}
|
|
97
|
+
return tool_dict
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _normalize_thought_block(tb: Any) -> dict[str, Any] | None:
|
|
101
|
+
"""Convert a thought block (dict, Pydantic model, SDK object) to an Anthropic block dict."""
|
|
102
|
+
if isinstance(tb, dict):
|
|
103
|
+
return dict(tb)
|
|
104
|
+
if hasattr(tb, "model_dump") and callable(tb.model_dump):
|
|
105
|
+
dumped = tb.model_dump()
|
|
106
|
+
if isinstance(dumped, dict):
|
|
107
|
+
return dumped
|
|
108
|
+
if hasattr(tb, "to_dict") and callable(tb.to_dict):
|
|
109
|
+
dumped = tb.to_dict()
|
|
110
|
+
if isinstance(dumped, dict):
|
|
111
|
+
return dumped
|
|
112
|
+
if hasattr(tb, "type"):
|
|
113
|
+
block: dict[str, Any] = {"type": getattr(tb, "type")}
|
|
114
|
+
if hasattr(tb, "thinking") and getattr(tb, "thinking") is not None:
|
|
115
|
+
block["thinking"] = getattr(tb, "thinking")
|
|
116
|
+
if hasattr(tb, "signature") and getattr(tb, "signature") is not None:
|
|
117
|
+
block["signature"] = getattr(tb, "signature")
|
|
118
|
+
if hasattr(tb, "data") and getattr(tb, "data") is not None:
|
|
119
|
+
block["data"] = getattr(tb, "data")
|
|
120
|
+
return block
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def format_messages(self, messages: Sequence[Message]) -> list[dict[str, Any]]:
|
|
124
|
+
"""Format domain messages into Anthropic-compliant message items."""
|
|
125
|
+
formatted: list[dict[str, Any]] = []
|
|
126
|
+
for message in messages:
|
|
127
|
+
if message.role == "system":
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
if message.role == "tool":
|
|
131
|
+
tool_use_id = message.tool_call_id or "call_unknown"
|
|
132
|
+
content_val = (
|
|
133
|
+
message.content
|
|
134
|
+
if isinstance(message.content, (str, list))
|
|
135
|
+
else ("" if message.content is None else json.dumps(message.content))
|
|
136
|
+
)
|
|
137
|
+
tool_block: dict[str, Any] = {
|
|
138
|
+
"type": "tool_result",
|
|
139
|
+
"tool_use_id": tool_use_id,
|
|
140
|
+
"content": content_val,
|
|
141
|
+
}
|
|
142
|
+
if getattr(message, "is_error", False):
|
|
143
|
+
tool_block["is_error"] = True
|
|
144
|
+
|
|
145
|
+
if formatted and formatted[-1]["role"] == "user":
|
|
146
|
+
prev_content = formatted[-1]["content"]
|
|
147
|
+
if isinstance(prev_content, list):
|
|
148
|
+
prev_content.append(tool_block)
|
|
149
|
+
elif isinstance(prev_content, str):
|
|
150
|
+
formatted[-1]["content"] = [
|
|
151
|
+
{"type": "text", "text": prev_content},
|
|
152
|
+
tool_block,
|
|
153
|
+
]
|
|
154
|
+
else:
|
|
155
|
+
formatted[-1]["content"] = [tool_block]
|
|
156
|
+
else:
|
|
157
|
+
formatted.append({
|
|
158
|
+
"role": "user",
|
|
159
|
+
"content": [tool_block],
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
elif message.role == "assistant":
|
|
163
|
+
if message.thought_blocks or message.tool_calls or message.compaction:
|
|
164
|
+
content_blocks: list[dict[str, Any]] = []
|
|
165
|
+
if message.compaction:
|
|
166
|
+
content_blocks.append({"type": "compaction", "content": message.compaction})
|
|
167
|
+
# Replay thought_blocks with cryptographic signatures if present (must precede text/tools)
|
|
168
|
+
if message.thought_blocks:
|
|
169
|
+
for tb in message.thought_blocks:
|
|
170
|
+
normalized = self._normalize_thought_block(tb)
|
|
171
|
+
if normalized is not None:
|
|
172
|
+
content_blocks.append(normalized)
|
|
173
|
+
if message.content:
|
|
174
|
+
content_blocks.append({"type": "text", "text": message.content})
|
|
175
|
+
if message.tool_calls:
|
|
176
|
+
for tc in message.tool_calls:
|
|
177
|
+
content_blocks.append({
|
|
178
|
+
"type": "tool_use",
|
|
179
|
+
"id": tc.id,
|
|
180
|
+
"name": tc.name,
|
|
181
|
+
"input": tc.arguments,
|
|
182
|
+
})
|
|
183
|
+
formatted.append({"role": "assistant", "content": content_blocks})
|
|
184
|
+
else:
|
|
185
|
+
formatted.append({"role": "assistant", "content": message.content})
|
|
186
|
+
|
|
187
|
+
else:
|
|
188
|
+
# User or other role
|
|
189
|
+
if formatted and formatted[-1]["role"] == "user":
|
|
190
|
+
prev_content = formatted[-1]["content"]
|
|
191
|
+
if isinstance(prev_content, list):
|
|
192
|
+
if isinstance(message.content, list):
|
|
193
|
+
prev_content.extend(message.content)
|
|
194
|
+
elif message.content:
|
|
195
|
+
prev_content.append({"type": "text", "text": message.content})
|
|
196
|
+
elif isinstance(prev_content, str):
|
|
197
|
+
if isinstance(message.content, str):
|
|
198
|
+
formatted[-1]["content"] = f"{prev_content}\n\n{message.content}"
|
|
199
|
+
elif isinstance(message.content, list):
|
|
200
|
+
formatted[-1]["content"] = [{"type": "text", "text": prev_content}] + list(message.content)
|
|
201
|
+
elif message.content:
|
|
202
|
+
formatted[-1]["content"] = [
|
|
203
|
+
{"type": "text", "text": prev_content},
|
|
204
|
+
{"type": "text", "text": str(message.content)},
|
|
205
|
+
]
|
|
206
|
+
else:
|
|
207
|
+
formatted.append({"role": message.role, "content": message.content})
|
|
208
|
+
|
|
209
|
+
return formatted
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Response parser for Anthropic Claude."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from evidor.models import GenerationResponse, ToolCall
|
|
6
|
+
from ..base import build_token_usage, safe_parse_tool_arguments
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_field(obj: Any, name: str, default: Any = None) -> Any:
|
|
10
|
+
"""Retrieve an attribute or dict key safely."""
|
|
11
|
+
if obj is None:
|
|
12
|
+
return default
|
|
13
|
+
if isinstance(obj, dict):
|
|
14
|
+
return obj.get(name, default)
|
|
15
|
+
return getattr(obj, name, default)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AnthropicResponseParser:
|
|
19
|
+
"""Parses raw Anthropic SDK responses into normalized GenerationResponse objects."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, model: str) -> None:
|
|
22
|
+
self.model = model
|
|
23
|
+
|
|
24
|
+
def parse(self, response_msg: Any) -> GenerationResponse:
|
|
25
|
+
"""Parse an Anthropic response object or dict into GenerationResponse."""
|
|
26
|
+
if response_msg is None:
|
|
27
|
+
return GenerationResponse(text="", model=self.model)
|
|
28
|
+
|
|
29
|
+
raw_id = _get_field(response_msg, "id")
|
|
30
|
+
msg_id = raw_id if isinstance(raw_id, str) and raw_id.strip() else None
|
|
31
|
+
|
|
32
|
+
raw_model = _get_field(response_msg, "model")
|
|
33
|
+
resp_model = raw_model if isinstance(raw_model, str) and raw_model.strip() else self.model
|
|
34
|
+
|
|
35
|
+
raw_content = _get_field(response_msg, "content", [])
|
|
36
|
+
text, tool_calls, thought_blocks, thought_parts, compaction_summary = self.parse_content_blocks(
|
|
37
|
+
raw_content
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
thinking_text = "".join(thought_parts) if thought_parts else None
|
|
41
|
+
if thinking_text is None:
|
|
42
|
+
raw_thinking = _get_field(response_msg, "thinking") or _get_field(response_msg, "reasoning")
|
|
43
|
+
if isinstance(raw_thinking, str) and raw_thinking.strip():
|
|
44
|
+
thinking_text = raw_thinking.strip()
|
|
45
|
+
|
|
46
|
+
usage = self.parse_usage(_get_field(response_msg, "usage"))
|
|
47
|
+
|
|
48
|
+
return GenerationResponse(
|
|
49
|
+
id=msg_id,
|
|
50
|
+
text=text,
|
|
51
|
+
model=resp_model,
|
|
52
|
+
tool_calls=tuple(tool_calls),
|
|
53
|
+
usage=usage,
|
|
54
|
+
thinking=thinking_text,
|
|
55
|
+
thought_blocks=tuple(thought_blocks),
|
|
56
|
+
compaction=compaction_summary,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def parse_content_blocks(
|
|
60
|
+
self, content_blocks: Any
|
|
61
|
+
) -> tuple[str, list[ToolCall], list[dict[str, Any]], list[str], str | None]:
|
|
62
|
+
"""Extract text, tools, thoughts, and compaction from response content blocks."""
|
|
63
|
+
text_parts: list[str] = []
|
|
64
|
+
thought_parts: list[str] = []
|
|
65
|
+
thought_blocks: list[dict[str, Any]] = []
|
|
66
|
+
tool_calls: list[ToolCall] = []
|
|
67
|
+
compaction_summary: str | None = None
|
|
68
|
+
|
|
69
|
+
if not content_blocks:
|
|
70
|
+
return "", [], [], [], None
|
|
71
|
+
|
|
72
|
+
if isinstance(content_blocks, str):
|
|
73
|
+
return content_blocks, [], [], [], None
|
|
74
|
+
|
|
75
|
+
blocks = content_blocks if isinstance(content_blocks, (list, tuple)) else [content_blocks]
|
|
76
|
+
|
|
77
|
+
for block in blocks:
|
|
78
|
+
if isinstance(block, str):
|
|
79
|
+
text_parts.append(block)
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
block_type = _get_field(block, "type")
|
|
83
|
+
if block_type == "text":
|
|
84
|
+
t = _get_field(block, "text", "")
|
|
85
|
+
if t:
|
|
86
|
+
text_parts.append(str(t))
|
|
87
|
+
elif block_type == "compaction":
|
|
88
|
+
c = (
|
|
89
|
+
_get_field(block, "content")
|
|
90
|
+
or _get_field(block, "text")
|
|
91
|
+
or _get_field(block, "summary")
|
|
92
|
+
or _get_field(block, "data")
|
|
93
|
+
)
|
|
94
|
+
if c:
|
|
95
|
+
compaction_summary = str(c)
|
|
96
|
+
elif block_type == "thinking":
|
|
97
|
+
th_text = _get_field(block, "thinking") or _get_field(block, "text") or ""
|
|
98
|
+
thought_parts.append(str(th_text))
|
|
99
|
+
sig = _get_field(block, "signature")
|
|
100
|
+
tb: dict[str, Any] = {"type": "thinking", "thinking": str(th_text)}
|
|
101
|
+
if isinstance(sig, str) and sig.strip():
|
|
102
|
+
tb["signature"] = sig.strip()
|
|
103
|
+
thought_blocks.append(tb)
|
|
104
|
+
elif block_type == "redacted_thinking":
|
|
105
|
+
data = _get_field(block, "data")
|
|
106
|
+
tb_redacted: dict[str, Any] = {"type": "redacted_thinking"}
|
|
107
|
+
if data is not None and isinstance(data, str):
|
|
108
|
+
tb_redacted["data"] = data
|
|
109
|
+
elif data is not None:
|
|
110
|
+
tb_redacted["data"] = str(data)
|
|
111
|
+
thought_blocks.append(tb_redacted)
|
|
112
|
+
elif block_type == "tool_use":
|
|
113
|
+
raw_id = _get_field(block, "id")
|
|
114
|
+
call_id = raw_id if isinstance(raw_id, str) and raw_id.strip() else f"call_{len(tool_calls)+1}"
|
|
115
|
+
|
|
116
|
+
raw_name = _get_field(block, "name")
|
|
117
|
+
tool_name = raw_name if isinstance(raw_name, str) and raw_name.strip() else "unknown_tool"
|
|
118
|
+
|
|
119
|
+
raw_input = _get_field(block, "input")
|
|
120
|
+
tool_args = safe_parse_tool_arguments(raw_input)
|
|
121
|
+
|
|
122
|
+
tool_calls.append(
|
|
123
|
+
ToolCall(
|
|
124
|
+
id=call_id,
|
|
125
|
+
name=tool_name,
|
|
126
|
+
arguments=tool_args,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return "".join(text_parts), tool_calls, thought_blocks, thought_parts, compaction_summary
|
|
131
|
+
|
|
132
|
+
def parse_usage(self, raw_usage: Any) -> Any:
|
|
133
|
+
"""Parse token usage statistics from Anthropic raw usage."""
|
|
134
|
+
if raw_usage is None:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
it = _get_field(raw_usage, "input_tokens")
|
|
138
|
+
ot = _get_field(raw_usage, "output_tokens")
|
|
139
|
+
raw_cache_read = _get_field(raw_usage, "cache_read_input_tokens")
|
|
140
|
+
raw_cache_write = _get_field(raw_usage, "cache_creation_input_tokens")
|
|
141
|
+
|
|
142
|
+
cache_read = (
|
|
143
|
+
int(raw_cache_read)
|
|
144
|
+
if isinstance(raw_cache_read, (int, float)) and not isinstance(raw_cache_read, bool)
|
|
145
|
+
else 0
|
|
146
|
+
)
|
|
147
|
+
cache_write = (
|
|
148
|
+
int(raw_cache_write)
|
|
149
|
+
if isinstance(raw_cache_write, (int, float)) and not isinstance(raw_cache_write, bool)
|
|
150
|
+
else 0
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
reasoning_tokens = 0
|
|
154
|
+
out_details = _get_field(raw_usage, "output_tokens_details")
|
|
155
|
+
for candidate in (
|
|
156
|
+
_get_field(raw_usage, "thinking_tokens"),
|
|
157
|
+
_get_field(raw_usage, "reasoning_tokens"),
|
|
158
|
+
_get_field(out_details, "reasoning_tokens"),
|
|
159
|
+
_get_field(out_details, "thinking_tokens"),
|
|
160
|
+
):
|
|
161
|
+
if isinstance(candidate, (int, float)) and not isinstance(candidate, bool):
|
|
162
|
+
reasoning_tokens = int(candidate)
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
if not (isinstance(it, int) or isinstance(ot, int)):
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
prompt_tokens = (int(it) if isinstance(it, int) else 0) + cache_read
|
|
169
|
+
completion_tokens = int(ot) if isinstance(ot, int) else 0
|
|
170
|
+
|
|
171
|
+
return build_token_usage(
|
|
172
|
+
prompt_tokens=prompt_tokens,
|
|
173
|
+
completion_tokens=completion_tokens,
|
|
174
|
+
total_tokens=prompt_tokens + completion_tokens,
|
|
175
|
+
cached_tokens=cache_read,
|
|
176
|
+
cache_creation_tokens=cache_write,
|
|
177
|
+
reasoning_tokens=reasoning_tokens,
|
|
178
|
+
)
|
|
@@ -45,6 +45,7 @@ class AnthropicProvider(BaseProvider):
|
|
|
45
45
|
thinking: Any = None,
|
|
46
46
|
compact_threshold: int | None = None,
|
|
47
47
|
compact_instructions: str | None = None,
|
|
48
|
+
base_url: str | None = None,
|
|
48
49
|
) -> None:
|
|
49
50
|
if thinking is not None:
|
|
50
51
|
from evidor.models import AnthropicThinkingConfig
|
|
@@ -62,6 +63,7 @@ class AnthropicProvider(BaseProvider):
|
|
|
62
63
|
self._enable_prompt_caching = enable_prompt_caching
|
|
63
64
|
self._compact_threshold = compact_threshold
|
|
64
65
|
self._compact_instructions = compact_instructions
|
|
66
|
+
self._base_url = base_url
|
|
65
67
|
self._sync_client: Any = None
|
|
66
68
|
self._default_async_client: Any = None
|
|
67
69
|
self._async_clients: dict[asyncio.AbstractEventLoop, Any] = {}
|
|
@@ -99,6 +101,11 @@ class AnthropicProvider(BaseProvider):
|
|
|
99
101
|
def compact_instructions(self) -> str | None:
|
|
100
102
|
return self._compact_instructions
|
|
101
103
|
|
|
104
|
+
@property
|
|
105
|
+
def base_url(self) -> str | None:
|
|
106
|
+
"""The custom base URL for Anthropic-compatible endpoints, if configured."""
|
|
107
|
+
return self._base_url
|
|
108
|
+
|
|
102
109
|
def with_model(self, model: str) -> "AnthropicProvider":
|
|
103
110
|
return AnthropicProvider(
|
|
104
111
|
model=model,
|
|
@@ -108,6 +115,7 @@ class AnthropicProvider(BaseProvider):
|
|
|
108
115
|
thinking=self._thinking,
|
|
109
116
|
compact_threshold=self._compact_threshold,
|
|
110
117
|
compact_instructions=self._compact_instructions,
|
|
118
|
+
base_url=self._base_url,
|
|
111
119
|
)
|
|
112
120
|
|
|
113
121
|
@overload
|
|
@@ -163,31 +171,29 @@ class AnthropicProvider(BaseProvider):
|
|
|
163
171
|
) -> GenerationResponse | AsyncStreamResponse:
|
|
164
172
|
client = self._get_async_client()
|
|
165
173
|
should_stream = stream or request.stream
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
payload["stream"] = True
|
|
170
|
-
if self._compact_threshold is not None or self._compact_instructions is not None:
|
|
171
|
-
raw_stream = await client.beta.messages.create(**payload)
|
|
172
|
-
else:
|
|
173
|
-
raw_stream = await client.messages.create(**payload)
|
|
174
|
-
return AsyncStreamResponse(self._create_async_stream(raw_stream))
|
|
175
|
-
|
|
174
|
+
payload = self._builder.build(request)
|
|
175
|
+
if should_stream:
|
|
176
|
+
payload["stream"] = True
|
|
176
177
|
if self._compact_threshold is not None or self._compact_instructions is not None:
|
|
177
|
-
|
|
178
|
+
raw_stream = await client.beta.messages.create(**payload)
|
|
178
179
|
else:
|
|
179
|
-
|
|
180
|
-
return self.
|
|
181
|
-
|
|
180
|
+
raw_stream = await client.messages.create(**payload)
|
|
181
|
+
return AsyncStreamResponse(self._create_async_stream(raw_stream))
|
|
182
|
+
|
|
183
|
+
if self._compact_threshold is not None or self._compact_instructions is not None:
|
|
184
|
+
response_msg = await client.beta.messages.create(**payload)
|
|
185
|
+
else:
|
|
186
|
+
response_msg = await client.messages.create(**payload)
|
|
187
|
+
return self._parser.parse(response_msg)
|
|
182
188
|
|
|
183
189
|
def _create_stream(self, raw_stream: Any) -> Iterator[StreamChunk]:
|
|
184
|
-
handler = AnthropicStreamHandler()
|
|
190
|
+
handler = AnthropicStreamHandler(model=self._model, parser=self._parser)
|
|
185
191
|
for event in raw_stream:
|
|
186
192
|
yield from handler.process_event(event)
|
|
187
193
|
yield from handler.finalize()
|
|
188
194
|
|
|
189
195
|
async def _create_async_stream(self, raw_stream: Any) -> AsyncIterator[StreamChunk]:
|
|
190
|
-
handler = AnthropicStreamHandler()
|
|
196
|
+
handler = AnthropicStreamHandler(model=self._model, parser=self._parser)
|
|
191
197
|
async for event in raw_stream:
|
|
192
198
|
for chunk in handler.process_event(event):
|
|
193
199
|
yield chunk
|
|
@@ -218,23 +224,30 @@ class AnthropicProvider(BaseProvider):
|
|
|
218
224
|
except ImportError as error:
|
|
219
225
|
raise ImportError("Install Anthropic support with: pip install 'evidor[anthropic]'") from error
|
|
220
226
|
|
|
221
|
-
|
|
227
|
+
client_kwargs: dict[str, Any] = {"api_key": self._api_key}
|
|
228
|
+
if self._base_url is not None:
|
|
229
|
+
client_kwargs["base_url"] = self._base_url
|
|
230
|
+
client = Anthropic(**client_kwargs)
|
|
222
231
|
self._sync_client = client
|
|
223
232
|
return client
|
|
224
233
|
|
|
225
234
|
def _get_async_client(self) -> Any:
|
|
226
235
|
try:
|
|
227
236
|
from anthropic import AsyncAnthropic
|
|
228
|
-
except (ImportError, AttributeError):
|
|
229
|
-
|
|
237
|
+
except (ImportError, AttributeError) as error:
|
|
238
|
+
raise ImportError("Install Anthropic support with: pip install 'evidor[anthropic]'") from error
|
|
230
239
|
|
|
231
240
|
if not hasattr(self, "_async_clients"):
|
|
232
241
|
self._async_clients = {}
|
|
233
242
|
|
|
243
|
+
client_kwargs: dict[str, Any] = {"api_key": self._api_key}
|
|
244
|
+
if self._base_url is not None:
|
|
245
|
+
client_kwargs["base_url"] = self._base_url
|
|
246
|
+
|
|
234
247
|
client, self._default_async_client = get_or_create_async_client(
|
|
235
248
|
self._async_clients,
|
|
236
249
|
self._default_async_client,
|
|
237
|
-
lambda: AsyncAnthropic(
|
|
250
|
+
lambda: AsyncAnthropic(**client_kwargs),
|
|
238
251
|
self._lock,
|
|
239
252
|
)
|
|
240
253
|
return client
|
|
@@ -242,4 +255,3 @@ class AnthropicProvider(BaseProvider):
|
|
|
242
255
|
# Backward-compatible delegates
|
|
243
256
|
def _format_messages(self, messages: Sequence[Message]) -> list[dict[str, Any]]:
|
|
244
257
|
return self._builder.format_messages(messages)
|
|
245
|
-
|