emdash-core 0.1.7__py3-none-any.whl

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 (187) hide show
  1. emdash_core/__init__.py +3 -0
  2. emdash_core/agent/__init__.py +37 -0
  3. emdash_core/agent/agents.py +225 -0
  4. emdash_core/agent/code_reviewer.py +476 -0
  5. emdash_core/agent/compaction.py +143 -0
  6. emdash_core/agent/context_manager.py +140 -0
  7. emdash_core/agent/events.py +338 -0
  8. emdash_core/agent/handlers.py +224 -0
  9. emdash_core/agent/inprocess_subagent.py +377 -0
  10. emdash_core/agent/mcp/__init__.py +50 -0
  11. emdash_core/agent/mcp/client.py +346 -0
  12. emdash_core/agent/mcp/config.py +302 -0
  13. emdash_core/agent/mcp/manager.py +496 -0
  14. emdash_core/agent/mcp/tool_factory.py +213 -0
  15. emdash_core/agent/prompts/__init__.py +38 -0
  16. emdash_core/agent/prompts/main_agent.py +104 -0
  17. emdash_core/agent/prompts/subagents.py +131 -0
  18. emdash_core/agent/prompts/workflow.py +136 -0
  19. emdash_core/agent/providers/__init__.py +34 -0
  20. emdash_core/agent/providers/base.py +143 -0
  21. emdash_core/agent/providers/factory.py +80 -0
  22. emdash_core/agent/providers/models.py +220 -0
  23. emdash_core/agent/providers/openai_provider.py +463 -0
  24. emdash_core/agent/providers/transformers_provider.py +217 -0
  25. emdash_core/agent/research/__init__.py +81 -0
  26. emdash_core/agent/research/agent.py +143 -0
  27. emdash_core/agent/research/controller.py +254 -0
  28. emdash_core/agent/research/critic.py +428 -0
  29. emdash_core/agent/research/macros.py +469 -0
  30. emdash_core/agent/research/planner.py +449 -0
  31. emdash_core/agent/research/researcher.py +436 -0
  32. emdash_core/agent/research/state.py +523 -0
  33. emdash_core/agent/research/synthesizer.py +594 -0
  34. emdash_core/agent/reviewer_profile.py +475 -0
  35. emdash_core/agent/rules.py +123 -0
  36. emdash_core/agent/runner.py +601 -0
  37. emdash_core/agent/session.py +262 -0
  38. emdash_core/agent/spec_schema.py +66 -0
  39. emdash_core/agent/specification.py +479 -0
  40. emdash_core/agent/subagent.py +397 -0
  41. emdash_core/agent/subagent_prompts.py +13 -0
  42. emdash_core/agent/toolkit.py +482 -0
  43. emdash_core/agent/toolkits/__init__.py +64 -0
  44. emdash_core/agent/toolkits/base.py +96 -0
  45. emdash_core/agent/toolkits/explore.py +47 -0
  46. emdash_core/agent/toolkits/plan.py +55 -0
  47. emdash_core/agent/tools/__init__.py +141 -0
  48. emdash_core/agent/tools/analytics.py +436 -0
  49. emdash_core/agent/tools/base.py +131 -0
  50. emdash_core/agent/tools/coding.py +484 -0
  51. emdash_core/agent/tools/github_mcp.py +592 -0
  52. emdash_core/agent/tools/history.py +13 -0
  53. emdash_core/agent/tools/modes.py +153 -0
  54. emdash_core/agent/tools/plan.py +206 -0
  55. emdash_core/agent/tools/plan_write.py +135 -0
  56. emdash_core/agent/tools/search.py +412 -0
  57. emdash_core/agent/tools/spec.py +341 -0
  58. emdash_core/agent/tools/task.py +262 -0
  59. emdash_core/agent/tools/task_output.py +204 -0
  60. emdash_core/agent/tools/tasks.py +454 -0
  61. emdash_core/agent/tools/traversal.py +588 -0
  62. emdash_core/agent/tools/web.py +179 -0
  63. emdash_core/analytics/__init__.py +5 -0
  64. emdash_core/analytics/engine.py +1286 -0
  65. emdash_core/api/__init__.py +5 -0
  66. emdash_core/api/agent.py +308 -0
  67. emdash_core/api/agents.py +154 -0
  68. emdash_core/api/analyze.py +264 -0
  69. emdash_core/api/auth.py +173 -0
  70. emdash_core/api/context.py +77 -0
  71. emdash_core/api/db.py +121 -0
  72. emdash_core/api/embed.py +131 -0
  73. emdash_core/api/feature.py +143 -0
  74. emdash_core/api/health.py +93 -0
  75. emdash_core/api/index.py +162 -0
  76. emdash_core/api/plan.py +110 -0
  77. emdash_core/api/projectmd.py +210 -0
  78. emdash_core/api/query.py +320 -0
  79. emdash_core/api/research.py +122 -0
  80. emdash_core/api/review.py +161 -0
  81. emdash_core/api/router.py +76 -0
  82. emdash_core/api/rules.py +116 -0
  83. emdash_core/api/search.py +119 -0
  84. emdash_core/api/spec.py +99 -0
  85. emdash_core/api/swarm.py +223 -0
  86. emdash_core/api/tasks.py +109 -0
  87. emdash_core/api/team.py +120 -0
  88. emdash_core/auth/__init__.py +17 -0
  89. emdash_core/auth/github.py +389 -0
  90. emdash_core/config.py +74 -0
  91. emdash_core/context/__init__.py +52 -0
  92. emdash_core/context/models.py +50 -0
  93. emdash_core/context/providers/__init__.py +11 -0
  94. emdash_core/context/providers/base.py +74 -0
  95. emdash_core/context/providers/explored_areas.py +183 -0
  96. emdash_core/context/providers/touched_areas.py +360 -0
  97. emdash_core/context/registry.py +73 -0
  98. emdash_core/context/reranker.py +199 -0
  99. emdash_core/context/service.py +260 -0
  100. emdash_core/context/session.py +352 -0
  101. emdash_core/core/__init__.py +104 -0
  102. emdash_core/core/config.py +454 -0
  103. emdash_core/core/exceptions.py +55 -0
  104. emdash_core/core/models.py +265 -0
  105. emdash_core/core/review_config.py +57 -0
  106. emdash_core/db/__init__.py +67 -0
  107. emdash_core/db/auth.py +134 -0
  108. emdash_core/db/models.py +91 -0
  109. emdash_core/db/provider.py +222 -0
  110. emdash_core/db/providers/__init__.py +5 -0
  111. emdash_core/db/providers/supabase.py +452 -0
  112. emdash_core/embeddings/__init__.py +24 -0
  113. emdash_core/embeddings/indexer.py +534 -0
  114. emdash_core/embeddings/models.py +192 -0
  115. emdash_core/embeddings/providers/__init__.py +7 -0
  116. emdash_core/embeddings/providers/base.py +112 -0
  117. emdash_core/embeddings/providers/fireworks.py +141 -0
  118. emdash_core/embeddings/providers/openai.py +104 -0
  119. emdash_core/embeddings/registry.py +146 -0
  120. emdash_core/embeddings/service.py +215 -0
  121. emdash_core/graph/__init__.py +26 -0
  122. emdash_core/graph/builder.py +134 -0
  123. emdash_core/graph/connection.py +692 -0
  124. emdash_core/graph/schema.py +416 -0
  125. emdash_core/graph/writer.py +667 -0
  126. emdash_core/ingestion/__init__.py +7 -0
  127. emdash_core/ingestion/change_detector.py +150 -0
  128. emdash_core/ingestion/git/__init__.py +5 -0
  129. emdash_core/ingestion/git/commit_analyzer.py +196 -0
  130. emdash_core/ingestion/github/__init__.py +6 -0
  131. emdash_core/ingestion/github/pr_fetcher.py +296 -0
  132. emdash_core/ingestion/github/task_extractor.py +100 -0
  133. emdash_core/ingestion/orchestrator.py +540 -0
  134. emdash_core/ingestion/parsers/__init__.py +10 -0
  135. emdash_core/ingestion/parsers/base_parser.py +66 -0
  136. emdash_core/ingestion/parsers/call_graph_builder.py +121 -0
  137. emdash_core/ingestion/parsers/class_extractor.py +154 -0
  138. emdash_core/ingestion/parsers/function_extractor.py +202 -0
  139. emdash_core/ingestion/parsers/import_analyzer.py +119 -0
  140. emdash_core/ingestion/parsers/python_parser.py +123 -0
  141. emdash_core/ingestion/parsers/registry.py +72 -0
  142. emdash_core/ingestion/parsers/ts_ast_parser.js +313 -0
  143. emdash_core/ingestion/parsers/typescript_parser.py +278 -0
  144. emdash_core/ingestion/repository.py +346 -0
  145. emdash_core/models/__init__.py +38 -0
  146. emdash_core/models/agent.py +68 -0
  147. emdash_core/models/index.py +77 -0
  148. emdash_core/models/query.py +113 -0
  149. emdash_core/planning/__init__.py +7 -0
  150. emdash_core/planning/agent_api.py +413 -0
  151. emdash_core/planning/context_builder.py +265 -0
  152. emdash_core/planning/feature_context.py +232 -0
  153. emdash_core/planning/feature_expander.py +646 -0
  154. emdash_core/planning/llm_explainer.py +198 -0
  155. emdash_core/planning/similarity.py +509 -0
  156. emdash_core/planning/team_focus.py +821 -0
  157. emdash_core/server.py +153 -0
  158. emdash_core/sse/__init__.py +5 -0
  159. emdash_core/sse/stream.py +196 -0
  160. emdash_core/swarm/__init__.py +17 -0
  161. emdash_core/swarm/merge_agent.py +383 -0
  162. emdash_core/swarm/session_manager.py +274 -0
  163. emdash_core/swarm/swarm_runner.py +226 -0
  164. emdash_core/swarm/task_definition.py +137 -0
  165. emdash_core/swarm/worker_spawner.py +319 -0
  166. emdash_core/swarm/worktree_manager.py +278 -0
  167. emdash_core/templates/__init__.py +10 -0
  168. emdash_core/templates/defaults/agent-builder.md.template +82 -0
  169. emdash_core/templates/defaults/focus.md.template +115 -0
  170. emdash_core/templates/defaults/pr-review-enhanced.md.template +309 -0
  171. emdash_core/templates/defaults/pr-review.md.template +80 -0
  172. emdash_core/templates/defaults/project.md.template +85 -0
  173. emdash_core/templates/defaults/research_critic.md.template +112 -0
  174. emdash_core/templates/defaults/research_planner.md.template +85 -0
  175. emdash_core/templates/defaults/research_synthesizer.md.template +128 -0
  176. emdash_core/templates/defaults/reviewer.md.template +81 -0
  177. emdash_core/templates/defaults/spec.md.template +41 -0
  178. emdash_core/templates/defaults/tasks.md.template +78 -0
  179. emdash_core/templates/loader.py +296 -0
  180. emdash_core/utils/__init__.py +45 -0
  181. emdash_core/utils/git.py +84 -0
  182. emdash_core/utils/image.py +502 -0
  183. emdash_core/utils/logger.py +51 -0
  184. emdash_core-0.1.7.dist-info/METADATA +35 -0
  185. emdash_core-0.1.7.dist-info/RECORD +187 -0
  186. emdash_core-0.1.7.dist-info/WHEEL +4 -0
  187. emdash_core-0.1.7.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,220 @@
1
+ """Chat/LLM models enum - single source of truth for all supported models."""
2
+
3
+ from enum import Enum
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ChatModelSpec:
10
+ """Specification for a chat/LLM model."""
11
+
12
+ provider: str # "anthropic", "openai", "fireworks"
13
+ model_id: str # The actual model identifier for the API
14
+ api_model: str # Model string to send to the API
15
+ context_window: int # Max context tokens
16
+ max_output_tokens: int # Max output tokens
17
+ supports_tools: bool # Whether model supports function calling
18
+ supports_vision: bool # Whether model supports image input
19
+ description: str # Human-readable description
20
+
21
+
22
+ class ChatModel(Enum):
23
+ """
24
+ All supported chat/LLM models.
25
+
26
+ Format: PROVIDER_MODEL_NAME
27
+
28
+ Usage:
29
+ model = ChatModel.ANTHROPIC_CLAUDE_HAIKU_4
30
+ print(model.spec.context_window) # 200000
31
+ print(model.spec.provider) # "anthropic"
32
+ """
33
+
34
+ # ═══════════════════════════════════════════════════════════════════════════
35
+ # Anthropic Models
36
+ # ═══════════════════════════════════════════════════════════════════════════
37
+
38
+ ANTHROPIC_CLAUDE_OPUS_4 = ChatModelSpec(
39
+ provider="anthropic",
40
+ model_id="claude-opus-4-20250514",
41
+ api_model="claude-opus-4-20250514",
42
+ context_window=200000,
43
+ max_output_tokens=32000,
44
+ supports_tools=True,
45
+ supports_vision=True,
46
+ description="Claude Opus 4 - Most capable, complex reasoning",
47
+ )
48
+
49
+ ANTHROPIC_CLAUDE_SONNET_4 = ChatModelSpec(
50
+ provider="anthropic",
51
+ model_id="claude-sonnet-4",
52
+ api_model="claude-sonnet-4",
53
+ context_window=200000,
54
+ max_output_tokens=16000,
55
+ supports_tools=True,
56
+ supports_vision=True,
57
+ description="Claude Sonnet 4 - Balanced performance and cost",
58
+ )
59
+
60
+ ANTHROPIC_CLAUDE_HAIKU_4 = ChatModelSpec(
61
+ provider="anthropic",
62
+ model_id="claude-haiku-4-5",
63
+ api_model="claude-haiku-4-5",
64
+ context_window=200000,
65
+ max_output_tokens=8192,
66
+ supports_tools=True,
67
+ supports_vision=True,
68
+ description="Claude Haiku 4.5 - Fast and efficient",
69
+ )
70
+
71
+ # ═══════════════════════════════════════════════════════════════════════════
72
+ # OpenAI Models
73
+ # ═══════════════════════════════════════════════════════════════════════════
74
+
75
+ OPENAI_GPT_4O_MINI = ChatModelSpec(
76
+ provider="openai",
77
+ model_id="gpt-4o-mini",
78
+ api_model="gpt-4o-mini",
79
+ context_window=128000,
80
+ max_output_tokens=16384,
81
+ supports_tools=True,
82
+ supports_vision=True,
83
+ description="GPT-4o Mini - Fast and cost-effective",
84
+ )
85
+
86
+ # ═══════════════════════════════════════════════════════════════════════════
87
+ # Fireworks AI Models
88
+ # ═══════════════════════════════════════════════════════════════════════════
89
+
90
+ FIREWORKS_GLM_4P7 = ChatModelSpec(
91
+ provider="fireworks",
92
+ model_id="accounts/fireworks/models/glm-4p7",
93
+ api_model="accounts/fireworks/models/glm-4p7",
94
+ context_window=128000,
95
+ max_output_tokens=16384,
96
+ supports_tools=True,
97
+ supports_vision=False,
98
+ description="GLM-4P7 - Fireworks GLM model",
99
+ )
100
+
101
+ FIREWORKS_MINIMAX_M2P1 = ChatModelSpec(
102
+ provider="fireworks",
103
+ model_id="accounts/fireworks/models/minimax-m2p1",
104
+ api_model="accounts/fireworks/models/minimax-m2p1",
105
+ context_window=1000000,
106
+ max_output_tokens=16384,
107
+ supports_tools=True,
108
+ supports_vision=False,
109
+ description="MiniMax M2P1 - Long context model",
110
+ )
111
+
112
+ # ═══════════════════════════════════════════════════════════════════════════
113
+ # OpenRouter Models
114
+ # ═══════════════════════════════════════════════════════════════════════════
115
+
116
+
117
+ # ═══════════════════════════════════════════════════════════════════════════
118
+
119
+ @property
120
+ def spec(self) -> ChatModelSpec:
121
+ """Get the model specification."""
122
+ return self.value
123
+
124
+ @property
125
+ def provider(self) -> str:
126
+ """Shortcut to get provider name."""
127
+ return self.value.provider
128
+
129
+ @property
130
+ def model_id(self) -> str:
131
+ """Shortcut to get the API model ID."""
132
+ return self.value.model_id
133
+
134
+ @property
135
+ def api_model(self) -> str:
136
+ """Shortcut to get the API model string."""
137
+ return self.value.api_model
138
+
139
+ @property
140
+ def context_window(self) -> int:
141
+ """Shortcut to get context window size."""
142
+ return self.value.context_window
143
+
144
+ @classmethod
145
+ def get_default(cls) -> "ChatModel":
146
+ """Get the default chat model."""
147
+ return cls.FIREWORKS_GLM_4P7
148
+
149
+ @classmethod
150
+ def from_string(cls, value: str) -> Optional["ChatModel"]:
151
+ """
152
+ Parse model from string.
153
+
154
+ Accepts:
155
+ - Short aliases: "haiku", "sonnet" (checked first!)
156
+ - Enum name: "ANTHROPIC_CLAUDE_HAIKU_4"
157
+ - Provider:model: "anthropic:claude-haiku-4-5"
158
+ - Just model_id: "claude-haiku-4-5"
159
+ """
160
+ value = value.strip()
161
+
162
+ # Check short aliases FIRST (most common use case)
163
+ aliases = {
164
+ # Anthropic
165
+ "haiku": cls.ANTHROPIC_CLAUDE_HAIKU_4,
166
+ "sonnet": cls.ANTHROPIC_CLAUDE_SONNET_4,
167
+ "opus": cls.ANTHROPIC_CLAUDE_OPUS_4,
168
+ # OpenAI
169
+ "gpt-4o-mini": cls.OPENAI_GPT_4O_MINI,
170
+ # Fireworks
171
+ "glm-4p7": cls.FIREWORKS_GLM_4P7,
172
+ "minimax": cls.FIREWORKS_MINIMAX_M2P1,
173
+ }
174
+ if value.lower() in aliases:
175
+ return aliases[value.lower()]
176
+
177
+ # Try enum name
178
+ try:
179
+ return cls[value.upper().replace("-", "_").replace(":", "_")]
180
+ except KeyError:
181
+ pass
182
+
183
+ # Try provider:model format
184
+ if ":" in value:
185
+ provider, model_id = value.split(":", 1)
186
+ for model in cls:
187
+ if model.provider == provider and model.model_id == model_id:
188
+ return model
189
+
190
+ # Try exact model_id match
191
+ for model in cls:
192
+ if model.model_id == value:
193
+ return model
194
+
195
+ return None
196
+
197
+ @classmethod
198
+ def list_by_provider(cls, provider: str) -> list["ChatModel"]:
199
+ """List all models for a specific provider."""
200
+ return [m for m in cls if m.provider == provider]
201
+
202
+ @classmethod
203
+ def list_all(cls) -> list[dict]:
204
+ """List all models with their specs for display."""
205
+ return [
206
+ {
207
+ "name": m.name,
208
+ "provider": m.provider,
209
+ "model_id": m.model_id,
210
+ "api_model": m.api_model,
211
+ "context_window": m.context_window,
212
+ "supports_tools": m.spec.supports_tools,
213
+ "description": m.spec.description,
214
+ }
215
+ for m in cls
216
+ ]
217
+
218
+ def __str__(self) -> str:
219
+ """String representation as provider:model_id."""
220
+ return f"{self.provider}:{self.model_id}"
@@ -0,0 +1,463 @@
1
+ """OpenAI SDK-based provider - unified interface to OpenAI-compatible APIs."""
2
+
3
+ import os
4
+ import base64
5
+ from typing import Optional, Union
6
+
7
+ from openai import OpenAI
8
+
9
+ from .base import LLMProvider, LLMResponse, ToolCall, ImageContent
10
+ from .models import ChatModel
11
+ from ...utils.logger import log
12
+
13
+
14
+ # Provider configuration: base URLs and API key environment variables
15
+ PROVIDER_CONFIG = {
16
+ "openai": {
17
+ "base_url": None, # Uses default OpenAI URL
18
+ "api_key_env": "OPENAI_API_KEY",
19
+ },
20
+ "anthropic": {
21
+ "base_url": "https://api.anthropic.com/v1",
22
+ "api_key_env": "ANTHROPIC_API_KEY",
23
+ },
24
+ "fireworks": {
25
+ "base_url": "https://api.fireworks.ai/inference/v1",
26
+ "api_key_env": "FIREWORKS_API_KEY",
27
+ },
28
+ }
29
+
30
+ # Providers that support the reasoning parameter via extra_body
31
+ REASONING_SUPPORTED_PROVIDERS = {"openai"}
32
+
33
+
34
+ class OpenAIProvider(LLMProvider):
35
+ """
36
+ Unified LLM provider using OpenAI SDK.
37
+
38
+ Supports OpenAI, Anthropic, and Fireworks through their OpenAI-compatible APIs.
39
+ Just change the model - the provider auto-configures based on the model's provider.
40
+ """
41
+
42
+ def __init__(self, model: Union[ChatModel, str]):
43
+ """
44
+ Initialize the provider.
45
+
46
+ Args:
47
+ model: ChatModel enum or model string
48
+ """
49
+ if isinstance(model, ChatModel):
50
+ self.chat_model = model
51
+ self.model = model.api_model
52
+ self._context_limit = model.context_window
53
+ self._provider = model.provider
54
+ else:
55
+ # Raw model string - try to parse it
56
+ parsed = ChatModel.from_string(model)
57
+ if parsed:
58
+ self.chat_model = parsed
59
+ self.model = parsed.api_model
60
+ self._context_limit = parsed.context_window
61
+ self._provider = parsed.provider
62
+ else:
63
+ # Fallback for unknown models
64
+ self.chat_model = None
65
+ self.model = model
66
+ self._context_limit = 128000
67
+ self._provider = self._infer_provider(model)
68
+
69
+ # Override provider if OPENAI_BASE_URL is set (custom OpenAI-compatible API)
70
+ if os.environ.get("OPENAI_BASE_URL"):
71
+ self._provider = "openai"
72
+
73
+ # Create OpenAI client with provider-specific configuration
74
+ config = PROVIDER_CONFIG.get(self._provider, PROVIDER_CONFIG["openai"])
75
+
76
+ # Check for provider-specific API key first, then fallback to OPENAI_API_KEY
77
+ # if the provider is openai-compatible
78
+ api_key_env = config["api_key_env"]
79
+ raw_api_key = os.environ.get(api_key_env)
80
+
81
+ if not raw_api_key and self._provider != "openai":
82
+ # Fallback to OPENAI_API_KEY for third-party providers if their specific key is missing
83
+ raw_api_key = os.environ.get("OPENAI_API_KEY")
84
+ if raw_api_key:
85
+ log.debug(
86
+ f"Using OPENAI_API_KEY fallback for provider '{self._provider}' "
87
+ f"because {api_key_env} is not set."
88
+ )
89
+
90
+ api_key = self._sanitize_api_key(raw_api_key)
91
+ if not api_key:
92
+ raise ValueError(
93
+ f"Missing API key. Set {config['api_key_env']} for provider '{self._provider}'."
94
+ )
95
+ self._api_key = api_key
96
+ if raw_api_key and api_key != raw_api_key:
97
+ log.debug(
98
+ f"Sanitized API key for provider={self._provider} env={config['api_key_env']} "
99
+ "(trimmed whitespace/quotes)."
100
+ )
101
+ log_api_key = os.environ.get("EMDASH_LOG_LLM_API_KEY", "").strip().lower() in {
102
+ "1",
103
+ "true",
104
+ "yes",
105
+ }
106
+ if log_api_key:
107
+ log.debug(
108
+ "LLM provider init provider={} model={} base_url={} key_env={} api_key={}",
109
+ self._provider,
110
+ self.model,
111
+ config["base_url"] or "https://api.openai.com/v1",
112
+ config["api_key_env"],
113
+ api_key,
114
+ )
115
+ else:
116
+ log.debug(
117
+ "LLM provider init provider={} model={} base_url={} key_env={} key_len={} key_hint={}",
118
+ self._provider,
119
+ self.model,
120
+ config["base_url"] or "https://api.openai.com/v1",
121
+ config["api_key_env"],
122
+ len(api_key),
123
+ self._mask_api_key(api_key),
124
+ )
125
+ if len(api_key) < 20:
126
+ log.warning(
127
+ "API key for provider={} looks short (len={}). Verify {}.",
128
+ self._provider,
129
+ len(api_key),
130
+ config["api_key_env"],
131
+ )
132
+
133
+ self._reasoning_override = self._parse_bool_env("EMDASH_LLM_REASONING")
134
+
135
+ self.client = OpenAI(
136
+ api_key=api_key,
137
+ base_url=config["base_url"],
138
+ )
139
+
140
+ @staticmethod
141
+ def _sanitize_api_key(api_key: Optional[str]) -> Optional[str]:
142
+ """Normalize API key values loaded from env/.env."""
143
+ if api_key is None:
144
+ return None
145
+ cleaned = api_key.strip()
146
+ if len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in {"'", '"'}:
147
+ cleaned = cleaned[1:-1].strip()
148
+ return cleaned or None
149
+
150
+ @staticmethod
151
+ def _parse_bool_env(name: str) -> Optional[bool]:
152
+ """Parse a boolean environment variable."""
153
+ raw = os.environ.get(name)
154
+ if raw is None:
155
+ return None
156
+ cleaned = raw.strip().lower()
157
+ if cleaned in {"1", "true", "yes", "y", "on"}:
158
+ return True
159
+ if cleaned in {"0", "false", "no", "n", "off"}:
160
+ return False
161
+ return None
162
+
163
+ @staticmethod
164
+ def _mask_api_key(api_key: str) -> str:
165
+ """Mask API key for safe logging."""
166
+ if len(api_key) <= 8:
167
+ return "*" * len(api_key)
168
+ return f"{api_key[:4]}...{api_key[-4:]}"
169
+
170
+ def _infer_provider(self, model: str) -> str:
171
+ """Infer provider from model string.
172
+
173
+ If OPENAI_BASE_URL is set, always returns 'openai' to use the custom
174
+ OpenAI-compatible API endpoint with OPENAI_API_KEY.
175
+ """
176
+ # If custom base URL is set, use openai provider (uses OPENAI_API_KEY)
177
+ if os.environ.get("OPENAI_BASE_URL"):
178
+ return "openai"
179
+
180
+ model_lower = model.lower()
181
+ if "claude" in model_lower or "anthropic" in model_lower:
182
+ return "anthropic"
183
+ elif "fireworks" in model_lower or "accounts/fireworks" in model_lower:
184
+ return "fireworks"
185
+ else:
186
+ return "openai" # Default
187
+
188
+ def chat(
189
+ self,
190
+ messages: list[dict],
191
+ tools: Optional[list[dict]] = None,
192
+ system: Optional[str] = None,
193
+ reasoning: bool = False,
194
+ images: Optional[list[ImageContent]] = None,
195
+ ) -> LLMResponse:
196
+ """
197
+ Send a chat completion request via OpenAI SDK.
198
+
199
+ Args:
200
+ messages: List of message dicts with 'role' and 'content'
201
+ tools: Optional list of tool schemas (OpenAI format)
202
+ system: Optional system prompt
203
+ reasoning: Enable reasoning mode (for models that support it)
204
+ images: Optional list of images for vision-capable models
205
+
206
+ Returns:
207
+ LLMResponse with content and/or tool calls
208
+ """
209
+ # Prepend system message if provided
210
+ if system:
211
+ messages = [{"role": "system", "content": system}] + messages
212
+
213
+ if self._reasoning_override is not None:
214
+ reasoning = self._reasoning_override
215
+
216
+ # Build completion kwargs
217
+ kwargs = {
218
+ "model": self.model,
219
+ "messages": messages,
220
+ }
221
+
222
+ # Add tools if provided
223
+ if tools:
224
+ kwargs["tools"] = tools
225
+
226
+ # Add reasoning support via extra_body for providers that support it
227
+ # Skip reasoning for custom base URLs (they may not support it)
228
+ is_custom_api = bool(os.environ.get("OPENAI_BASE_URL"))
229
+ if reasoning and self._provider in REASONING_SUPPORTED_PROVIDERS and not is_custom_api:
230
+ kwargs["extra_body"] = {"reasoning": {"enabled": True}}
231
+
232
+ # Add images if provided (vision support)
233
+ if images:
234
+ log.info(
235
+ "Adding {} images to request provider={} model={}",
236
+ len(images),
237
+ self._provider,
238
+ self.model,
239
+ )
240
+ # Find the last user message and add images to it
241
+ for i in range(len(messages) - 1, -1, -1):
242
+ if messages[i].get("role") == "user":
243
+ messages[i]["content"] = self._format_content_with_images(
244
+ messages[i].get("content", ""), images
245
+ )
246
+ break
247
+
248
+ extra_headers = {}
249
+
250
+ messages_summary = [
251
+ {
252
+ "role": m.get("role"),
253
+ "content_len": len(str(m.get("content", ""))),
254
+ }
255
+ for m in messages
256
+ ]
257
+ log.info(
258
+ "LLM request start provider={} model={} messages={} tools={} reasoning={}",
259
+ self._provider,
260
+ self.model,
261
+ len(messages),
262
+ bool(tools),
263
+ reasoning,
264
+ )
265
+ log_payload = os.environ.get("EMDASH_LOG_LLM_PAYLOAD", "").strip().lower() in {
266
+ "1",
267
+ "true",
268
+ "yes",
269
+ }
270
+ if log_payload:
271
+ log.debug(
272
+ "LLM request payload provider={} model={} headers={} payload={}",
273
+ self._provider,
274
+ self.model,
275
+ sorted(extra_headers.keys()),
276
+ kwargs,
277
+ )
278
+ else:
279
+ log.debug(
280
+ "LLM request provider={} model={} messages={} tools={} reasoning={} headers={}",
281
+ self._provider,
282
+ self.model,
283
+ messages_summary,
284
+ bool(tools),
285
+ reasoning,
286
+ sorted(extra_headers.keys()),
287
+ )
288
+
289
+ # Call OpenAI SDK
290
+ try:
291
+ response = self.client.chat.completions.create(**kwargs)
292
+ except Exception as exc: # pragma: no cover - defensive logging
293
+ status = getattr(exc, "status_code", None)
294
+ code = getattr(exc, "code", None)
295
+ log.exception(
296
+ "LLM request failed provider={} model={} status={} code={} error={}",
297
+ self._provider,
298
+ self.model,
299
+ status,
300
+ code,
301
+ exc,
302
+ )
303
+ raise
304
+
305
+ return self._to_llm_response(response)
306
+
307
+ def _to_llm_response(self, response) -> LLMResponse:
308
+ """Convert OpenAI response to our LLMResponse format."""
309
+ response_model = getattr(response, "model", None)
310
+ log.info(
311
+ "LLM response received provider={} model={} response_model={}",
312
+ self._provider,
313
+ self.model,
314
+ response_model,
315
+ )
316
+ log.debug(
317
+ "LLM response provider={} model={} response_model={}",
318
+ self._provider,
319
+ self.model,
320
+ response_model,
321
+ )
322
+ choice = response.choices[0]
323
+ message = choice.message
324
+
325
+ # Extract content
326
+ content = message.content
327
+
328
+ # Extract tool calls
329
+ tool_calls = []
330
+ if message.tool_calls:
331
+ for tc in message.tool_calls:
332
+ tool_calls.append(ToolCall(
333
+ id=tc.id,
334
+ name=tc.function.name,
335
+ arguments=tc.function.arguments,
336
+ ))
337
+
338
+ # Extract token usage if available
339
+ input_tokens = 0
340
+ output_tokens = 0
341
+ if hasattr(response, "usage") and response.usage:
342
+ input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
343
+ output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
344
+
345
+ return LLMResponse(
346
+ content=content,
347
+ tool_calls=tool_calls,
348
+ raw=response,
349
+ stop_reason=choice.finish_reason,
350
+ input_tokens=input_tokens,
351
+ output_tokens=output_tokens,
352
+ )
353
+
354
+ def get_context_limit(self) -> int:
355
+ """Get the context window size for this model."""
356
+ return self._context_limit
357
+
358
+ def get_max_image_size(self) -> int:
359
+ """Get maximum image size in bytes for this model."""
360
+ # Different providers have different limits
361
+ if self._provider == "anthropic":
362
+ return 5 * 1024 * 1024 # 5MB for Claude
363
+ elif self._provider == "openai":
364
+ return 5 * 1024 * 1024 # 5MB for GPT-4o
365
+ else:
366
+ return 5 * 1024 * 1024 # Default
367
+
368
+ def supports_vision(self) -> bool:
369
+ """Check if this model supports image input."""
370
+ if self.chat_model:
371
+ return self.chat_model.spec.supports_vision
372
+
373
+ # For unknown models, assume no vision support
374
+ return False
375
+
376
+ def _format_image_for_api(self, image: ImageContent) -> dict:
377
+ """Format an image for OpenAI/Anthropic API.
378
+
379
+ Args:
380
+ image: ImageContent with raw image data
381
+
382
+ Returns:
383
+ Dict with image_url for the API
384
+ """
385
+ encoded = base64.b64encode(image.image_data).decode("utf-8")
386
+ return {
387
+ "type": "image_url",
388
+ "image_url": {
389
+ "url": f"data:image/{image.format};base64,{encoded}"
390
+ }
391
+ }
392
+
393
+ def _format_content_with_images(
394
+ self,
395
+ text: str,
396
+ images: Optional[list[ImageContent]] = None
397
+ ):
398
+ """Format message content with optional images.
399
+
400
+ For vision models, returns a list of content blocks.
401
+ For non-vision models, returns text only.
402
+
403
+ Args:
404
+ text: Text content
405
+ images: Optional list of images
406
+
407
+ Returns:
408
+ Content formatted for this provider
409
+ """
410
+ if not images:
411
+ return text
412
+
413
+ if not self.supports_vision():
414
+ log.warning(
415
+ "Model {} does not support vision, images will be stripped",
416
+ self.model,
417
+ )
418
+ return text
419
+
420
+ # Vision model: create content blocks
421
+ content = [{"type": "text", "text": text}]
422
+ for img in images:
423
+ content.append(self._format_image_for_api(img))
424
+
425
+ return content
426
+
427
+ def format_tool_result(self, tool_call_id: str, result: str) -> dict:
428
+ """
429
+ Format a tool result message.
430
+
431
+ Uses OpenAI format.
432
+ """
433
+ return {
434
+ "role": "tool",
435
+ "tool_call_id": tool_call_id,
436
+ "content": result,
437
+ }
438
+
439
+ def format_assistant_message(self, response: LLMResponse) -> dict:
440
+ """
441
+ Format an assistant response to add back to messages.
442
+
443
+ Uses OpenAI format.
444
+ """
445
+ message = {"role": "assistant"}
446
+
447
+ if response.content:
448
+ message["content"] = response.content
449
+
450
+ if response.tool_calls:
451
+ message["tool_calls"] = [
452
+ {
453
+ "id": tc.id,
454
+ "type": "function",
455
+ "function": {
456
+ "name": tc.name,
457
+ "arguments": tc.arguments,
458
+ }
459
+ }
460
+ for tc in response.tool_calls
461
+ ]
462
+
463
+ return message