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.
- emdash_core/__init__.py +3 -0
- emdash_core/agent/__init__.py +37 -0
- emdash_core/agent/agents.py +225 -0
- emdash_core/agent/code_reviewer.py +476 -0
- emdash_core/agent/compaction.py +143 -0
- emdash_core/agent/context_manager.py +140 -0
- emdash_core/agent/events.py +338 -0
- emdash_core/agent/handlers.py +224 -0
- emdash_core/agent/inprocess_subagent.py +377 -0
- emdash_core/agent/mcp/__init__.py +50 -0
- emdash_core/agent/mcp/client.py +346 -0
- emdash_core/agent/mcp/config.py +302 -0
- emdash_core/agent/mcp/manager.py +496 -0
- emdash_core/agent/mcp/tool_factory.py +213 -0
- emdash_core/agent/prompts/__init__.py +38 -0
- emdash_core/agent/prompts/main_agent.py +104 -0
- emdash_core/agent/prompts/subagents.py +131 -0
- emdash_core/agent/prompts/workflow.py +136 -0
- emdash_core/agent/providers/__init__.py +34 -0
- emdash_core/agent/providers/base.py +143 -0
- emdash_core/agent/providers/factory.py +80 -0
- emdash_core/agent/providers/models.py +220 -0
- emdash_core/agent/providers/openai_provider.py +463 -0
- emdash_core/agent/providers/transformers_provider.py +217 -0
- emdash_core/agent/research/__init__.py +81 -0
- emdash_core/agent/research/agent.py +143 -0
- emdash_core/agent/research/controller.py +254 -0
- emdash_core/agent/research/critic.py +428 -0
- emdash_core/agent/research/macros.py +469 -0
- emdash_core/agent/research/planner.py +449 -0
- emdash_core/agent/research/researcher.py +436 -0
- emdash_core/agent/research/state.py +523 -0
- emdash_core/agent/research/synthesizer.py +594 -0
- emdash_core/agent/reviewer_profile.py +475 -0
- emdash_core/agent/rules.py +123 -0
- emdash_core/agent/runner.py +601 -0
- emdash_core/agent/session.py +262 -0
- emdash_core/agent/spec_schema.py +66 -0
- emdash_core/agent/specification.py +479 -0
- emdash_core/agent/subagent.py +397 -0
- emdash_core/agent/subagent_prompts.py +13 -0
- emdash_core/agent/toolkit.py +482 -0
- emdash_core/agent/toolkits/__init__.py +64 -0
- emdash_core/agent/toolkits/base.py +96 -0
- emdash_core/agent/toolkits/explore.py +47 -0
- emdash_core/agent/toolkits/plan.py +55 -0
- emdash_core/agent/tools/__init__.py +141 -0
- emdash_core/agent/tools/analytics.py +436 -0
- emdash_core/agent/tools/base.py +131 -0
- emdash_core/agent/tools/coding.py +484 -0
- emdash_core/agent/tools/github_mcp.py +592 -0
- emdash_core/agent/tools/history.py +13 -0
- emdash_core/agent/tools/modes.py +153 -0
- emdash_core/agent/tools/plan.py +206 -0
- emdash_core/agent/tools/plan_write.py +135 -0
- emdash_core/agent/tools/search.py +412 -0
- emdash_core/agent/tools/spec.py +341 -0
- emdash_core/agent/tools/task.py +262 -0
- emdash_core/agent/tools/task_output.py +204 -0
- emdash_core/agent/tools/tasks.py +454 -0
- emdash_core/agent/tools/traversal.py +588 -0
- emdash_core/agent/tools/web.py +179 -0
- emdash_core/analytics/__init__.py +5 -0
- emdash_core/analytics/engine.py +1286 -0
- emdash_core/api/__init__.py +5 -0
- emdash_core/api/agent.py +308 -0
- emdash_core/api/agents.py +154 -0
- emdash_core/api/analyze.py +264 -0
- emdash_core/api/auth.py +173 -0
- emdash_core/api/context.py +77 -0
- emdash_core/api/db.py +121 -0
- emdash_core/api/embed.py +131 -0
- emdash_core/api/feature.py +143 -0
- emdash_core/api/health.py +93 -0
- emdash_core/api/index.py +162 -0
- emdash_core/api/plan.py +110 -0
- emdash_core/api/projectmd.py +210 -0
- emdash_core/api/query.py +320 -0
- emdash_core/api/research.py +122 -0
- emdash_core/api/review.py +161 -0
- emdash_core/api/router.py +76 -0
- emdash_core/api/rules.py +116 -0
- emdash_core/api/search.py +119 -0
- emdash_core/api/spec.py +99 -0
- emdash_core/api/swarm.py +223 -0
- emdash_core/api/tasks.py +109 -0
- emdash_core/api/team.py +120 -0
- emdash_core/auth/__init__.py +17 -0
- emdash_core/auth/github.py +389 -0
- emdash_core/config.py +74 -0
- emdash_core/context/__init__.py +52 -0
- emdash_core/context/models.py +50 -0
- emdash_core/context/providers/__init__.py +11 -0
- emdash_core/context/providers/base.py +74 -0
- emdash_core/context/providers/explored_areas.py +183 -0
- emdash_core/context/providers/touched_areas.py +360 -0
- emdash_core/context/registry.py +73 -0
- emdash_core/context/reranker.py +199 -0
- emdash_core/context/service.py +260 -0
- emdash_core/context/session.py +352 -0
- emdash_core/core/__init__.py +104 -0
- emdash_core/core/config.py +454 -0
- emdash_core/core/exceptions.py +55 -0
- emdash_core/core/models.py +265 -0
- emdash_core/core/review_config.py +57 -0
- emdash_core/db/__init__.py +67 -0
- emdash_core/db/auth.py +134 -0
- emdash_core/db/models.py +91 -0
- emdash_core/db/provider.py +222 -0
- emdash_core/db/providers/__init__.py +5 -0
- emdash_core/db/providers/supabase.py +452 -0
- emdash_core/embeddings/__init__.py +24 -0
- emdash_core/embeddings/indexer.py +534 -0
- emdash_core/embeddings/models.py +192 -0
- emdash_core/embeddings/providers/__init__.py +7 -0
- emdash_core/embeddings/providers/base.py +112 -0
- emdash_core/embeddings/providers/fireworks.py +141 -0
- emdash_core/embeddings/providers/openai.py +104 -0
- emdash_core/embeddings/registry.py +146 -0
- emdash_core/embeddings/service.py +215 -0
- emdash_core/graph/__init__.py +26 -0
- emdash_core/graph/builder.py +134 -0
- emdash_core/graph/connection.py +692 -0
- emdash_core/graph/schema.py +416 -0
- emdash_core/graph/writer.py +667 -0
- emdash_core/ingestion/__init__.py +7 -0
- emdash_core/ingestion/change_detector.py +150 -0
- emdash_core/ingestion/git/__init__.py +5 -0
- emdash_core/ingestion/git/commit_analyzer.py +196 -0
- emdash_core/ingestion/github/__init__.py +6 -0
- emdash_core/ingestion/github/pr_fetcher.py +296 -0
- emdash_core/ingestion/github/task_extractor.py +100 -0
- emdash_core/ingestion/orchestrator.py +540 -0
- emdash_core/ingestion/parsers/__init__.py +10 -0
- emdash_core/ingestion/parsers/base_parser.py +66 -0
- emdash_core/ingestion/parsers/call_graph_builder.py +121 -0
- emdash_core/ingestion/parsers/class_extractor.py +154 -0
- emdash_core/ingestion/parsers/function_extractor.py +202 -0
- emdash_core/ingestion/parsers/import_analyzer.py +119 -0
- emdash_core/ingestion/parsers/python_parser.py +123 -0
- emdash_core/ingestion/parsers/registry.py +72 -0
- emdash_core/ingestion/parsers/ts_ast_parser.js +313 -0
- emdash_core/ingestion/parsers/typescript_parser.py +278 -0
- emdash_core/ingestion/repository.py +346 -0
- emdash_core/models/__init__.py +38 -0
- emdash_core/models/agent.py +68 -0
- emdash_core/models/index.py +77 -0
- emdash_core/models/query.py +113 -0
- emdash_core/planning/__init__.py +7 -0
- emdash_core/planning/agent_api.py +413 -0
- emdash_core/planning/context_builder.py +265 -0
- emdash_core/planning/feature_context.py +232 -0
- emdash_core/planning/feature_expander.py +646 -0
- emdash_core/planning/llm_explainer.py +198 -0
- emdash_core/planning/similarity.py +509 -0
- emdash_core/planning/team_focus.py +821 -0
- emdash_core/server.py +153 -0
- emdash_core/sse/__init__.py +5 -0
- emdash_core/sse/stream.py +196 -0
- emdash_core/swarm/__init__.py +17 -0
- emdash_core/swarm/merge_agent.py +383 -0
- emdash_core/swarm/session_manager.py +274 -0
- emdash_core/swarm/swarm_runner.py +226 -0
- emdash_core/swarm/task_definition.py +137 -0
- emdash_core/swarm/worker_spawner.py +319 -0
- emdash_core/swarm/worktree_manager.py +278 -0
- emdash_core/templates/__init__.py +10 -0
- emdash_core/templates/defaults/agent-builder.md.template +82 -0
- emdash_core/templates/defaults/focus.md.template +115 -0
- emdash_core/templates/defaults/pr-review-enhanced.md.template +309 -0
- emdash_core/templates/defaults/pr-review.md.template +80 -0
- emdash_core/templates/defaults/project.md.template +85 -0
- emdash_core/templates/defaults/research_critic.md.template +112 -0
- emdash_core/templates/defaults/research_planner.md.template +85 -0
- emdash_core/templates/defaults/research_synthesizer.md.template +128 -0
- emdash_core/templates/defaults/reviewer.md.template +81 -0
- emdash_core/templates/defaults/spec.md.template +41 -0
- emdash_core/templates/defaults/tasks.md.template +78 -0
- emdash_core/templates/loader.py +296 -0
- emdash_core/utils/__init__.py +45 -0
- emdash_core/utils/git.py +84 -0
- emdash_core/utils/image.py +502 -0
- emdash_core/utils/logger.py +51 -0
- emdash_core-0.1.7.dist-info/METADATA +35 -0
- emdash_core-0.1.7.dist-info/RECORD +187 -0
- emdash_core-0.1.7.dist-info/WHEEL +4 -0
- 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
|