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.
Files changed (105) hide show
  1. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/CHANGELOG.md +14 -0
  2. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/PKG-INFO +1 -1
  3. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/pyproject.toml +1 -1
  4. evidor-1.10.5.dev1/src/evidor/agent/providers/anthropic/builder.py +209 -0
  5. evidor-1.10.5.dev1/src/evidor/agent/providers/anthropic/parser.py +178 -0
  6. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/anthropic/provider.py +33 -21
  7. evidor-1.10.5.dev1/src/evidor/agent/providers/anthropic/stream.py +239 -0
  8. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/base.py +4 -1
  9. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/models/generation.py +1 -0
  10. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_providers.py +399 -0
  11. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_streaming.py +163 -0
  12. evidor-1.10.4.dev1/src/evidor/agent/providers/anthropic/builder.py +0 -120
  13. evidor-1.10.4.dev1/src/evidor/agent/providers/anthropic/parser.py +0 -102
  14. evidor-1.10.4.dev1/src/evidor/agent/providers/anthropic/stream.py +0 -67
  15. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/.github/workflows/release.yml +0 -0
  16. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/.gitignore +0 -0
  17. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/AGENTS.md +0 -0
  18. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/CODE_OF_CONDUCT.md +0 -0
  19. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/LICENSE +0 -0
  20. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/README.md +0 -0
  21. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/requirements.txt +0 -0
  22. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/__init__.py +0 -0
  23. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/__init__.py +0 -0
  24. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/core/__init__.py +0 -0
  25. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/core/agent.py +0 -0
  26. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/core/context.py +0 -0
  27. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/core/runner.py +0 -0
  28. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/core/telemetry_bridge.py +0 -0
  29. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/__init__.py +0 -0
  30. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/anthropic/__init__.py +0 -0
  31. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/client_pool.py +0 -0
  32. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/gemini/__init__.py +0 -0
  33. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/gemini/builder.py +0 -0
  34. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/gemini/parser.py +0 -0
  35. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/gemini/provider.py +0 -0
  36. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/gemini/stream.py +0 -0
  37. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/openai/__init__.py +0 -0
  38. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/openai/builder.py +0 -0
  39. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/openai/parser.py +0 -0
  40. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/openai/provider.py +0 -0
  41. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/agent/providers/openai/stream.py +0 -0
  42. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/constants.py +0 -0
  43. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/errors.py +0 -0
  44. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/mcp/__init__.py +0 -0
  45. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/mcp/client.py +0 -0
  46. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/mcp/config.py +0 -0
  47. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/mcp/runner.py +0 -0
  48. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/mcp/session.py +0 -0
  49. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/models/__init__.py +0 -0
  50. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/models/streaming.py +0 -0
  51. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/models/thinking.py +0 -0
  52. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/__init__.py +0 -0
  53. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/adapters/__init__.py +0 -0
  54. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/adapters/langfuse.py +0 -0
  55. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/adapters/opentelemetry.py +0 -0
  56. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/adapters/phoenix.py +0 -0
  57. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/adapters/prometheus.py +0 -0
  58. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/context.py +0 -0
  59. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/events.py +0 -0
  60. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/runtime.py +0 -0
  61. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/semconv.py +0 -0
  62. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/telemetry/sink.py +0 -0
  63. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/__init__.py +0 -0
  64. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/core/__init__.py +0 -0
  65. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/core/tool_executor.py +0 -0
  66. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/core/tools.py +0 -0
  67. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/core/utils.py +0 -0
  68. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/__init__.py +0 -0
  69. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/calculator.py +0 -0
  70. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/evidence_store.py +0 -0
  71. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/__init__.py +0 -0
  72. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/_common.py +0 -0
  73. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/list_files.py +0 -0
  74. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/mutate_files.py +0 -0
  75. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/read_file.py +0 -0
  76. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/filesystem/search_files.py +0 -0
  77. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/get_current_time.py +0 -0
  78. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/read_webpage.py +0 -0
  79. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/__init__.py +0 -0
  80. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/_base.py +0 -0
  81. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/http.py +0 -0
  82. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/providers/__init__.py +0 -0
  83. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/providers/brave.py +0 -0
  84. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/providers/exa.py +0 -0
  85. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/providers/tavily.py +0 -0
  86. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/tools/native/web_search/tool.py +0 -0
  87. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/utils/__init__.py +0 -0
  88. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/utils/execution.py +0 -0
  89. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/src/evidor/utils/schema.py +0 -0
  90. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_agent.py +0 -0
  91. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_builtin_tools.py +0 -0
  92. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_conversation.py +0 -0
  93. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_filesystem_tools.py +0 -0
  94. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_langfuse_adapter.py +0 -0
  95. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_mcp.py +0 -0
  96. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_models.py +0 -0
  97. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_native_tools.py +0 -0
  98. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_opentelemetry_adapter.py +0 -0
  99. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_phoenix_adapter.py +0 -0
  100. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_prometheus_adapter.py +0 -0
  101. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_telemetry_events.py +0 -0
  102. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_telemetry_integration.py +0 -0
  103. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_thinking.py +0 -0
  104. {evidor-1.10.4.dev1 → evidor-1.10.5.dev1}/tests/test_tools.py +0 -0
  105. {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.4-dev.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "evidor"
7
- version = "1.10.4-dev.1"
7
+ version = "1.10.5-dev.1"
8
8
  description = "A small, provider-agnostic harness for LLM input and output."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
- if client is not None:
167
- payload = self._builder.build(request)
168
- if should_stream:
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
- response_msg = await client.beta.messages.create(**payload)
178
+ raw_stream = await client.beta.messages.create(**payload)
178
179
  else:
179
- response_msg = await client.messages.create(**payload)
180
- return self._parser.parse(response_msg)
181
- return await asyncio.to_thread(self.generate, request, stream=stream)
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
- client = Anthropic(api_key=self._api_key)
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
- return None
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(api_key=self._api_key),
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
-