evidor 1.10.3.dev1__tar.gz → 1.10.4.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.3.dev1 → evidor-1.10.4.dev1}/CHANGELOG.md +20 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/PKG-INFO +1 -1
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/pyproject.toml +1 -1
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/openai/builder.py +38 -13
- evidor-1.10.4.dev1/src/evidor/agent/providers/openai/parser.py +286 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/openai/provider.py +7 -4
- evidor-1.10.4.dev1/src/evidor/agent/providers/openai/stream.py +128 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_providers.py +289 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_streaming.py +158 -0
- evidor-1.10.3.dev1/src/evidor/agent/providers/openai/parser.py +0 -145
- evidor-1.10.3.dev1/src/evidor/agent/providers/openai/stream.py +0 -57
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/.github/workflows/release.yml +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/.gitignore +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/AGENTS.md +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/CODE_OF_CONDUCT.md +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/LICENSE +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/README.md +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/requirements.txt +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/core/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/core/agent.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/core/context.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/core/runner.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/core/telemetry_bridge.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/anthropic/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/anthropic/builder.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/anthropic/parser.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/anthropic/provider.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/anthropic/stream.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/base.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/client_pool.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/gemini/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/gemini/builder.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/gemini/parser.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/gemini/provider.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/gemini/stream.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/openai/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/constants.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/errors.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/mcp/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/mcp/client.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/mcp/config.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/mcp/runner.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/mcp/session.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/models/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/models/generation.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/models/streaming.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/models/thinking.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/adapters/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/adapters/langfuse.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/adapters/opentelemetry.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/adapters/phoenix.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/adapters/prometheus.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/context.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/events.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/runtime.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/semconv.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/sink.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/core/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/core/tool_executor.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/core/tools.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/core/utils.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/calculator.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/evidence_store.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/_common.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/list_files.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/mutate_files.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/read_file.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/search_files.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/get_current_time.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/read_webpage.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/_base.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/http.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/providers/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/providers/brave.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/providers/exa.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/providers/tavily.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/tool.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/utils/__init__.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/utils/execution.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/utils/schema.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_agent.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_builtin_tools.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_conversation.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_filesystem_tools.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_langfuse_adapter.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_mcp.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_models.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_native_tools.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_opentelemetry_adapter.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_phoenix_adapter.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_prometheus_adapter.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_telemetry_events.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_telemetry_integration.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_thinking.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_tools.py +0 -0
- {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_web_search.py +0 -0
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v1.10.4-dev.1 (2026-10-29)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- **openai**: Fixing minor issues
|
|
10
|
+
([`3cfa475`](https://github.com/vedangiitb/evidor-core/commit/3cfa4750140e293ea8c3d3f94db37fcc332ee9f2))
|
|
11
|
+
|
|
12
|
+
- **openai**: Fixing request building issues with context compaction and reasoning
|
|
13
|
+
([`a8274f5`](https://github.com/vedangiitb/evidor-core/commit/a8274f58267081942b0c0d8c9f187121e05d6834))
|
|
14
|
+
|
|
15
|
+
- **openai**: Fixing response parsing issues
|
|
16
|
+
([`c194b2c`](https://github.com/vedangiitb/evidor-core/commit/c194b2c12932660a896f986cfa83149cf412176b))
|
|
17
|
+
|
|
18
|
+
- **openai**: Fixing streaming issues
|
|
19
|
+
([`644da04`](https://github.com/vedangiitb/evidor-core/commit/644da042fe16e025b24bd15a11ab961720ce4057))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
## v1.10.3 (2026-10-22)
|
|
23
|
+
|
|
24
|
+
|
|
5
25
|
## v1.10.3-dev.1 (2026-10-22)
|
|
6
26
|
|
|
7
27
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: evidor
|
|
3
|
-
Version: 1.10.
|
|
3
|
+
Version: 1.10.4-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
|
|
@@ -32,7 +32,7 @@ class OpenAIPayloadBuilder:
|
|
|
32
32
|
payload["instructions"] = request.system
|
|
33
33
|
if request.previous_response_id:
|
|
34
34
|
payload["previous_response_id"] = request.previous_response_id
|
|
35
|
-
|
|
35
|
+
elif request.conversation_id:
|
|
36
36
|
payload["conversation"] = request.conversation_id
|
|
37
37
|
if request.store is not None:
|
|
38
38
|
payload["store"] = request.store
|
|
@@ -42,13 +42,16 @@ class OpenAIPayloadBuilder:
|
|
|
42
42
|
{"type": "compaction", "compact_threshold": self.compact_threshold}
|
|
43
43
|
]
|
|
44
44
|
|
|
45
|
-
if self.thinking
|
|
45
|
+
if self.thinking is not None:
|
|
46
46
|
reasoning_config: dict[str, Any] = {}
|
|
47
|
-
if self.thinking.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
if self.thinking.enabled:
|
|
48
|
+
if self.thinking.effort:
|
|
49
|
+
reasoning_config["effort"] = self.thinking.effort
|
|
50
|
+
summary_val = getattr(self.thinking, "summary", None)
|
|
51
|
+
if summary_val:
|
|
52
|
+
reasoning_config["summary"] = summary_val
|
|
53
|
+
else:
|
|
54
|
+
reasoning_config["effort"] = "none"
|
|
52
55
|
if reasoning_config:
|
|
53
56
|
payload["reasoning"] = reasoning_config
|
|
54
57
|
|
|
@@ -70,23 +73,45 @@ class OpenAIPayloadBuilder:
|
|
|
70
73
|
"""Format domain messages into OpenAI Responses API input items."""
|
|
71
74
|
input_items: list[dict[str, Any]] = []
|
|
72
75
|
for message in messages:
|
|
73
|
-
if message.
|
|
76
|
+
if message.compaction:
|
|
77
|
+
input_items.append({
|
|
78
|
+
"type": "compaction",
|
|
79
|
+
"encrypted_content": message.compaction,
|
|
80
|
+
})
|
|
81
|
+
if message.content:
|
|
82
|
+
input_items.append({"role": "assistant", "content": message.content})
|
|
83
|
+
elif message.role == "tool":
|
|
84
|
+
output_val = message.content if isinstance(message.content, str) else json.dumps(message.content)
|
|
74
85
|
input_items.append({
|
|
75
86
|
"type": "function_call_output",
|
|
76
|
-
"call_id": message.tool_call_id,
|
|
77
|
-
"output":
|
|
87
|
+
"call_id": message.tool_call_id or "call_unknown",
|
|
88
|
+
"output": output_val,
|
|
78
89
|
})
|
|
79
90
|
elif message.tool_calls:
|
|
91
|
+
if message.thought_blocks:
|
|
92
|
+
for tb in message.thought_blocks:
|
|
93
|
+
if isinstance(tb, dict) and tb.get("type") == "reasoning":
|
|
94
|
+
input_items.append(tb)
|
|
95
|
+
if message.content:
|
|
96
|
+
input_items.append({"role": "assistant", "content": message.content})
|
|
80
97
|
for tc in message.tool_calls:
|
|
98
|
+
if tc.arguments is None:
|
|
99
|
+
args_str = "{}"
|
|
100
|
+
elif isinstance(tc.arguments, (dict, list)):
|
|
101
|
+
args_str = json.dumps(tc.arguments)
|
|
102
|
+
else:
|
|
103
|
+
args_str = str(tc.arguments) if str(tc.arguments).strip() else "{}"
|
|
81
104
|
input_items.append({
|
|
82
105
|
"type": "function_call",
|
|
83
106
|
"call_id": tc.id,
|
|
84
107
|
"name": tc.name,
|
|
85
|
-
"arguments":
|
|
108
|
+
"arguments": args_str,
|
|
86
109
|
})
|
|
87
|
-
if message.content:
|
|
88
|
-
input_items.append({"role": "assistant", "content": message.content})
|
|
89
110
|
else:
|
|
111
|
+
if message.thought_blocks:
|
|
112
|
+
for tb in message.thought_blocks:
|
|
113
|
+
if isinstance(tb, dict) and tb.get("type") == "reasoning":
|
|
114
|
+
input_items.append(tb)
|
|
90
115
|
input_items.append({"role": message.role, "content": message.content})
|
|
91
116
|
return input_items
|
|
92
117
|
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Response parser for OpenAI Responses API."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, NamedTuple
|
|
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
|
+
def extract_reasoning_texts(val: Any) -> list[str]:
|
|
19
|
+
"""Extract string snippets from diverse reasoning or thought payload shapes."""
|
|
20
|
+
if not val:
|
|
21
|
+
return []
|
|
22
|
+
if isinstance(val, str):
|
|
23
|
+
s = val.strip()
|
|
24
|
+
return [s] if s else []
|
|
25
|
+
texts: list[str] = []
|
|
26
|
+
if isinstance(val, (list, tuple)):
|
|
27
|
+
for item in val:
|
|
28
|
+
if isinstance(item, str):
|
|
29
|
+
s = item.strip()
|
|
30
|
+
if s:
|
|
31
|
+
texts.append(s)
|
|
32
|
+
elif isinstance(item, dict):
|
|
33
|
+
t = item.get("text") or item.get("content") or item.get("summary")
|
|
34
|
+
if isinstance(t, str) and t.strip():
|
|
35
|
+
texts.append(t.strip())
|
|
36
|
+
else:
|
|
37
|
+
t = (
|
|
38
|
+
getattr(item, "text", None)
|
|
39
|
+
or getattr(item, "content", None)
|
|
40
|
+
or getattr(item, "summary", None)
|
|
41
|
+
)
|
|
42
|
+
if isinstance(t, str) and t.strip():
|
|
43
|
+
texts.append(t.strip())
|
|
44
|
+
elif isinstance(val, dict):
|
|
45
|
+
t = val.get("text") or val.get("content") or val.get("summary")
|
|
46
|
+
if isinstance(t, str) and t.strip():
|
|
47
|
+
texts.append(t.strip())
|
|
48
|
+
else:
|
|
49
|
+
t = (
|
|
50
|
+
getattr(val, "text", None)
|
|
51
|
+
or getattr(val, "content", None)
|
|
52
|
+
or getattr(val, "summary", None)
|
|
53
|
+
)
|
|
54
|
+
if isinstance(t, str) and t.strip():
|
|
55
|
+
texts.append(t.strip())
|
|
56
|
+
return texts
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ParsedOutputItems(NamedTuple):
|
|
60
|
+
"""Structured extraction results from response output items."""
|
|
61
|
+
|
|
62
|
+
tool_calls: list[ToolCall]
|
|
63
|
+
thinking_parts: list[str]
|
|
64
|
+
compaction: str | None = None
|
|
65
|
+
text_parts: list[str] = []
|
|
66
|
+
refusal_parts: list[str] = []
|
|
67
|
+
thought_blocks: list[Any] = []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class OpenAIResponseParser:
|
|
71
|
+
"""Parses raw OpenAI Responses API responses into GenerationResponse objects."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, model: str) -> None:
|
|
74
|
+
self.model = model
|
|
75
|
+
|
|
76
|
+
def parse(self, response: Any) -> GenerationResponse:
|
|
77
|
+
"""Parse an OpenAI response object into GenerationResponse."""
|
|
78
|
+
raw_id = _get_field(response, "id")
|
|
79
|
+
resp_id = raw_id if isinstance(raw_id, str) else None
|
|
80
|
+
|
|
81
|
+
raw_output = _get_field(response, "output")
|
|
82
|
+
output_items = raw_output if isinstance(raw_output, (list, tuple)) else []
|
|
83
|
+
parsed = self.parse_output_items(output_items)
|
|
84
|
+
|
|
85
|
+
output_text = _get_field(response, "output_text")
|
|
86
|
+
if isinstance(output_text, str) and output_text:
|
|
87
|
+
text = output_text
|
|
88
|
+
elif parsed.text_parts:
|
|
89
|
+
text = "".join(parsed.text_parts)
|
|
90
|
+
elif parsed.refusal_parts:
|
|
91
|
+
text = "\n".join(parsed.refusal_parts)
|
|
92
|
+
else:
|
|
93
|
+
text = ""
|
|
94
|
+
|
|
95
|
+
thinking_parts = list(parsed.thinking_parts)
|
|
96
|
+
seen_thinking = set(thinking_parts)
|
|
97
|
+
|
|
98
|
+
raw_resp_thinking = _get_field(response, "reasoning") or _get_field(response, "thinking")
|
|
99
|
+
if isinstance(raw_resp_thinking, str) and raw_resp_thinking.strip():
|
|
100
|
+
s = raw_resp_thinking.strip()
|
|
101
|
+
if s not in seen_thinking:
|
|
102
|
+
seen_thinking.add(s)
|
|
103
|
+
thinking_parts.append(s)
|
|
104
|
+
elif raw_resp_thinking is not None:
|
|
105
|
+
summary_val = _get_field(raw_resp_thinking, "summary")
|
|
106
|
+
if isinstance(summary_val, (str, list, tuple, dict)):
|
|
107
|
+
for snippet in extract_reasoning_texts(summary_val):
|
|
108
|
+
if snippet not in seen_thinking:
|
|
109
|
+
seen_thinking.add(snippet)
|
|
110
|
+
thinking_parts.append(snippet)
|
|
111
|
+
|
|
112
|
+
thinking_text = "\n".join(thinking_parts) if thinking_parts else None
|
|
113
|
+
usage = self.parse_usage(_get_field(response, "usage"))
|
|
114
|
+
|
|
115
|
+
return GenerationResponse(
|
|
116
|
+
id=resp_id,
|
|
117
|
+
text=text,
|
|
118
|
+
model=self.model,
|
|
119
|
+
tool_calls=tuple(parsed.tool_calls),
|
|
120
|
+
usage=usage,
|
|
121
|
+
thinking=thinking_text,
|
|
122
|
+
thought_blocks=tuple(parsed.thought_blocks),
|
|
123
|
+
compaction=parsed.compaction,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def parse_output_items(self, output_items: list[Any]) -> ParsedOutputItems:
|
|
127
|
+
"""Extract function calls, reasoning snippets, compaction, text, and refusal from output items."""
|
|
128
|
+
tool_calls: list[ToolCall] = []
|
|
129
|
+
thinking_parts: list[str] = []
|
|
130
|
+
seen_thinking: set[str] = set()
|
|
131
|
+
compaction_part: str | None = None
|
|
132
|
+
text_parts: list[str] = []
|
|
133
|
+
refusal_parts: list[str] = []
|
|
134
|
+
thought_blocks: list[Any] = []
|
|
135
|
+
|
|
136
|
+
if not output_items or not isinstance(output_items, (list, tuple)):
|
|
137
|
+
return ParsedOutputItems(
|
|
138
|
+
tool_calls=tool_calls,
|
|
139
|
+
thinking_parts=thinking_parts,
|
|
140
|
+
compaction=compaction_part,
|
|
141
|
+
text_parts=text_parts,
|
|
142
|
+
refusal_parts=refusal_parts,
|
|
143
|
+
thought_blocks=thought_blocks,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
for item in output_items:
|
|
147
|
+
raw_type = _get_field(item, "type")
|
|
148
|
+
item_type = raw_type if isinstance(raw_type, str) else None
|
|
149
|
+
if item_type in ("reasoning", "thought"):
|
|
150
|
+
thought_blocks.append(item)
|
|
151
|
+
for attr_name in ("summary", "content", "text"):
|
|
152
|
+
val = _get_field(item, attr_name)
|
|
153
|
+
if isinstance(val, (str, list, tuple, dict)):
|
|
154
|
+
for snippet in extract_reasoning_texts(val):
|
|
155
|
+
if snippet not in seen_thinking:
|
|
156
|
+
seen_thinking.add(snippet)
|
|
157
|
+
thinking_parts.append(snippet)
|
|
158
|
+
elif item_type in ("function_call", "custom_tool_call"):
|
|
159
|
+
raw_args = _get_field(item, "arguments")
|
|
160
|
+
args = (
|
|
161
|
+
safe_parse_tool_arguments(raw_args)
|
|
162
|
+
if isinstance(raw_args, (str, dict)) or (hasattr(raw_args, "items") and not isinstance(raw_args, type))
|
|
163
|
+
else {}
|
|
164
|
+
)
|
|
165
|
+
raw_call_id = _get_field(item, "call_id")
|
|
166
|
+
if not isinstance(raw_call_id, str) or not raw_call_id.strip():
|
|
167
|
+
raw_call_id = _get_field(item, "id")
|
|
168
|
+
call_id = (
|
|
169
|
+
raw_call_id.strip()
|
|
170
|
+
if isinstance(raw_call_id, str) and raw_call_id.strip()
|
|
171
|
+
else f"call_{len(tool_calls)+1}"
|
|
172
|
+
)
|
|
173
|
+
raw_name = _get_field(item, "name")
|
|
174
|
+
name = raw_name if isinstance(raw_name, str) and raw_name.strip() else "tool"
|
|
175
|
+
tool_calls.append(ToolCall(id=call_id, name=name, arguments=args))
|
|
176
|
+
elif item_type == "compaction":
|
|
177
|
+
c = None
|
|
178
|
+
for attr_name in ("encrypted_content", "content", "text"):
|
|
179
|
+
val = _get_field(item, attr_name)
|
|
180
|
+
if isinstance(val, str) and val.strip():
|
|
181
|
+
c = val.strip()
|
|
182
|
+
break
|
|
183
|
+
if c is None:
|
|
184
|
+
for attr_name in ("encrypted_content", "content", "text"):
|
|
185
|
+
val = _get_field(item, attr_name)
|
|
186
|
+
if val is not None and not isinstance(val, (type, bool)) and type(val).__name__ != "MagicMock":
|
|
187
|
+
c = str(val)
|
|
188
|
+
break
|
|
189
|
+
if c:
|
|
190
|
+
compaction_part = c
|
|
191
|
+
elif item_type == "message":
|
|
192
|
+
contents = _get_field(item, "content")
|
|
193
|
+
if isinstance(contents, str) and contents.strip():
|
|
194
|
+
text_parts.append(contents)
|
|
195
|
+
elif isinstance(contents, (list, tuple)):
|
|
196
|
+
for part in contents:
|
|
197
|
+
if isinstance(part, str) and part.strip():
|
|
198
|
+
text_parts.append(part)
|
|
199
|
+
elif part is not None:
|
|
200
|
+
raw_part_type = _get_field(part, "type")
|
|
201
|
+
part_type = raw_part_type if isinstance(raw_part_type, str) else None
|
|
202
|
+
if part_type == "output_text":
|
|
203
|
+
t = _get_field(part, "text")
|
|
204
|
+
if isinstance(t, str) and t:
|
|
205
|
+
text_parts.append(t)
|
|
206
|
+
elif part_type == "refusal":
|
|
207
|
+
r = _get_field(part, "refusal")
|
|
208
|
+
if not isinstance(r, str):
|
|
209
|
+
r = _get_field(part, "text")
|
|
210
|
+
if isinstance(r, str) and r:
|
|
211
|
+
refusal_parts.append(r)
|
|
212
|
+
else:
|
|
213
|
+
t = _get_field(part, "text")
|
|
214
|
+
if isinstance(t, str) and t:
|
|
215
|
+
text_parts.append(t)
|
|
216
|
+
|
|
217
|
+
return ParsedOutputItems(
|
|
218
|
+
tool_calls=tool_calls,
|
|
219
|
+
thinking_parts=thinking_parts,
|
|
220
|
+
compaction=compaction_part,
|
|
221
|
+
text_parts=text_parts,
|
|
222
|
+
refusal_parts=refusal_parts,
|
|
223
|
+
thought_blocks=thought_blocks,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def parse_usage(self, raw_usage: Any) -> Any:
|
|
227
|
+
"""Parse token usage statistics from OpenAI Responses API usage."""
|
|
228
|
+
if raw_usage is None:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
pt = _get_field(raw_usage, "prompt_tokens")
|
|
232
|
+
if not isinstance(pt, int) or isinstance(pt, bool):
|
|
233
|
+
pt = _get_field(raw_usage, "input_tokens")
|
|
234
|
+
ct = _get_field(raw_usage, "completion_tokens")
|
|
235
|
+
if not isinstance(ct, int) or isinstance(ct, bool):
|
|
236
|
+
ct = _get_field(raw_usage, "output_tokens")
|
|
237
|
+
tt = _get_field(raw_usage, "total_tokens")
|
|
238
|
+
|
|
239
|
+
has_pt = isinstance(pt, int) and not isinstance(pt, bool)
|
|
240
|
+
has_ct = isinstance(ct, int) and not isinstance(ct, bool)
|
|
241
|
+
has_tt = isinstance(tt, int) and not isinstance(tt, bool)
|
|
242
|
+
|
|
243
|
+
if not (has_pt or has_ct or has_tt):
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
prompt_tokens = int(pt) if has_pt else 0
|
|
247
|
+
completion_tokens = int(ct) if has_ct else 0
|
|
248
|
+
|
|
249
|
+
cached_tokens = 0
|
|
250
|
+
cache_creation_tokens = 0
|
|
251
|
+
pt_details = _get_field(raw_usage, "prompt_tokens_details") or _get_field(raw_usage, "input_tokens_details")
|
|
252
|
+
if pt_details is not None:
|
|
253
|
+
raw_cached = _get_field(pt_details, "cached_tokens", 0)
|
|
254
|
+
if isinstance(raw_cached, int) and not isinstance(raw_cached, bool):
|
|
255
|
+
cached_tokens = int(raw_cached)
|
|
256
|
+
if not cached_tokens:
|
|
257
|
+
raw_hit = _get_field(pt_details, "prompt_cache_hit_tokens", 0)
|
|
258
|
+
if isinstance(raw_hit, int) and not isinstance(raw_hit, bool):
|
|
259
|
+
cached_tokens = int(raw_hit)
|
|
260
|
+
|
|
261
|
+
raw_write = _get_field(pt_details, "cache_write_tokens", 0)
|
|
262
|
+
if not isinstance(raw_write, int) or isinstance(raw_write, bool) or raw_write == 0:
|
|
263
|
+
raw_write = _get_field(pt_details, "cache_creation_tokens", 0)
|
|
264
|
+
if isinstance(raw_write, int) and not isinstance(raw_write, bool):
|
|
265
|
+
cache_creation_tokens = int(raw_write)
|
|
266
|
+
|
|
267
|
+
if not cached_tokens:
|
|
268
|
+
raw_hit = _get_field(raw_usage, "prompt_cache_hit_tokens", 0)
|
|
269
|
+
if isinstance(raw_hit, int) and not isinstance(raw_hit, bool):
|
|
270
|
+
cached_tokens = int(raw_hit)
|
|
271
|
+
|
|
272
|
+
reasoning_tokens = 0
|
|
273
|
+
ct_details = _get_field(raw_usage, "completion_tokens_details") or _get_field(raw_usage, "output_tokens_details")
|
|
274
|
+
if ct_details is not None:
|
|
275
|
+
raw_reasoning = _get_field(ct_details, "reasoning_tokens", 0)
|
|
276
|
+
if isinstance(raw_reasoning, int) and not isinstance(raw_reasoning, bool):
|
|
277
|
+
reasoning_tokens = int(raw_reasoning)
|
|
278
|
+
|
|
279
|
+
return build_token_usage(
|
|
280
|
+
prompt_tokens=prompt_tokens,
|
|
281
|
+
completion_tokens=completion_tokens,
|
|
282
|
+
total_tokens=int(tt) if has_tt else None,
|
|
283
|
+
cached_tokens=cached_tokens,
|
|
284
|
+
reasoning_tokens=reasoning_tokens,
|
|
285
|
+
cache_creation_tokens=cache_creation_tokens,
|
|
286
|
+
)
|
|
@@ -174,7 +174,7 @@ class OpenAIProvider(BaseProvider):
|
|
|
174
174
|
|
|
175
175
|
try:
|
|
176
176
|
from openai import OpenAI
|
|
177
|
-
except ImportError as error:
|
|
177
|
+
except (ImportError, AttributeError) as error:
|
|
178
178
|
raise ImportError("Install OpenAI support with: pip install 'evidor[openai]'") from error
|
|
179
179
|
|
|
180
180
|
client_kwargs: dict[str, Any] = {"api_key": self._api_key}
|
|
@@ -192,8 +192,8 @@ class OpenAIProvider(BaseProvider):
|
|
|
192
192
|
def _get_async_client(self) -> Any:
|
|
193
193
|
try:
|
|
194
194
|
from openai import AsyncOpenAI
|
|
195
|
-
except (ImportError, AttributeError):
|
|
196
|
-
|
|
195
|
+
except (ImportError, AttributeError) as error:
|
|
196
|
+
raise ImportError("Install OpenAI support with: pip install 'evidor[openai]'") from error
|
|
197
197
|
|
|
198
198
|
if not hasattr(self, "_async_clients"):
|
|
199
199
|
self._async_clients = {}
|
|
@@ -210,7 +210,10 @@ class OpenAIProvider(BaseProvider):
|
|
|
210
210
|
)
|
|
211
211
|
|
|
212
212
|
if not hasattr(client, "responses") or not hasattr(client.responses, "create"):
|
|
213
|
-
|
|
213
|
+
raise RuntimeError(
|
|
214
|
+
"Modern OpenAI Responses API (client.responses.create) is required. "
|
|
215
|
+
"Please update openai to the latest version."
|
|
216
|
+
)
|
|
214
217
|
return client
|
|
215
218
|
|
|
216
219
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Streaming event handler for OpenAI Responses API."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from evidor.models import StreamChunk, ToolCall
|
|
7
|
+
from ..base import ToolCallAccumulator
|
|
8
|
+
from .parser import OpenAIResponseParser, _get_field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenAIStreamHandler:
|
|
12
|
+
"""Processes OpenAI Responses API stream events into normalized StreamChunk instances."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, parser: OpenAIResponseParser) -> None:
|
|
15
|
+
self.tool_acc = ToolCallAccumulator()
|
|
16
|
+
self.parser = parser
|
|
17
|
+
self.item_id_map: dict[str, str] = {}
|
|
18
|
+
|
|
19
|
+
def process_event(self, event: Any) -> Iterator[StreamChunk]:
|
|
20
|
+
"""Process a single event from the OpenAI Responses API raw stream."""
|
|
21
|
+
raw_ev_type = _get_field(event, "type")
|
|
22
|
+
ev_type = raw_ev_type if isinstance(raw_ev_type, str) else None
|
|
23
|
+
|
|
24
|
+
if ev_type in ("response.output_text.delta", "response.text.delta"):
|
|
25
|
+
delta = _get_field(event, "delta") or ""
|
|
26
|
+
if delta:
|
|
27
|
+
yield StreamChunk(text=str(delta), raw_event=event)
|
|
28
|
+
elif ev_type in ("response.refusal.delta", "response.refusal_text.delta"):
|
|
29
|
+
delta = _get_field(event, "delta") or ""
|
|
30
|
+
if delta:
|
|
31
|
+
yield StreamChunk(text=str(delta), raw_event=event)
|
|
32
|
+
elif ev_type in ("response.reasoning_text.delta", "response.reasoning_summary_text.delta"):
|
|
33
|
+
delta = _get_field(event, "delta") or ""
|
|
34
|
+
if delta:
|
|
35
|
+
yield StreamChunk(thinking=str(delta), raw_event=event)
|
|
36
|
+
elif ev_type == "response.output_item.added":
|
|
37
|
+
item = _get_field(event, "item")
|
|
38
|
+
item_type = _get_field(item, "type") if item else None
|
|
39
|
+
if item and item_type in ("function_call", "custom_tool_call"):
|
|
40
|
+
raw_call_id = _get_field(item, "call_id")
|
|
41
|
+
raw_item_id = _get_field(item, "id")
|
|
42
|
+
call_id = (
|
|
43
|
+
raw_call_id
|
|
44
|
+
if isinstance(raw_call_id, str) and raw_call_id.strip()
|
|
45
|
+
else (
|
|
46
|
+
raw_item_id
|
|
47
|
+
if isinstance(raw_item_id, str) and raw_item_id.strip()
|
|
48
|
+
else f"call_{len(self.item_id_map)+1}"
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
if isinstance(raw_item_id, str) and raw_item_id.strip():
|
|
52
|
+
self.item_id_map[raw_item_id.strip()] = call_id
|
|
53
|
+
self.item_id_map[call_id] = call_id
|
|
54
|
+
name = _get_field(item, "name") or ""
|
|
55
|
+
self.tool_acc.start_call(call_id, str(name))
|
|
56
|
+
elif ev_type in (
|
|
57
|
+
"response.function_call_arguments.delta",
|
|
58
|
+
"response.custom_tool_call_input_delta",
|
|
59
|
+
):
|
|
60
|
+
delta = _get_field(event, "delta") or ""
|
|
61
|
+
raw_id = (
|
|
62
|
+
_get_field(event, "item_id")
|
|
63
|
+
or _get_field(event, "call_id")
|
|
64
|
+
or _get_field(event, "id")
|
|
65
|
+
)
|
|
66
|
+
call_id = self.item_id_map.get(raw_id, raw_id) if raw_id else "call_1"
|
|
67
|
+
if delta:
|
|
68
|
+
self.tool_acc.add_arguments_delta(call_id, str(delta))
|
|
69
|
+
elif ev_type in (
|
|
70
|
+
"response.function_call_arguments.done",
|
|
71
|
+
"response.custom_tool_call_input_done",
|
|
72
|
+
"response.output_item.done",
|
|
73
|
+
):
|
|
74
|
+
item = _get_field(event, "item")
|
|
75
|
+
raw_id = (
|
|
76
|
+
_get_field(item, "call_id")
|
|
77
|
+
or _get_field(item, "id")
|
|
78
|
+
or _get_field(event, "item_id")
|
|
79
|
+
or _get_field(event, "call_id")
|
|
80
|
+
)
|
|
81
|
+
call_id = self.item_id_map.get(raw_id, raw_id) if raw_id else None
|
|
82
|
+
if call_id and call_id in self.tool_acc._calls:
|
|
83
|
+
event_args = _get_field(event, "arguments") or _get_field(item, "arguments")
|
|
84
|
+
if event_args and not self.tool_acc._calls[call_id]["args_parts"]:
|
|
85
|
+
self.tool_acc.add_arguments_delta(call_id, str(event_args))
|
|
86
|
+
item_name = _get_field(item, "name")
|
|
87
|
+
if item_name and not self.tool_acc._calls[call_id]["name"]:
|
|
88
|
+
self.tool_acc._calls[call_id]["name"] = str(item_name)
|
|
89
|
+
|
|
90
|
+
tc = self.tool_acc.complete_call(call_id)
|
|
91
|
+
if tc:
|
|
92
|
+
yield StreamChunk(tool_calls=(tc,), raw_event=event)
|
|
93
|
+
elif ev_type in ("response.error", "error"):
|
|
94
|
+
err_msg = _get_field(event, "message")
|
|
95
|
+
if not err_msg:
|
|
96
|
+
err_obj = _get_field(event, "error")
|
|
97
|
+
err_msg = _get_field(err_obj, "message") if err_obj else None
|
|
98
|
+
err_code = _get_field(event, "code")
|
|
99
|
+
full_msg = (
|
|
100
|
+
f"OpenAI stream error ({err_code}): {err_msg}"
|
|
101
|
+
if err_code
|
|
102
|
+
else f"OpenAI stream error: {err_msg or 'unknown error'}"
|
|
103
|
+
)
|
|
104
|
+
raise RuntimeError(full_msg)
|
|
105
|
+
elif ev_type == "response.failed":
|
|
106
|
+
resp = _get_field(event, "response")
|
|
107
|
+
err_obj = _get_field(resp, "error") if resp else None
|
|
108
|
+
err_msg = _get_field(err_obj, "message") if err_obj else None
|
|
109
|
+
err_code = _get_field(err_obj, "code") if err_obj else None
|
|
110
|
+
full_msg = (
|
|
111
|
+
f"OpenAI response failed ({err_code}): {err_msg}"
|
|
112
|
+
if err_code
|
|
113
|
+
else f"OpenAI response failed: {err_msg or 'unknown failure'}"
|
|
114
|
+
)
|
|
115
|
+
raise RuntimeError(full_msg)
|
|
116
|
+
elif ev_type == "response.completed":
|
|
117
|
+
final_resp = self.parser.parse(_get_field(event, "response"))
|
|
118
|
+
yield StreamChunk(is_finished=True, response=final_resp, raw_event=event)
|
|
119
|
+
|
|
120
|
+
def finalize(self) -> Iterator[StreamChunk]:
|
|
121
|
+
"""Yield any in-flight accumulated tool calls at the end of the stream."""
|
|
122
|
+
in_flight: list[ToolCall] = []
|
|
123
|
+
for call_id in list(self.tool_acc._calls.keys()):
|
|
124
|
+
tc = self.tool_acc.complete_call(call_id)
|
|
125
|
+
if tc is not None:
|
|
126
|
+
in_flight.append(tc)
|
|
127
|
+
if in_flight:
|
|
128
|
+
yield StreamChunk(tool_calls=tuple(in_flight))
|