Skip to content

Commit 660962a

Browse files
committed
feat(seer): Add Seer one-shot client
Add a client for Seer's one-shot platform: a single, synchronous structured LLM call where the caller posts {oneshot_id, payload} and gets back a {result} envelope inline (no background run, no run_id, no push-back). call_seer_oneshot() captures the shared boilerplate for a single one-shot style Seer task — building viewer context from the organization, dispatching with the default timeout, and raising SeerApiError with an error metric on a non-2xx response. run_oneshot() builds on it to dispatch to the one-shot run endpoint and return the structured result. Future one-shot style callers can reuse call_seer_oneshot() rather than re-implementing the request scaffolding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Agent transcript: https://claudescope.sentry.dev/share/nobE80fVhfJT_G0OMWFkJyhzZOt4mk8WWe5so8Z0EQo
1 parent f30ba59 commit 660962a

2 files changed

Lines changed: 128 additions & 0 deletions

File tree

src/sentry/seer/oneshot.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Client for Seer's one-shot platform.
2+
3+
A one-shot is a single, synchronous structured LLM call on the Seer side: the
4+
caller posts ``{oneshot_id, payload}`` and gets back a ``{result}`` envelope
5+
inline (no background run, no ``run_id``, no push-back). See the ``ONESHOTS``
6+
registry in ``seer/automation/oneshots/`` for the available one-shots and the
7+
payload/result contract each one defines.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
from collections.abc import Callable, Mapping
14+
from typing import Any
15+
16+
import orjson
17+
from django.conf import settings
18+
from urllib3 import BaseHTTPResponse
19+
20+
from sentry.models.organization import Organization
21+
from sentry.seer.models.seer_api_models import SeerApiError
22+
from sentry.seer.signed_seer_api import (
23+
OneShotRunRequest,
24+
SeerViewerContext,
25+
make_oneshot_request,
26+
)
27+
from sentry.utils import metrics
28+
29+
logger = logging.getLogger(__name__)
30+
31+
# A request maker from ``signed_seer_api`` (e.g. ``make_oneshot_request``):
32+
# given a typed body, a timeout and viewer context, dispatches a single signed
33+
# Seer call and returns the raw response.
34+
SeerRequestMaker = Callable[..., BaseHTTPResponse]
35+
36+
37+
def call_seer_oneshot(
38+
make_request: SeerRequestMaker,
39+
body: Mapping[str, Any],
40+
organization: Organization,
41+
*,
42+
error_metric: str,
43+
error_metric_tags: dict[str, Any] | None = None,
44+
user_id: int | None = None,
45+
timeout: int | float | None = None,
46+
) -> dict[str, Any]:
47+
"""Dispatch a single synchronous Seer task and return its parsed JSON body.
48+
49+
This is the shared boilerplate behind the one-shot style Seer calls: it
50+
builds viewer context from ``organization`` (plus an optional ``user_id``),
51+
invokes ``make_request`` with the default timeout, and on a non-2xx response
52+
increments ``error_metric`` (merging ``error_metric_tags`` with the response
53+
``status``) before raising :class:`SeerApiError`. On success it returns the
54+
decoded JSON object; callers shape it into their own result contract.
55+
56+
Seer task endpoints require viewer context with an organization, so
57+
``organization`` is mandatory.
58+
"""
59+
viewer_context = SeerViewerContext(organization_id=organization.id)
60+
if user_id is not None:
61+
viewer_context["user_id"] = user_id
62+
63+
response = make_request(
64+
body,
65+
timeout=timeout if timeout is not None else settings.SEER_DEFAULT_TIMEOUT,
66+
viewer_context=viewer_context,
67+
)
68+
69+
if response.status >= 400:
70+
metrics.incr(
71+
error_metric,
72+
tags={**(error_metric_tags or {}), "status": response.status},
73+
)
74+
raise SeerApiError(response.data.decode("utf-8"), response.status)
75+
76+
data: dict[str, Any] = orjson.loads(response.data)
77+
return data
78+
79+
80+
def run_oneshot(
81+
oneshot_id: str,
82+
payload: dict[str, Any],
83+
organization: Organization,
84+
*,
85+
user_id: int | None = None,
86+
timeout: int | float | None = None,
87+
) -> dict[str, Any]:
88+
"""Dispatch a one-shot to Seer and return its structured ``result``.
89+
90+
The one-shot endpoint requires viewer context with an organization, so
91+
``organization`` is mandatory. Raises :class:`SeerApiError` on a non-2xx
92+
response; callers validate the returned dict against the one-shot's result
93+
contract.
94+
"""
95+
body = OneShotRunRequest(oneshot_id=oneshot_id, payload=payload)
96+
data = call_seer_oneshot(
97+
make_oneshot_request,
98+
body,
99+
organization,
100+
error_metric="seer.oneshot.error",
101+
error_metric_tags={"oneshot_id": oneshot_id},
102+
user_id=user_id,
103+
timeout=timeout,
104+
)
105+
return data.get("result", {})

src/sentry/seer/signed_seer_api.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ class LlmGenerateRequest(TypedDict):
242242
reasoning: NotRequired[Literal["off", "low", "med", "high"] | None]
243243

244244

245+
class OneShotRunRequest(TypedDict):
246+
# The registered one-shot to dispatch to on the Seer side (see the ONESHOTS
247+
# registry in seer/automation/oneshots/). The `payload` shape is defined by
248+
# that one-shot and parsed against its contract.
249+
oneshot_id: str
250+
payload: dict[str, Any]
251+
252+
245253
class RepoDetails(TypedDict):
246254
project_ids: list[int]
247255
provider: str
@@ -398,6 +406,21 @@ def make_llm_generate_request(
398406
)
399407

400408

409+
def make_oneshot_request(
410+
body: OneShotRunRequest,
411+
timeout: int | float | None = None,
412+
viewer_context: SeerViewerContext | None = None,
413+
) -> BaseHTTPResponse:
414+
return make_signed_seer_api_request(
415+
seer_autofix_default_connection_pool,
416+
"/v1/automation/agent/oneshot/run",
417+
body=orjson.dumps(body),
418+
timeout=timeout,
419+
metric_tags={"oneshot_id": body["oneshot_id"]},
420+
viewer_context=viewer_context,
421+
)
422+
423+
401424
class SummarizeTraceRequest(TypedDict):
402425
trace_id: str
403426
only_transaction: bool

0 commit comments

Comments
 (0)