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)