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.
Files changed (104) hide show
  1. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/CHANGELOG.md +20 -0
  2. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/PKG-INFO +1 -1
  3. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/pyproject.toml +1 -1
  4. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/openai/builder.py +38 -13
  5. evidor-1.10.4.dev1/src/evidor/agent/providers/openai/parser.py +286 -0
  6. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/openai/provider.py +7 -4
  7. evidor-1.10.4.dev1/src/evidor/agent/providers/openai/stream.py +128 -0
  8. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_providers.py +289 -0
  9. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_streaming.py +158 -0
  10. evidor-1.10.3.dev1/src/evidor/agent/providers/openai/parser.py +0 -145
  11. evidor-1.10.3.dev1/src/evidor/agent/providers/openai/stream.py +0 -57
  12. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/.github/workflows/release.yml +0 -0
  13. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/.gitignore +0 -0
  14. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/AGENTS.md +0 -0
  15. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/CODE_OF_CONDUCT.md +0 -0
  16. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/LICENSE +0 -0
  17. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/README.md +0 -0
  18. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/requirements.txt +0 -0
  19. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/__init__.py +0 -0
  20. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/__init__.py +0 -0
  21. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/core/__init__.py +0 -0
  22. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/core/agent.py +0 -0
  23. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/core/context.py +0 -0
  24. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/core/runner.py +0 -0
  25. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/core/telemetry_bridge.py +0 -0
  26. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/__init__.py +0 -0
  27. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/anthropic/__init__.py +0 -0
  28. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/anthropic/builder.py +0 -0
  29. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/anthropic/parser.py +0 -0
  30. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/anthropic/provider.py +0 -0
  31. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/anthropic/stream.py +0 -0
  32. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/base.py +0 -0
  33. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/client_pool.py +0 -0
  34. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/gemini/__init__.py +0 -0
  35. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/gemini/builder.py +0 -0
  36. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/gemini/parser.py +0 -0
  37. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/gemini/provider.py +0 -0
  38. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/gemini/stream.py +0 -0
  39. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/agent/providers/openai/__init__.py +0 -0
  40. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/constants.py +0 -0
  41. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/errors.py +0 -0
  42. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/mcp/__init__.py +0 -0
  43. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/mcp/client.py +0 -0
  44. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/mcp/config.py +0 -0
  45. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/mcp/runner.py +0 -0
  46. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/mcp/session.py +0 -0
  47. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/models/__init__.py +0 -0
  48. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/models/generation.py +0 -0
  49. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/models/streaming.py +0 -0
  50. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/models/thinking.py +0 -0
  51. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/__init__.py +0 -0
  52. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/adapters/__init__.py +0 -0
  53. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/adapters/langfuse.py +0 -0
  54. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/adapters/opentelemetry.py +0 -0
  55. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/adapters/phoenix.py +0 -0
  56. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/adapters/prometheus.py +0 -0
  57. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/context.py +0 -0
  58. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/events.py +0 -0
  59. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/runtime.py +0 -0
  60. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/semconv.py +0 -0
  61. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/telemetry/sink.py +0 -0
  62. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/__init__.py +0 -0
  63. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/core/__init__.py +0 -0
  64. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/core/tool_executor.py +0 -0
  65. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/core/tools.py +0 -0
  66. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/core/utils.py +0 -0
  67. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/__init__.py +0 -0
  68. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/calculator.py +0 -0
  69. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/evidence_store.py +0 -0
  70. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/__init__.py +0 -0
  71. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/_common.py +0 -0
  72. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/list_files.py +0 -0
  73. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/mutate_files.py +0 -0
  74. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/read_file.py +0 -0
  75. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/filesystem/search_files.py +0 -0
  76. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/get_current_time.py +0 -0
  77. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/read_webpage.py +0 -0
  78. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/__init__.py +0 -0
  79. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/_base.py +0 -0
  80. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/http.py +0 -0
  81. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/providers/__init__.py +0 -0
  82. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/providers/brave.py +0 -0
  83. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/providers/exa.py +0 -0
  84. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/providers/tavily.py +0 -0
  85. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/tools/native/web_search/tool.py +0 -0
  86. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/utils/__init__.py +0 -0
  87. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/utils/execution.py +0 -0
  88. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/src/evidor/utils/schema.py +0 -0
  89. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_agent.py +0 -0
  90. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_builtin_tools.py +0 -0
  91. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_conversation.py +0 -0
  92. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_filesystem_tools.py +0 -0
  93. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_langfuse_adapter.py +0 -0
  94. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_mcp.py +0 -0
  95. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_models.py +0 -0
  96. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_native_tools.py +0 -0
  97. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_opentelemetry_adapter.py +0 -0
  98. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_phoenix_adapter.py +0 -0
  99. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_prometheus_adapter.py +0 -0
  100. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_telemetry_events.py +0 -0
  101. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_telemetry_integration.py +0 -0
  102. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_thinking.py +0 -0
  103. {evidor-1.10.3.dev1 → evidor-1.10.4.dev1}/tests/test_tools.py +0 -0
  104. {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-dev.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "evidor"
7
- version = "1.10.3-dev.1"
7
+ version = "1.10.4-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"
@@ -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
- if request.conversation_id:
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 and self.thinking.enabled:
45
+ if self.thinking is not None:
46
46
  reasoning_config: dict[str, Any] = {}
47
- if self.thinking.effort:
48
- reasoning_config["effort"] = self.thinking.effort
49
- summary_val = getattr(self.thinking, "summary", None)
50
- if summary_val:
51
- reasoning_config["summary"] = summary_val
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.role == "tool":
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": message.content,
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": json.dumps(tc.arguments) if isinstance(tc.arguments, dict) else str(tc.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
- return None
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
- return None
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))