Spaces:
Sleeping
Sleeping
File size: 19,178 Bytes
2e818da | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 | """Cerebras SDK wrapper.
Responsibilities:
- structured_complete(): call API with strict JSON schema, parse into a Pydantic model
- stream_complete(): async generator yielding text tokens
- Rate-limit short-circuit: tracks cooldown window, raises CerebrasError immediately
- Health mailbox: get_health() exposes current state for the /api/health endpoint
- Schema builder: strips $defs and enforces additionalProperties=false at all levels
Each method is self-contained. If structured_complete breaks, stream still works.
"""
import json
import os
import time
import threading
from typing import Any, AsyncIterator, Dict, List, Optional, Type
from pydantic import BaseModel, ValidationError
from app.agents.cerebras_errors import CerebrasError, CerebrasErrorKind, classify_error
from app.observability.operation import observe_operation
MODEL_ID = "gemma-4-31b"
_deployment_env = os.getenv("DEPLOYMENT_ENV", "desktop")
_cerebras_concurrency = 5 if _deployment_env == "demo" else 50
_cerebras_semaphore = threading.Semaphore(_cerebras_concurrency)
class CerebrasClient:
def __init__(self, api_key: Optional[str] = None) -> None:
from cerebras.cloud.sdk import Cerebras
self._client = Cerebras(api_key=api_key or os.getenv("CEREBRAS_API_KEY"))
self._health: dict = {"status": "ok"}
self._rate_limit_until: float = 0.0
# ------------------------------------------------------------------ #
# Health #
# ------------------------------------------------------------------ #
def get_health(self) -> dict:
return dict(self._health)
# ------------------------------------------------------------------ #
# Internal helpers #
# ------------------------------------------------------------------ #
def _raise_if_cooling_down(self) -> None:
"""Short-circuit a call made while a rate-limit cooldown window is still
active -> raise RATE_LIMITED immediately instead of blocking a worker for
up to the cooldown and then hitting the API (which would just 429 again).
Matches the documented contract: 'tracks rate-limit cooldown windows and
short-circuits future calls without hitting the API.'
"""
remaining = self._rate_limit_until - time.time()
if remaining > 0:
err = CerebrasError(
kind=CerebrasErrorKind.RATE_LIMITED,
message=f"Rate limit cooldown active -> retry in {remaining:.0f}s.",
retry_after_seconds=int(remaining) + 1,
)
self._health = {"status": "error", **err.to_frontend_payload()}
raise err
async def _async_check_rate_limit(self) -> None:
self._raise_if_cooling_down()
def _sync_check_rate_limit(self) -> None:
self._raise_if_cooling_down()
def _handle_sdk_exc(self, exc: Exception, op: Any = None) -> None:
err = classify_error(exc)
if err.kind == CerebrasErrorKind.RATE_LIMITED and err.retry_after_seconds:
self._rate_limit_until = time.time() + err.retry_after_seconds
self._health = {"status": "error", **err.to_frontend_payload()}
if op is not None:
op.mark_error(err.kind.value)
raise err from exc
def _log_usage(self, project_id: str, operation: str, tokens: int | float, elapsed: float, model: str) -> None:
if not project_id or not isinstance(tokens, (int, float)) or not tokens or elapsed <= 0:
return
try:
from app.services.project_activity import ProjectActivityService
ProjectActivityService().record_llm_usage(project_id, operation, tokens, elapsed, model)
except Exception:
pass
def _build_schema(self, model: Type[BaseModel]) -> Dict[str, Any]:
raw = model.model_json_schema()
defs = raw.pop("$defs", {})
def _inline(obj: Any, path: frozenset = frozenset()) -> Any:
"""Recursively replace every {"$ref": "#/$defs/Name"} with a copy of
the referenced definition. Cerebras strict mode rejects $ref, so the
schema must be fully self-contained.
`path` tracks which $defs names are currently being expanded along
this branch, to detect a self-referential/recursive Pydantic model
(e.g. a tree node with children of its own type) -- inlining one of
those with no cycle check recurses forever (confirmed: RecursionError,
hit by HierNode before it was redesigned -- see
app/agents/d3/templates/_hierarchy.py). Cerebras's strict mode can't
represent true recursion anyway (it rejects $ref outright), so a
genuinely cyclic schema needs a bounded-depth redesign, not a runtime
workaround -- this raises a clear, immediate error naming the cycle
instead of silently blowing the stack.
"""
if isinstance(obj, list):
return [_inline(v, path) for v in obj]
if not isinstance(obj, dict):
return obj
ref = obj.get("$ref")
if isinstance(ref, str) and ref.startswith("#/$defs/"):
name = ref.split("/")[-1]
if name in path:
raise ValueError(
f"Cerebras schema build hit a self-referential/recursive model "
f"({name!r}) -- strict json_schema mode cannot represent unbounded "
f"recursion. Redesign it as a fixed-depth chain of distinct classes "
f"instead (see HierNode/HierNodeL2/HierNodeL3/HierLeaf in "
f"app/agents/d3/templates/_hierarchy.py for the pattern)."
)
target = defs.get(name, {})
merged = {**target, **{k: v for k, v in obj.items() if k != "$ref"}}
return _inline(merged, path | {name})
return {k: _inline(v, path) for k, v in obj.items()}
raw = _inline(raw)
raw["additionalProperties"] = False
def _fix(obj: Any) -> None:
if not isinstance(obj, dict):
return
if obj.get("type") == "object":
if "properties" not in obj and "anyOf" not in obj and isinstance(obj.get("additionalProperties"), dict):
# An open-ended dict[str, X] field -> Cerebras strict json_schema mode rejects
# any object schema that isn't fully enumerated ("Object fields require at least
# one of: 'properties' or 'anyOf'"). This only ever surfaces as a confusing 400
# from the API well after the request is built, so fail immediately and name the
# field instead. Reshape dict[str, X] into a list of an explicit {key, value} model.
raise ValueError(
f"Cerebras schema build hit an open-ended dict field ({obj.get('title', 'unnamed field')!r}) "
"-- strict json_schema mode requires every object schema to declare 'properties' or "
"'anyOf'. Replace the dict[str, X] field with a list of an explicit Pydantic model "
"(e.g. list[KeyValue] with key/value fields) instead."
)
obj.setdefault("additionalProperties", False)
if obj.get("type") == "string":
# Cerebras strict json_schema mode rejects minLength/maxLength on
# string types ("Invalid fields for schema with types ['string']").
obj.pop("minLength", None)
obj.pop("maxLength", None)
if obj.get("type") == "array":
# Cerebras likewise rejects JSON Schema cardinality keywords on
# arrays. Pydantic still enforces these constraints after the
# response is parsed; only the provider-facing schema is relaxed.
obj.pop("minItems", None)
obj.pop("maxItems", None)
for v in obj.values():
if isinstance(v, list):
for item in v:
_fix(item)
else:
_fix(v)
_fix(raw)
return raw
# ------------------------------------------------------------------ #
# Public API #
# ------------------------------------------------------------------ #
def structured_complete(
self, messages: List[Dict[str, Any]], output_model: Type[BaseModel], model: str | None = None, **kwargs
) -> BaseModel:
from cerebras.cloud.sdk import APIConnectionError as _ConnErr
project_id = str(kwargs.pop("project_id", "") or "")
trace_label = str(kwargs.pop("trace_label", output_model.__name__) or output_model.__name__)
with observe_operation(
"llm.generate",
subsystem="llm",
consumer="structured",
attributes={"llm_model": model or MODEL_ID, "llm_schema": output_model.__name__},
) as op:
self._sync_check_rate_limit()
schema = self._build_schema(output_model)
response_format = {
"type": "json_schema",
"json_schema": {
"name": output_model.__name__,
"strict": True,
"schema": schema,
},
}
last_exc: Exception = RuntimeError("unreachable")
max_retries = 3
print(f"[Cerebras] structured_complete queued trace={trace_label} schema={output_model.__name__}", flush=True)
for attempt in range(max_retries):
self._sync_check_rate_limit()
try:
t0 = time.time()
with _cerebras_semaphore:
print(
f"[Cerebras] structured_complete dispatch trace={trace_label} attempt={attempt + 1}/{max_retries}",
flush=True,
)
resp = self._client.chat.completions.create(
model=model or MODEL_ID, messages=messages, response_format=response_format, **kwargs
)
t1 = time.time()
tokens = getattr(resp.usage, "completion_tokens", 0)
if isinstance(tokens, (int, float)) and tokens and (t1 - t0) > 0:
suffix = " (retry)" if attempt > 0 else ""
print(f"[Cerebras Speed] structured_complete{suffix} trace={trace_label} generated {tokens} tokens in {t1-t0:.2f}s ({tokens/(t1-t0):.1f} tok/s)", flush=True)
self._log_usage(project_id, "structured_complete", tokens, t1 - t0, model or MODEL_ID)
op.add_count("llm_tokens", int(tokens))
raw = resp.choices[0].message.content
self._health = {"status": "ok"}
op.set("llm_attempts", attempt + 1)
return output_model.model_validate_json(raw)
except _ConnErr as exc:
# HTTP/2 server disconnect -> retry once with backoff
print(f"[Cerebras] connection error trace={trace_label} attempt={attempt + 1}; retrying", flush=True)
last_exc = exc
time.sleep(2)
continue
except Exception as exc:
err = classify_error(exc)
if err.kind == CerebrasErrorKind.RATE_LIMITED and attempt < max_retries - 1:
delay = err.retry_after_seconds or (2 ** attempt)
print(f"[Cerebras] Rate limited. Retrying structured_complete in {delay}s...")
time.sleep(delay)
last_exc = exc
continue
if isinstance(exc, (json.JSONDecodeError, ValidationError)):
print(f"[Cerebras] structured response validation failed trace={trace_label} attempt={attempt + 1}", flush=True)
last_exc = exc
if attempt == 0:
continue
self._handle_sdk_exc(exc, op)
self._handle_sdk_exc(last_exc, op)
def complete_with_tools(
self,
messages: List[Dict[str, Any]],
tools: List[Dict[str, Any]],
tool_choice: str = "auto",
model: str | None = None,
**kwargs
):
"""Non-streaming completion that may return tool calls.
Returns the raw SDK message object (has `.content` and `.tool_calls`).
The caller inspects `.tool_calls`, executes them, appends the results,
then streams the final answer via stream_complete().
"""
max_retries = 3
last_exc: Exception = RuntimeError("unreachable")
project_id = str(kwargs.pop("project_id", "") or "")
with observe_operation(
"llm.generate", subsystem="llm", consumer="tools",
attributes={"llm_model": model or MODEL_ID},
) as op:
for attempt in range(max_retries):
self._sync_check_rate_limit()
try:
t0 = time.time()
with _cerebras_semaphore:
resp = self._client.chat.completions.create(
model=model or MODEL_ID,
messages=messages,
tools=tools,
tool_choice=tool_choice,
**kwargs
)
t1 = time.time()
tokens = getattr(resp.usage, "completion_tokens", 0)
if isinstance(tokens, (int, float)) and tokens and (t1 - t0) > 0:
print(f"[Cerebras Speed] complete_with_tools generated {tokens} tokens in {t1-t0:.2f}s ({tokens/(t1-t0):.1f} tok/s)")
self._log_usage(project_id, "complete_with_tools", tokens, t1 - t0, model or MODEL_ID)
op.add_count("llm_tokens", int(tokens))
self._health = {"status": "ok"}
op.set("llm_attempts", attempt + 1)
return resp.choices[0].message
except Exception as exc:
err = classify_error(exc)
if err.kind == CerebrasErrorKind.RATE_LIMITED and attempt < max_retries - 1:
delay = err.retry_after_seconds or (2 ** attempt)
print(f"[Cerebras] Rate limited. Retrying complete_with_tools in {delay}s...")
time.sleep(delay)
last_exc = exc
continue
self._handle_sdk_exc(exc, op)
self._handle_sdk_exc(last_exc, op)
async def stream_complete(self, messages: List[Dict[str, Any]], model: str | None = None, **kwargs) -> AsyncIterator[str]:
max_retries = 3
last_exc: Exception = RuntimeError("unreachable")
project_id = str(kwargs.pop("project_id", "") or "")
with observe_operation(
"llm.generate", subsystem="llm", consumer="stream",
attributes={"llm_model": model or MODEL_ID, "llm_streaming": True},
) as op:
for attempt in range(max_retries):
await self._async_check_rate_limit()
try:
t0 = time.time()
first_token_at: float | None = None
with _cerebras_semaphore:
# Note: this blocks the thread during generator creation.
# We only hold the semaphore for the initial call, the streaming
# generator yields responses over time. But wait, if stream is sync,
# the generator yields in real-time. Since we process chunks outside the
# semaphore, we don't hold the semaphore while yielding.
stream = self._client.chat.completions.create(
model=model or MODEL_ID, messages=messages, stream=True, **kwargs
)
chunk_count = 0
usage_logged = False
for chunk in stream:
usage = getattr(chunk, "usage", None)
delta = chunk.choices[0].delta.content if chunk.choices else None
if delta:
if first_token_at is None:
first_token_at = time.time()
ttft_ms = round((first_token_at - t0) * 1000.0, 3)
op.set("llm_ttft_ms", ttft_ms)
op.event("llm_first_token", {"llm_ttft_ms": ttft_ms})
chunk_count += 1
yield delta
if usage and getattr(usage, "completion_tokens", 0) and not usage_logged:
t1 = time.time()
tokens = usage.completion_tokens
if isinstance(tokens, (int, float)) and tokens and (t1 - t0) > 0:
print(f"[Cerebras Speed] stream_complete generated {tokens} tokens in {t1-t0:.2f}s ({tokens/(t1-t0):.1f} tok/s)")
self._log_usage(project_id, "stream_complete", tokens, t1 - t0, model or MODEL_ID)
op.add_count("llm_tokens", int(tokens))
usage_logged = True
if not usage_logged:
t1 = time.time()
if (t1 - t0) > 0 and chunk_count > 0:
print(f"[Cerebras Speed] stream_complete (approx) generated {chunk_count} chunks in {t1-t0:.2f}s ({chunk_count/(t1-t0):.1f} chunks/s)")
self._log_usage(project_id, "stream_complete_approx", chunk_count, t1 - t0, model or MODEL_ID)
op.add_count("llm_chunks_approx", chunk_count)
self._health = {"status": "ok"}
op.set("llm_attempts", attempt + 1)
return
except Exception as exc:
err = classify_error(exc)
if err.kind == CerebrasErrorKind.RATE_LIMITED and attempt < max_retries - 1:
delay = err.retry_after_seconds or (2 ** attempt)
print(f"[Cerebras] Rate limited. Retrying stream_complete in {delay}s...")
import asyncio
await asyncio.sleep(delay)
last_exc = exc
continue
self._handle_sdk_exc(exc, op)
self._handle_sdk_exc(last_exc, op)
|