Spaces:
Running
Running
| """OpenRouter transport β config, one bounded request, and a safe error type. | |
| This is all that survives of the old dee/core/agent.py. That module also held | |
| a session store, a system prompt, a single-shot tool-calling loop | |
| (run_agent_step) and a separate project-consultation turn (run_consult). | |
| Every one of those was superseded by dee/core/orchestrator.py, whose long- | |
| running RUN does the same jobs better β it asks mid-run instead of resolving | |
| in one silent request, and it keeps working across turns. Their last callers | |
| went with the Director and the iframe chat, so they were removed rather than | |
| left as a second way to do the same thing. | |
| What's left is deliberately small: make one chat-completions call, don't leak | |
| anything, and hand the parsed message back. The orchestrator owns the loop, | |
| the prompt, and the state. | |
| SECURITY β the reason this file reads so defensively: | |
| every exception below is logged in full server-side and surfaced to callers | |
| as a fixed, generic message. Never str(exc). A real incident (2026-07-08) is | |
| why: a copy-pasted API key with a trailing newline made the HTTP client raise | |
| an "invalid header value" error whose message embeds the offending header | |
| VERBATIM β i.e. the bearer token β and that string reached the client through | |
| an error response, live, in production. config.api_key must never appear in a | |
| raised AgentError, even indirectly. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from dataclasses import dataclass | |
| from typing import Any, Dict, Optional, Tuple | |
| import requests | |
| logger = logging.getLogger("dee.llm") | |
| class AgentError(Exception): | |
| """A failure worth surfacing to the caller as a clean message. | |
| kind mirrors the "kind" field the rest of the REST API already sends on | |
| error responses (e.g. "signin_required", "rate_limited"), so a route can | |
| pass it straight through instead of collapsing every failure into one | |
| generic value β letting the frontend render distinct cases (like "the | |
| model is temporarily unavailable") with their own copy.""" | |
| def __init__(self, message: str, kind: str = "agent_error") -> None: | |
| super().__init__(message) | |
| self.kind = kind | |
| # google/gemini-3.5-flash's context window β verified against | |
| # openrouter.ai/google/gemini-3.5-flash (2026-07-08, same check as the model | |
| # slug below). Used only to compute a "% of context used" figure for the UI; | |
| # there's no live per-model lookup here, so if AGENT_MODEL is ever changed to | |
| # a different model, update this constant too. | |
| CONTEXT_WINDOW_TOKENS = 1_000_000 | |
| class OpenRouterConfig: | |
| api_key: str | |
| model: str | |
| max_steps: int | |
| max_cost_usd: float | |
| # "medium" is google/gemini-3.5-flash's own default (confirmed via | |
| # GET /api/v1/models β this model's reasoning object reports | |
| # default_effort: "medium", mandatory: true). Reasoning tokens are | |
| # billed as OUTPUT tokens, and this model's output is $9.00/M vs | |
| # $1.50/M input (openrouter.ai/google/gemini-3.5-flash, checked | |
| # 2026-08-10) β 6x the price, on by default, for a tool-orchestration | |
| # task (pick from a documented catalog, not open-ended reasoning) that | |
| # doesn't need medium-depth thinking on every one of up to 8 steps a | |
| # single run can take. "low" is a fully supported effort level for | |
| # this model (not experimental) β "none"/"minimal" are NOT used here | |
| # because mandatory: true means this model can reject effort: "none", | |
| # and tool-selection accuracy is worth not pushing to the floor. | |
| reasoning_effort: str = "low" | |
| # Hard ceiling on a single reply's OUTPUT tokens. Nothing set one before, | |
| # which left the most expensive token class on the bill uncapped: | |
| # $9.00/M output against $1.50/M input β 6x β and, unlike input, output | |
| # can never be served from a prompt cache. Once the input prefix caches, | |
| # output IS the dominant marginal cost of a run. | |
| # | |
| # This is a tail-risk cap, not a routine saving. Normal replies land far | |
| # under it, so it changes nothing about ordinary quality; what it stops is | |
| # one run deciding to write an essay, or looping on a verbose tool, and | |
| # billing the whole way at the priciest rate. max_cost_usd catches that | |
| # too, but only AFTER the tokens are spent β this catches it per reply. | |
| # | |
| # 8,000 is deliberately generous: the longest legitimate thing this agent | |
| # writes is a methods-style summary, and truncating one of those mid- | |
| # sentence would be a quality regression to save a fraction of a cent. | |
| # Raise it if a real reply is ever cut off; do not lower it to save money | |
| # without checking what got clipped. | |
| max_output_tokens: int = 8_000 | |
| def from_env(cls) -> Optional["OpenRouterConfig"]: | |
| # .strip() β a copy-pasted secret with a trailing newline produced an | |
| # invalid HTTP header ("Bearer <key>\n\n"), which the underlying HTTP | |
| # client rejected with an exception whose message embedded the raw | |
| # header value. See the module docstring: that leak reached production. | |
| # Stripping here prevents the malformed-header case entirely; | |
| # _call_openrouter no longer echoes exception text either way. | |
| api_key = (os.environ.get("OPENROUTER_API_KEY") or "").strip() | |
| if not api_key: | |
| return None | |
| return cls( | |
| api_key=api_key, | |
| # Verified against openrouter.ai/google/gemini-3.5-flash directly | |
| # (2026-07-08) β don't change this without re-checking the slug, | |
| # Google's Gemini naming moves fast and a stale slug just 400s. | |
| model=os.environ.get("AGENT_MODEL", "google/gemini-3.5-flash"), | |
| max_steps=int(os.environ.get("AGENT_MAX_STEPS", "8")), | |
| max_cost_usd=float(os.environ.get("AGENT_MAX_COST_USD", "0.5")), | |
| reasoning_effort=os.environ.get("AGENT_REASONING_EFFORT", "low"), | |
| max_output_tokens=int(os.environ.get("AGENT_MAX_OUTPUT_TOKENS", "8000")), | |
| ) | |
| def call(config: OpenRouterConfig, body: Dict[str, Any]) -> Tuple[Dict[str, Any], float, int, int]: | |
| """POST one chat-completions request. | |
| Returns (message_dict, cost_usd, prompt_tokens, cached_tokens). | |
| prompt_tokens is the size of the WHOLE messages array just sent, so it | |
| naturally grows as a conversation lengthens β that's what drives the | |
| context-usage figure. cached_tokens is how much of that the provider | |
| served from its prompt cache; see the note at the return statement for why | |
| it is measured at all. | |
| Raises AgentError, never anything with provider text or the key in it. | |
| """ | |
| try: | |
| resp = requests.post( | |
| "https://openrouter.ai/api/v1/chat/completions", | |
| headers={ | |
| "Authorization": f"Bearer {config.api_key}", | |
| "Content-Type": "application/json", | |
| }, | |
| json=body, | |
| timeout=30, | |
| ) | |
| except Exception as exc: # noqa: BLE001 β deliberately broad, see docstring | |
| logger.exception("OpenRouter request failed") | |
| raise AgentError("could not reach OpenRouter") from exc | |
| # ββ Failure copy, by case ββββββββββββββββββββββββββββββββββββββββββββ | |
| # Two constraints pull against each other here and both are real: | |
| # | |
| # Β· Never surface billing language. "Insufficient credits" reads as | |
| # "this product ran out of money", which is alarming and none of the | |
| # user's business. | |
| # Β· Never tell a paying user the product is unfinished. The previous | |
| # copy β "Turing is still in active development" β was written when | |
| # that was merely true and harmless. To someone evaluating whether to | |
| # rely on this for lab work it reads as "this is a prototype, don't | |
| # build on it", which is worse than the billing detail it was hiding. | |
| # | |
| # What satisfies both: name the SHAPE of the failure (transient, ours, | |
| # retryable) without its cause, and give a concrete next step. Each case | |
| # gets its own `kind` so the client can react differently. | |
| if resp.status_code == 402: | |
| # OpenRouter's status specifically for "account/key is out of credits" | |
| # (distinct from 401 auth / 403 permissions / 429 rate limit) β | |
| # https://openrouter.ai/docs, confirmed 2026-07-10. | |
| logger.warning("OpenRouter out of credits (402): %s", resp.text[:500]) | |
| raise AgentError( | |
| "Turing is temporarily at capacity β that's on our side, not " | |
| "your run. Your workspace is unaffected; try again in a few " | |
| "minutes.", | |
| kind="agent_capacity", | |
| ) | |
| if resp.status_code == 429: | |
| logger.warning("OpenRouter rate limited (429): %s", resp.text[:500]) | |
| raise AgentError( | |
| "Turing is handling a lot of requests right now. Give it about a " | |
| "minute and send that again.", | |
| kind="agent_busy", | |
| ) | |
| if resp.status_code >= 500: | |
| logger.warning("OpenRouter upstream %s: %s", resp.status_code, resp.text[:500]) | |
| raise AgentError( | |
| "The model provider is having a moment. Nothing was lost β send " | |
| "that again and it'll pick up where it left off.", | |
| kind="agent_upstream", | |
| ) | |
| if resp.status_code != 200: | |
| # OpenRouter's error body is small and safe to LOG; never echo it | |
| # verbatim to the client (it can carry the key's own account info). | |
| # 4xx that isn't 402/429 is a bug in OUR request, not their problem. | |
| logger.warning("OpenRouter error %s: %s", resp.status_code, resp.text[:500]) | |
| raise AgentError( | |
| "Turing hit an unexpected error talking to the model. This one is " | |
| "logged on our side β please retry.", | |
| kind="agent_error", | |
| ) | |
| try: | |
| parsed = resp.json() | |
| choices = parsed.get("choices") or [] | |
| if not choices: | |
| raise AgentError("empty response from OpenRouter") | |
| message = choices[0].get("message") or {} | |
| except AgentError: | |
| raise | |
| except Exception as exc: # noqa: BLE001 β malformed upstream JSON, etc. | |
| logger.exception("failed to parse OpenRouter response") | |
| raise AgentError("could not parse OpenRouter's response") from exc | |
| usage = parsed.get("usage") or {} | |
| cost = usage.get("total_cost") or usage.get("cost") or 0.0 | |
| prompt_tokens = int(usage.get("prompt_tokens") or 0) | |
| # ββ Cache visibility βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ~14.5k tokens of tool specs + system prompt are resent on EVERY step of | |
| # every run, and ~99% of that payload is byte-identical step to step. That | |
| # repetition, not the model's thinking, is the dominant line item on a run. | |
| # | |
| # Gemini caches such a prefix implicitly and bills the hit at a fraction of | |
| # the input rate, so the single biggest cost lever is simply whether the | |
| # prefix stays stable enough to hit. Until now nothing here read the field | |
| # that says whether it did β the cost could have halved or not moved at all | |
| # and the logs would look identical either way. Measure first: a cache | |
| # change you cannot observe is a guess, and this session already spent two | |
| # deploys on confident guesses that were wrong. | |
| # | |
| # Providers disagree on where the number lives (OpenRouter normalises to | |
| # prompt_tokens_details.cached_tokens; some pass through a flat | |
| # cached_tokens), so read both rather than assume one shape. Absent | |
| # entirely -> 0, which is honestly indistinguishable from "no hit" and is | |
| # the safe reading. | |
| details = usage.get("prompt_tokens_details") or {} | |
| cached_tokens = int(details.get("cached_tokens") | |
| or usage.get("cached_tokens") | |
| or 0) | |
| # Reasoning tokens, logged for the same reason cached_tokens is: they are | |
| # billed as OUTPUT ($9.00/M, 6x input, never cacheable) and are spent on | |
| # every step, but nothing here has ever reported how many. Whether | |
| # reasoning is 5% or 50% of output decides whether dialling effort further | |
| # is the next real cost lever or a rounding error β and that is a question | |
| # to answer with a number, not a guess. | |
| comp = usage.get("completion_tokens_details") or {} | |
| reasoning_tokens = int(comp.get("reasoning_tokens") or 0) | |
| completion_tokens = int(usage.get("completion_tokens") or 0) | |
| if prompt_tokens: | |
| logger.info( | |
| "openrouter usage: prompt=%d cached=%d (%.0f%% hit) " | |
| "completion=%d reasoning=%d cost=%.5f", | |
| prompt_tokens, cached_tokens, | |
| 100.0 * cached_tokens / prompt_tokens, | |
| completion_tokens, reasoning_tokens, cost) | |
| return message, cost, prompt_tokens, cached_tokens | |