devrel-origin 0.2.14__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.
- devrel_origin/__init__.py +15 -0
- devrel_origin/cli/__init__.py +92 -0
- devrel_origin/cli/_common.py +243 -0
- devrel_origin/cli/analytics.py +28 -0
- devrel_origin/cli/argus.py +497 -0
- devrel_origin/cli/auth.py +227 -0
- devrel_origin/cli/config.py +108 -0
- devrel_origin/cli/content.py +259 -0
- devrel_origin/cli/cost.py +108 -0
- devrel_origin/cli/cro.py +298 -0
- devrel_origin/cli/deliverables.py +65 -0
- devrel_origin/cli/docs.py +91 -0
- devrel_origin/cli/doctor.py +178 -0
- devrel_origin/cli/experiment.py +29 -0
- devrel_origin/cli/growth.py +97 -0
- devrel_origin/cli/init.py +472 -0
- devrel_origin/cli/intel.py +27 -0
- devrel_origin/cli/kb.py +96 -0
- devrel_origin/cli/listen.py +31 -0
- devrel_origin/cli/marketing.py +66 -0
- devrel_origin/cli/migrate.py +45 -0
- devrel_origin/cli/run.py +46 -0
- devrel_origin/cli/sales.py +57 -0
- devrel_origin/cli/schedule.py +62 -0
- devrel_origin/cli/synthesize.py +28 -0
- devrel_origin/cli/triage.py +29 -0
- devrel_origin/cli/video.py +35 -0
- devrel_origin/core/__init__.py +58 -0
- devrel_origin/core/agent_config.py +75 -0
- devrel_origin/core/argus.py +964 -0
- devrel_origin/core/atlas.py +1450 -0
- devrel_origin/core/base.py +372 -0
- devrel_origin/core/cyra.py +563 -0
- devrel_origin/core/dex.py +708 -0
- devrel_origin/core/echo.py +614 -0
- devrel_origin/core/growth/__init__.py +27 -0
- devrel_origin/core/growth/recommendations.py +219 -0
- devrel_origin/core/growth/target_kinds.py +51 -0
- devrel_origin/core/iris.py +513 -0
- devrel_origin/core/kai.py +1367 -0
- devrel_origin/core/llm.py +542 -0
- devrel_origin/core/llm_backends.py +274 -0
- devrel_origin/core/mox.py +514 -0
- devrel_origin/core/nova.py +349 -0
- devrel_origin/core/pax.py +1205 -0
- devrel_origin/core/rex.py +532 -0
- devrel_origin/core/sage.py +486 -0
- devrel_origin/core/sentinel.py +385 -0
- devrel_origin/core/types.py +98 -0
- devrel_origin/core/video/__init__.py +22 -0
- devrel_origin/core/video/assembler.py +131 -0
- devrel_origin/core/video/browser_recorder.py +118 -0
- devrel_origin/core/video/desktop_recorder.py +254 -0
- devrel_origin/core/video/overlay_renderer.py +143 -0
- devrel_origin/core/video/script_parser.py +147 -0
- devrel_origin/core/video/tts_engine.py +82 -0
- devrel_origin/core/vox.py +268 -0
- devrel_origin/core/watchdog.py +321 -0
- devrel_origin/project/__init__.py +1 -0
- devrel_origin/project/config.py +75 -0
- devrel_origin/project/cost_sink.py +61 -0
- devrel_origin/project/init.py +104 -0
- devrel_origin/project/paths.py +75 -0
- devrel_origin/project/state.py +241 -0
- devrel_origin/project/templates/__init__.py +4 -0
- devrel_origin/project/templates/config.toml +24 -0
- devrel_origin/project/templates/devrel.gitignore +10 -0
- devrel_origin/project/templates/slop-blocklist.md +45 -0
- devrel_origin/project/templates/style.md +24 -0
- devrel_origin/project/templates/voice.md +29 -0
- devrel_origin/quality/__init__.py +66 -0
- devrel_origin/quality/editorial.py +357 -0
- devrel_origin/quality/persona.py +84 -0
- devrel_origin/quality/readability.py +148 -0
- devrel_origin/quality/slop.py +167 -0
- devrel_origin/quality/style.py +110 -0
- devrel_origin/quality/voice.py +15 -0
- devrel_origin/tools/__init__.py +9 -0
- devrel_origin/tools/analytics.py +304 -0
- devrel_origin/tools/api_client.py +393 -0
- devrel_origin/tools/apollo_client.py +305 -0
- devrel_origin/tools/code_validator.py +428 -0
- devrel_origin/tools/github_tools.py +297 -0
- devrel_origin/tools/instantly_client.py +412 -0
- devrel_origin/tools/kb_harvester.py +340 -0
- devrel_origin/tools/mcp_server.py +578 -0
- devrel_origin/tools/notifications.py +245 -0
- devrel_origin/tools/run_report.py +193 -0
- devrel_origin/tools/scheduler.py +231 -0
- devrel_origin/tools/search_tools.py +321 -0
- devrel_origin/tools/self_improve.py +168 -0
- devrel_origin/tools/sheets.py +236 -0
- devrel_origin-0.2.14.dist-info/METADATA +354 -0
- devrel_origin-0.2.14.dist-info/RECORD +98 -0
- devrel_origin-0.2.14.dist-info/WHEEL +5 -0
- devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
- devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
- devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Shared LLM client wrapper for all agents.
|
|
2
|
+
|
|
3
|
+
Multi-provider via the LLMBackend abstraction in core/llm_backends.py:
|
|
4
|
+
AnthropicBackend (default) and OpenRouterBackend. Per-agent model overrides
|
|
5
|
+
flow through `agent_models={agent_name: model_id}` so e.g. Argus can run on
|
|
6
|
+
a cheap classification model while Kai sticks to a high-quality writer.
|
|
7
|
+
Cost tracking, budget gating, and agent-attribution stay in this layer; the
|
|
8
|
+
backend's only job is the actual chat call.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Awaitable
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from contextvars import ContextVar
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Callable
|
|
19
|
+
|
|
20
|
+
from devrel_origin.core.base import strip_markdown_fences
|
|
21
|
+
from devrel_origin.core.llm_backends import (
|
|
22
|
+
ANTHROPIC_DEFAULT_MODEL,
|
|
23
|
+
ANTHROPIC_MODELS,
|
|
24
|
+
LLMBackend,
|
|
25
|
+
make_backend,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_current_agent_var: ContextVar[str] = ContextVar("devrel_origin_current_agent", default="")
|
|
31
|
+
|
|
32
|
+
DEFAULT_MODEL = ANTHROPIC_DEFAULT_MODEL
|
|
33
|
+
DEFAULT_MAX_TOKENS = 4096
|
|
34
|
+
|
|
35
|
+
# Backwards-compatible alias map. Re-exported for callers that imported it
|
|
36
|
+
# directly; new code should rely on the backend's resolve_alias().
|
|
37
|
+
MODELS = dict(ANTHROPIC_MODELS)
|
|
38
|
+
|
|
39
|
+
# Cost per million tokens (USD), used for budget tracking. Anthropic ids
|
|
40
|
+
# stay un-prefixed; OpenRouter pass-through entries (`anthropic/...`,
|
|
41
|
+
# `openai/...`) are stripped to their base model id at lookup time so we
|
|
42
|
+
# don't have to duplicate every Anthropic price under both keys.
|
|
43
|
+
MODEL_COSTS: dict[str, dict[str, float]] = {
|
|
44
|
+
"claude-opus-4-0-20250514": {"input": 15.0, "output": 75.0},
|
|
45
|
+
DEFAULT_MODEL: {"input": 3.0, "output": 15.0},
|
|
46
|
+
"claude-haiku-4-5-20251001": {"input": 0.80, "output": 4.0},
|
|
47
|
+
# OpenAI models routed via OpenRouter; pricing per OpenAI public list.
|
|
48
|
+
"gpt-4o": {"input": 2.5, "output": 10.0},
|
|
49
|
+
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
|
|
50
|
+
"gpt-4-turbo": {"input": 10.0, "output": 30.0},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _lookup_cost(model: str) -> dict[str, float]:
|
|
55
|
+
"""Return the per-million-token pricing for a model id.
|
|
56
|
+
|
|
57
|
+
Accepts both bare ids ('claude-sonnet-4-5-20250929') and OpenRouter-
|
|
58
|
+
style provider-prefixed ids ('anthropic/claude-sonnet-4-5-20250929',
|
|
59
|
+
'openai/gpt-4o-mini'); falls back to the bare id after splitting the
|
|
60
|
+
provider prefix once. Unknown models price at zero so the cost ledger
|
|
61
|
+
doesn't crash, but we lose accuracy.
|
|
62
|
+
"""
|
|
63
|
+
if model in MODEL_COSTS:
|
|
64
|
+
return MODEL_COSTS[model]
|
|
65
|
+
if "/" in model:
|
|
66
|
+
bare = model.split("/", 1)[1]
|
|
67
|
+
if bare in MODEL_COSTS:
|
|
68
|
+
return MODEL_COSTS[bare]
|
|
69
|
+
return {"input": 0.0, "output": 0.0}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_CRITIQUE_CRITERIA: dict[str, str] = {
|
|
73
|
+
"content": (
|
|
74
|
+
"1. ACCURACY — Are claims grounded in the provided context? Any hallucinated facts?\n"
|
|
75
|
+
"2. CLARITY — Is the writing clear, scannable, and free of jargon-for-jargon's-sake?\n"
|
|
76
|
+
"3. ACTIONABILITY — Does the reader leave with something concrete to do?\n"
|
|
77
|
+
"4. STRUCTURE — Logical flow, good heading hierarchy, appropriate length?\n"
|
|
78
|
+
"5. VOICE — Developer-authentic, not marketing fluff or AI slop?\n"
|
|
79
|
+
"6. CODE QUALITY — Are code examples complete, correct, and well-commented?"
|
|
80
|
+
),
|
|
81
|
+
"sales": (
|
|
82
|
+
"1. ACCURACY — Are claims grounded in product facts? No overpromising?\n"
|
|
83
|
+
"2. CLARITY — Is the message scannable, short paragraphs, no filler?\n"
|
|
84
|
+
"3. PERSUASIVENESS — Does it sell the next step, not the whole product?\n"
|
|
85
|
+
"4. PERSONALIZATION — Does it reference the recipient's specific situation?\n"
|
|
86
|
+
"5. VOICE — Developer-aware, not corporate marketing speak?\n"
|
|
87
|
+
"6. CTA — One clear, low-friction call to action?"
|
|
88
|
+
),
|
|
89
|
+
"marketing": (
|
|
90
|
+
"1. ACCURACY — Are claims grounded in product knowledge base?\n"
|
|
91
|
+
"2. CLARITY — Short paragraphs, clear hierarchy, mobile-readable?\n"
|
|
92
|
+
"3. DIFFERENTIATION — Does it position against alternatives with evidence?\n"
|
|
93
|
+
"4. STRUCTURE — Appropriate format for the content type (blog/landing/social)?\n"
|
|
94
|
+
"5. VOICE — Developer-authentic, storytelling over selling?\n"
|
|
95
|
+
"6. CTA — One clear next step per piece?"
|
|
96
|
+
),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
CRITIQUE_PROMPT = """You are a senior content editor. Review the following draft and
|
|
100
|
+
provide a structured critique as JSON.
|
|
101
|
+
|
|
102
|
+
## Draft
|
|
103
|
+
{draft}
|
|
104
|
+
|
|
105
|
+
## Evaluation Criteria
|
|
106
|
+
{criteria}
|
|
107
|
+
|
|
108
|
+
Return ONLY a JSON object:
|
|
109
|
+
{{
|
|
110
|
+
"overall_score": <1-10>,
|
|
111
|
+
"issues": [
|
|
112
|
+
{{"criterion": "...", "severity": "high|medium|low", "description": "...", "fix": "..."}}
|
|
113
|
+
],
|
|
114
|
+
"strengths": ["..."]
|
|
115
|
+
}}"""
|
|
116
|
+
|
|
117
|
+
REVISE_PROMPT = """Revise the following draft by applying all editorial feedback.
|
|
118
|
+
For fixes that require additions (missing sections, examples), add them.
|
|
119
|
+
For fixes that require cuts (over-long paragraphs, buzzwords), remove them.
|
|
120
|
+
For fixes that require rewrites, rewrite only the affected section.
|
|
121
|
+
Do not change sections that have no associated issues.
|
|
122
|
+
Return ONLY the revised content, no preamble or commentary.
|
|
123
|
+
|
|
124
|
+
## Original Draft
|
|
125
|
+
{draft}
|
|
126
|
+
|
|
127
|
+
## Editorial Feedback
|
|
128
|
+
{critique}
|
|
129
|
+
|
|
130
|
+
## Revised Draft"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class CritiqueResult:
|
|
135
|
+
"""Result of an editorial critique."""
|
|
136
|
+
|
|
137
|
+
overall_score: int = 0
|
|
138
|
+
issues: list[dict[str, str]] = field(default_factory=list)
|
|
139
|
+
strengths: list[str] = field(default_factory=list)
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def revision_needed(self) -> bool:
|
|
143
|
+
"""Computed: revise if score < 7 or any high-severity issue exists."""
|
|
144
|
+
if self.overall_score < 7:
|
|
145
|
+
return True
|
|
146
|
+
return any(i.get("severity") == "high" for i in self.issues)
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_json(cls, raw: str) -> "CritiqueResult":
|
|
150
|
+
cleaned = strip_markdown_fences(raw)
|
|
151
|
+
try:
|
|
152
|
+
data = json.loads(cleaned)
|
|
153
|
+
except json.JSONDecodeError:
|
|
154
|
+
return cls(overall_score=5)
|
|
155
|
+
return cls(
|
|
156
|
+
overall_score=data.get("overall_score", 5),
|
|
157
|
+
issues=data.get("issues", []),
|
|
158
|
+
strengths=data.get("strengths", []),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class RevisionTrace:
|
|
164
|
+
"""Tracks the full revision history for a generation."""
|
|
165
|
+
|
|
166
|
+
drafts: list[str] = field(default_factory=list)
|
|
167
|
+
critiques: list[CritiqueResult] = field(default_factory=list)
|
|
168
|
+
final_score: int = 0
|
|
169
|
+
revision_rounds: int = 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class TokenUsage:
|
|
174
|
+
"""Tracks cumulative token usage, cost, and per-agent breakdown."""
|
|
175
|
+
|
|
176
|
+
total_input_tokens: int = 0
|
|
177
|
+
total_output_tokens: int = 0
|
|
178
|
+
total_calls: int = 0
|
|
179
|
+
total_cost_usd: float = 0.0
|
|
180
|
+
per_agent: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
181
|
+
|
|
182
|
+
def record(
|
|
183
|
+
self,
|
|
184
|
+
input_tokens: int,
|
|
185
|
+
output_tokens: int,
|
|
186
|
+
agent: str = "",
|
|
187
|
+
model: str = "",
|
|
188
|
+
) -> None:
|
|
189
|
+
self.total_input_tokens += input_tokens
|
|
190
|
+
self.total_output_tokens += output_tokens
|
|
191
|
+
self.total_calls += 1
|
|
192
|
+
|
|
193
|
+
# Compute cost. _lookup_cost handles both bare Anthropic ids and
|
|
194
|
+
# OpenRouter-style 'provider/model' paths so the ledger works for
|
|
195
|
+
# whichever backend ran the call.
|
|
196
|
+
costs = _lookup_cost(model) if model else MODEL_COSTS[DEFAULT_MODEL]
|
|
197
|
+
call_cost = (
|
|
198
|
+
input_tokens * costs["input"] / 1_000_000 + output_tokens * costs["output"] / 1_000_000
|
|
199
|
+
)
|
|
200
|
+
self.total_cost_usd += call_cost
|
|
201
|
+
|
|
202
|
+
if agent:
|
|
203
|
+
if agent not in self.per_agent:
|
|
204
|
+
self.per_agent[agent] = {
|
|
205
|
+
"input_tokens": 0,
|
|
206
|
+
"output_tokens": 0,
|
|
207
|
+
"calls": 0,
|
|
208
|
+
"cost_usd": 0.0,
|
|
209
|
+
}
|
|
210
|
+
self.per_agent[agent]["input_tokens"] += input_tokens
|
|
211
|
+
self.per_agent[agent]["output_tokens"] += output_tokens
|
|
212
|
+
self.per_agent[agent]["calls"] += 1
|
|
213
|
+
self.per_agent[agent]["cost_usd"] += call_cost
|
|
214
|
+
|
|
215
|
+
def to_dict(self) -> dict:
|
|
216
|
+
return {
|
|
217
|
+
"total_input_tokens": self.total_input_tokens,
|
|
218
|
+
"total_output_tokens": self.total_output_tokens,
|
|
219
|
+
"total_calls": self.total_calls,
|
|
220
|
+
"total_cost_usd": round(self.total_cost_usd, 4),
|
|
221
|
+
"per_agent": {
|
|
222
|
+
k: {**v, "cost_usd": round(v["cost_usd"], 4)} for k, v in self.per_agent.items()
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class LLMClient:
|
|
228
|
+
"""Async LLM client with multi-provider routing, per-agent model
|
|
229
|
+
overrides, and budget enforcement.
|
|
230
|
+
|
|
231
|
+
Supports per-call model overrides for cost optimization:
|
|
232
|
+
- Use "haiku" for classification/extraction tasks
|
|
233
|
+
- Use "opus" for high-quality content generation
|
|
234
|
+
- Use default "sonnet" for everything else
|
|
235
|
+
|
|
236
|
+
Per-agent overrides via `agent_models={agent_name: model_id}` are consulted
|
|
237
|
+
inside `_resolve_model` and win over the call-site default; explicit
|
|
238
|
+
`model=` arguments still win over the per-agent setting. The ContextVar
|
|
239
|
+
set by `agent_context()` drives the lookup so concurrent agents under
|
|
240
|
+
`asyncio.gather` each get their own configured model.
|
|
241
|
+
|
|
242
|
+
Budget enforcement: when total spend exceeds ``budget_limit_usd``, future
|
|
243
|
+
calls are forced to the backend's `cheap_model` (Haiku for Anthropic,
|
|
244
|
+
`anthropic/claude-haiku-4-5-...` for OpenRouter).
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def __init__(
|
|
248
|
+
self,
|
|
249
|
+
api_key: str = "",
|
|
250
|
+
model: str = "",
|
|
251
|
+
max_tokens: int = DEFAULT_MAX_TOKENS,
|
|
252
|
+
budget_limit_usd: float = 0.0,
|
|
253
|
+
*,
|
|
254
|
+
backend: LLMBackend | None = None,
|
|
255
|
+
provider: str | None = None,
|
|
256
|
+
openrouter_api_key: str = "",
|
|
257
|
+
agent_models: dict[str, str] | None = None,
|
|
258
|
+
):
|
|
259
|
+
# Backend resolution: caller-supplied wins; otherwise auto-detect from
|
|
260
|
+
# explicit `provider` arg or env vars (OPENROUTER_API_KEY presence).
|
|
261
|
+
self._backend: LLMBackend = backend or make_backend(
|
|
262
|
+
provider=provider,
|
|
263
|
+
anthropic_api_key=api_key,
|
|
264
|
+
openrouter_api_key=openrouter_api_key,
|
|
265
|
+
)
|
|
266
|
+
# Per-instance default model. Empty defers to the backend's default,
|
|
267
|
+
# which keeps callers that pass api_key but no model on the right
|
|
268
|
+
# default for whichever backend was auto-selected.
|
|
269
|
+
self.model = model or self._backend.default_model
|
|
270
|
+
self.max_tokens = max_tokens
|
|
271
|
+
self.budget_limit_usd = budget_limit_usd
|
|
272
|
+
self.agent_models: dict[str, str] = dict(agent_models or {})
|
|
273
|
+
self.usage = TokenUsage()
|
|
274
|
+
self._current_agent: str = ""
|
|
275
|
+
self._budget_exhausted = False
|
|
276
|
+
self._cost_sink: "Callable[[str, str, dict[str, Any]], Awaitable[None]] | None" = None
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def backend(self) -> LLMBackend:
|
|
280
|
+
return self._backend
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def _client(self):
|
|
284
|
+
"""Back-compat shim. Pre-multi-provider tests reached into
|
|
285
|
+
``client._client.messages.create`` to mock the Anthropic SDK; new code
|
|
286
|
+
should mock ``client._backend.chat`` (returns a ``BackendResponse``)
|
|
287
|
+
instead. Returns the AnthropicBackend's underlying SDK client when
|
|
288
|
+
present, otherwise None."""
|
|
289
|
+
return getattr(self._backend, "_client", None)
|
|
290
|
+
|
|
291
|
+
def _resolve_model(self, model_override: str | None) -> str:
|
|
292
|
+
"""Resolve the model id to use for a call, in priority order:
|
|
293
|
+
|
|
294
|
+
1. Budget downgrade (forced cheap model, overrides everything)
|
|
295
|
+
2. Explicit ``model=`` argument at the call site
|
|
296
|
+
3. Per-agent override from ``agent_models[current_agent]``
|
|
297
|
+
4. The instance default (``self.model``)
|
|
298
|
+
|
|
299
|
+
Whatever value comes out is then run through the backend's
|
|
300
|
+
``resolve_alias`` so shorthand ('haiku' / 'sonnet' / 'opus') ends up as
|
|
301
|
+
a real provider id before the chat call.
|
|
302
|
+
"""
|
|
303
|
+
if self._budget_exhausted:
|
|
304
|
+
return self._backend.cheap_model
|
|
305
|
+
if model_override:
|
|
306
|
+
return self._backend.resolve_alias(model_override)
|
|
307
|
+
agent = _current_agent_var.get() or self._current_agent
|
|
308
|
+
if agent and agent in self.agent_models:
|
|
309
|
+
return self._backend.resolve_alias(self.agent_models[agent])
|
|
310
|
+
return self._backend.resolve_alias(self.model)
|
|
311
|
+
|
|
312
|
+
def _check_budget(self) -> None:
|
|
313
|
+
"""Check if budget limit has been exceeded."""
|
|
314
|
+
if (
|
|
315
|
+
self.budget_limit_usd > 0
|
|
316
|
+
and not self._budget_exhausted
|
|
317
|
+
and self.usage.total_cost_usd >= self.budget_limit_usd * 0.95
|
|
318
|
+
):
|
|
319
|
+
self._budget_exhausted = True
|
|
320
|
+
logger.warning(
|
|
321
|
+
"budget_limit_reached",
|
|
322
|
+
extra={
|
|
323
|
+
"spent": round(self.usage.total_cost_usd, 4),
|
|
324
|
+
"limit": self.budget_limit_usd,
|
|
325
|
+
"action": "downgrading to haiku",
|
|
326
|
+
},
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
async def generate(
|
|
330
|
+
self,
|
|
331
|
+
system_prompt: str,
|
|
332
|
+
user_prompt: str,
|
|
333
|
+
temperature: float = 0.7,
|
|
334
|
+
max_tokens: int | None = None,
|
|
335
|
+
model: str | None = None,
|
|
336
|
+
) -> str:
|
|
337
|
+
"""Send a prompt and return the response text.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
model: Optional model override, "haiku" / "sonnet" / "opus", or a
|
|
341
|
+
full model id (Anthropic bare or OpenRouter-style). Per-agent
|
|
342
|
+
overrides apply when this is left None. Budget downgrade still
|
|
343
|
+
wins over both.
|
|
344
|
+
"""
|
|
345
|
+
resolved_model = self._resolve_model(model)
|
|
346
|
+
response = await self._backend.chat(
|
|
347
|
+
model=resolved_model,
|
|
348
|
+
system_prompt=system_prompt,
|
|
349
|
+
user_prompt=user_prompt,
|
|
350
|
+
temperature=temperature,
|
|
351
|
+
max_tokens=max_tokens or self.max_tokens,
|
|
352
|
+
)
|
|
353
|
+
# The backend may have downgraded or upgraded the model id (provider
|
|
354
|
+
# routing); record against the actually-served model so cost lookups
|
|
355
|
+
# match what was billed.
|
|
356
|
+
served_model = response.model or resolved_model
|
|
357
|
+
agent_for_record = _current_agent_var.get() or self._current_agent
|
|
358
|
+
self.usage.record(
|
|
359
|
+
input_tokens=response.input_tokens,
|
|
360
|
+
output_tokens=response.output_tokens,
|
|
361
|
+
agent=agent_for_record,
|
|
362
|
+
model=served_model,
|
|
363
|
+
)
|
|
364
|
+
self._check_budget()
|
|
365
|
+
await self._emit_cost(
|
|
366
|
+
model=served_model,
|
|
367
|
+
input_tokens=response.input_tokens,
|
|
368
|
+
output_tokens=response.output_tokens,
|
|
369
|
+
cache_creation_input_tokens=response.cache_creation_input_tokens,
|
|
370
|
+
cache_read_input_tokens=response.cache_read_input_tokens,
|
|
371
|
+
)
|
|
372
|
+
logger.info(
|
|
373
|
+
"llm_call",
|
|
374
|
+
extra={
|
|
375
|
+
"agent": agent_for_record or "unknown",
|
|
376
|
+
"backend": self._backend.name,
|
|
377
|
+
"input_tokens": response.input_tokens,
|
|
378
|
+
"output_tokens": response.output_tokens,
|
|
379
|
+
"model": served_model,
|
|
380
|
+
"cost_usd": round(self.usage.total_cost_usd, 4),
|
|
381
|
+
"cumulative_calls": self.usage.total_calls,
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
return response.text
|
|
385
|
+
|
|
386
|
+
async def aclose(self) -> None:
|
|
387
|
+
"""Release the underlying backend client (httpx pool / SDK client)."""
|
|
388
|
+
await self._backend.aclose()
|
|
389
|
+
|
|
390
|
+
def set_agent(self, agent_name: str) -> None:
|
|
391
|
+
"""Set the current agent name for per-agent cost tracking."""
|
|
392
|
+
self._current_agent = agent_name
|
|
393
|
+
|
|
394
|
+
@contextmanager
|
|
395
|
+
def agent_context(self, agent_name: str):
|
|
396
|
+
"""Set the cost-attribution agent for the duration of this context.
|
|
397
|
+
|
|
398
|
+
Async-task-local via ContextVar — safe under asyncio.gather() unlike
|
|
399
|
+
set_agent(), which mutates a shared instance attribute. Prefer this
|
|
400
|
+
over set_agent() when running agents concurrently.
|
|
401
|
+
"""
|
|
402
|
+
token = _current_agent_var.set(agent_name)
|
|
403
|
+
try:
|
|
404
|
+
yield
|
|
405
|
+
finally:
|
|
406
|
+
_current_agent_var.reset(token)
|
|
407
|
+
|
|
408
|
+
def set_cost_sink(
|
|
409
|
+
self,
|
|
410
|
+
sink: "Callable[[str, str, dict[str, Any]], Awaitable[None]] | None",
|
|
411
|
+
) -> None:
|
|
412
|
+
"""Register async callback ``(agent, model, usage_dict) -> None``.
|
|
413
|
+
|
|
414
|
+
Called once per successful Anthropic API response. ``None`` clears
|
|
415
|
+
the sink. Sink exceptions are caught and logged at WARNING — they
|
|
416
|
+
never break the LLM call (cost recording is best-effort).
|
|
417
|
+
"""
|
|
418
|
+
self._cost_sink = sink
|
|
419
|
+
|
|
420
|
+
async def _emit_cost(
|
|
421
|
+
self,
|
|
422
|
+
model: str,
|
|
423
|
+
input_tokens: int,
|
|
424
|
+
output_tokens: int,
|
|
425
|
+
cache_creation_input_tokens: int = 0,
|
|
426
|
+
cache_read_input_tokens: int = 0,
|
|
427
|
+
) -> None:
|
|
428
|
+
if self._cost_sink is None:
|
|
429
|
+
return
|
|
430
|
+
# Shield the sink call so an outer cancellation (e.g. Atlas's per-agent
|
|
431
|
+
# timeout firing between the API response and this write) doesn't drop
|
|
432
|
+
# the cost row. The Anthropic call already returned and we've been billed;
|
|
433
|
+
# the sink coroutine has no inner awaits, so once the event loop schedules
|
|
434
|
+
# it, the SQLite commit completes synchronously even if the calling task
|
|
435
|
+
# is being torn down. CancelledError is BaseException in 3.8+ so it
|
|
436
|
+
# bypasses `except Exception`; re-raise it to preserve cancellation
|
|
437
|
+
# semantics for the caller.
|
|
438
|
+
coro = self._cost_sink(
|
|
439
|
+
_current_agent_var.get() or self._current_agent or "unknown",
|
|
440
|
+
model,
|
|
441
|
+
{
|
|
442
|
+
"input_tokens": input_tokens,
|
|
443
|
+
"output_tokens": output_tokens,
|
|
444
|
+
"cache_creation_input_tokens": cache_creation_input_tokens,
|
|
445
|
+
"cache_read_input_tokens": cache_read_input_tokens,
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
try:
|
|
449
|
+
await asyncio.shield(coro)
|
|
450
|
+
except asyncio.CancelledError:
|
|
451
|
+
raise
|
|
452
|
+
except Exception as e:
|
|
453
|
+
logger.warning("cost sink raised; ignoring: %s", e)
|
|
454
|
+
|
|
455
|
+
async def critique(
|
|
456
|
+
self,
|
|
457
|
+
draft: str,
|
|
458
|
+
content_type: str = "content",
|
|
459
|
+
) -> CritiqueResult:
|
|
460
|
+
"""Run editorial critique on a draft, return structured feedback.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
draft: The content to critique.
|
|
464
|
+
content_type: One of "content" (tutorials/blogs), "sales"
|
|
465
|
+
(outreach/battle cards), "marketing" (landing pages/social).
|
|
466
|
+
Selects appropriate evaluation criteria.
|
|
467
|
+
"""
|
|
468
|
+
criteria = _CRITIQUE_CRITERIA.get(content_type, _CRITIQUE_CRITERIA["content"])
|
|
469
|
+
raw = await self.generate(
|
|
470
|
+
system_prompt="You are a senior content editor.",
|
|
471
|
+
user_prompt=CRITIQUE_PROMPT.format(
|
|
472
|
+
draft=draft[:12000],
|
|
473
|
+
criteria=criteria,
|
|
474
|
+
),
|
|
475
|
+
temperature=0.3,
|
|
476
|
+
max_tokens=2048,
|
|
477
|
+
)
|
|
478
|
+
return CritiqueResult.from_json(raw)
|
|
479
|
+
|
|
480
|
+
async def generate_with_revision(
|
|
481
|
+
self,
|
|
482
|
+
system_prompt: str,
|
|
483
|
+
user_prompt: str,
|
|
484
|
+
temperature: float = 0.7,
|
|
485
|
+
max_tokens: int | None = None,
|
|
486
|
+
max_rounds: int = 2,
|
|
487
|
+
min_score: int = 7,
|
|
488
|
+
content_type: str = "content",
|
|
489
|
+
) -> tuple[str, RevisionTrace]:
|
|
490
|
+
"""Generate content with a critique-then-revise loop.
|
|
491
|
+
|
|
492
|
+
Produces a draft, critiques it, and revises if the score is below
|
|
493
|
+
*min_score* or any high-severity issue is flagged. Repeats up to
|
|
494
|
+
*max_rounds* times.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
content_type: Selects critique criteria — "content", "sales",
|
|
498
|
+
or "marketing".
|
|
499
|
+
|
|
500
|
+
Returns the final content and the full revision trace.
|
|
501
|
+
"""
|
|
502
|
+
trace = RevisionTrace()
|
|
503
|
+
|
|
504
|
+
# Initial generation
|
|
505
|
+
draft = await self.generate(
|
|
506
|
+
system_prompt=system_prompt,
|
|
507
|
+
user_prompt=user_prompt,
|
|
508
|
+
temperature=temperature,
|
|
509
|
+
max_tokens=max_tokens,
|
|
510
|
+
)
|
|
511
|
+
trace.drafts.append(draft)
|
|
512
|
+
|
|
513
|
+
for _round in range(max_rounds):
|
|
514
|
+
crit = await self.critique(draft, content_type=content_type)
|
|
515
|
+
trace.critiques.append(crit)
|
|
516
|
+
trace.final_score = crit.overall_score
|
|
517
|
+
|
|
518
|
+
if not crit.revision_needed and crit.overall_score >= min_score:
|
|
519
|
+
break
|
|
520
|
+
|
|
521
|
+
# Revise
|
|
522
|
+
critique_text = json.dumps(
|
|
523
|
+
{"issues": crit.issues, "strengths": crit.strengths},
|
|
524
|
+
indent=2,
|
|
525
|
+
)
|
|
526
|
+
draft = await self.generate(
|
|
527
|
+
system_prompt=system_prompt,
|
|
528
|
+
user_prompt=REVISE_PROMPT.format(
|
|
529
|
+
draft=draft[:12000],
|
|
530
|
+
critique=critique_text,
|
|
531
|
+
),
|
|
532
|
+
temperature=max(temperature - 0.1, 0.2),
|
|
533
|
+
max_tokens=max_tokens,
|
|
534
|
+
)
|
|
535
|
+
trace.drafts.append(draft)
|
|
536
|
+
trace.revision_rounds += 1
|
|
537
|
+
|
|
538
|
+
return draft, trace
|
|
539
|
+
|
|
540
|
+
async def close(self) -> None:
|
|
541
|
+
"""Close the underlying HTTP client."""
|
|
542
|
+
await self._client.close()
|