Skip to content

Commit 1002311

Browse files
srest2021claude
andcommitted
fix(seer): Rely on Sentry's 402 instead of a separate credit check
Sentry checks Seer credits when analyze_issue_with_seer starts a run and answers 402 if there aren't enough, so the extra credit-check request isn't needed. The tool now turns a 402 from starting a run into an "Analysis Not Started" message instead of an error. Refs CW-2330 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 42b1ecf commit 1002311

7 files changed

Lines changed: 86 additions & 95 deletions

File tree

‎packages/mcp-core/src/api-client/client.ts‎

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ import {
180180
RepositoryListSchema,
181181
SearchAgentStartSchema,
182182
SearchAgentStateSchema,
183-
SeerCreditCheckSchema,
184183
SentryAppComponentListSchema,
185184
SentryAppExternalRequestOptionsSchema,
186185
SentryAppInstallationListSchema,
@@ -255,7 +254,6 @@ import type {
255254
ReplayList,
256255
SearchAgentStart,
257256
SearchAgentState,
258-
SeerCreditOperation,
259257
SentryAppComponentList,
260258
SentryAppExternalRequestOptions,
261259
SentryAppInstallationList,
@@ -4744,27 +4742,6 @@ export class SentryApiService {
47444742
return SearchAgentStateSchema.parse(body);
47454743
}
47464744

4747-
// GET https://us.sentry.io/api/0/organizations/my-org/seer/credit-check/?operation=autofix_root_cause
4748-
async hasSeerCredits(
4749-
{
4750-
organizationSlug,
4751-
operation,
4752-
}: {
4753-
organizationSlug: string;
4754-
operation: SeerCreditOperation;
4755-
},
4756-
opts?: RequestOptions,
4757-
): Promise<boolean> {
4758-
const queryParams = new URLSearchParams({ operation });
4759-
const body = await this.requestJSON(
4760-
apiPath`/organizations/${organizationSlug}/seer/credit-check/` +
4761-
`?${queryParams.toString()}`,
4762-
undefined,
4763-
opts,
4764-
);
4765-
return SeerCreditCheckSchema.parse(body).hasEnoughCredits;
4766-
}
4767-
47684745
/**
47694746
* Retrieves high-level metadata about a trace.
47704747
*

‎packages/mcp-core/src/api-client/schema.ts‎

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,28 +1368,6 @@ export const AutofixRunStateSchema = z.object({
13681368
.nullable(),
13691369
});
13701370

1371-
// Operations from Sentry's `SeerOperation` (`src/sentry/seer/credits.py`).
1372-
export const SeerCreditOperationSchema = z.enum([
1373-
"agent_chat_message",
1374-
"autofix_root_cause",
1375-
"autofix_solution",
1376-
"autofix_code_changes",
1377-
"autofix_open_pr",
1378-
"autofix_pr_iteration",
1379-
]);
1380-
1381-
/**
1382-
* Whether the organization has enough Seer credits to run an operation.
1383-
*
1384-
* Upstream source of truth in getsentry/sentry:
1385-
* - `src/sentry/seer/endpoints/organization_seer_credit_check.py`
1386-
*/
1387-
export const SeerCreditCheckSchema = z
1388-
.object({
1389-
hasEnoughCredits: z.boolean(),
1390-
})
1391-
.passthrough();
1392-
13931371
/**
13941372
* Schemas for Seer's search agent, which translates natural language into
13951373
* Sentry search queries.

‎packages/mcp-core/src/api-client/types.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ import type {
5656
AutofixRunStateSchema,
5757
SearchAgentStartSchema,
5858
SearchAgentStateSchema,
59-
SeerCreditOperationSchema,
6059
ClientKeyListSchema,
6160
ClientKeySchema,
6261
CommitListSchema,
@@ -244,7 +243,6 @@ export type AutofixRun = z.infer<typeof AutofixRunSchema>;
244243
export type AutofixRunState = z.infer<typeof AutofixRunStateSchema>;
245244
export type SearchAgentStart = z.infer<typeof SearchAgentStartSchema>;
246245
export type SearchAgentState = z.infer<typeof SearchAgentStateSchema>;
247-
export type SeerCreditOperation = z.infer<typeof SeerCreditOperationSchema>;
248246
export type AssignedTo = z.infer<typeof AssignedToSchema>;
249247
export type ReplayDetails = z.infer<typeof ReplayDetailsSchema>;
250248
export type ReplayList = z.infer<typeof ReplayListResponseSchema>["data"];

‎packages/mcp-core/src/internal/tool-helpers/seer.ts‎

Lines changed: 15 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import { z } from "zod";
2-
import {
3-
ApiNotFoundError,
4-
ApiValidationError,
5-
type AutofixRunState,
6-
type SentryApiService,
7-
} from "../../api-client/index";
8-
import type { Issue, SeerCreditOperation } from "../../api-client/types";
9-
import { logIssue, logWarn } from "../../telem/logging";
2+
import type { AutofixRunState } from "../../api-client/index";
3+
import type { Issue } from "../../api-client/types";
4+
import { isPlainObject } from "../type-guards";
105

116
type AutofixRun = NonNullable<AutofixRunState["autofix"]>;
127

@@ -94,6 +89,18 @@ export function getHumanInterventionGuidance(status: string): string {
9489
export const SEER_OUT_OF_CREDITS_MESSAGE =
9590
"Your organization does not have enough Seer credits. Someone with billing access can turn on auto-reload in Sentry.";
9691

92+
/**
93+
* Explain a 402 from starting a run. Sentry sends the credits 402 as
94+
* `{ detail }`. The quota 402 is a bare string the API client can't parse,
95+
* so it gets the fallback.
96+
*/
97+
export function getSeerPaymentRequiredMessage(responseBody: unknown): string {
98+
if (isPlainObject(responseBody) && typeof responseBody.detail === "string") {
99+
return responseBody.detail;
100+
}
101+
return "Your organization does not have enough Seer budget to start an analysis.";
102+
}
103+
97104
/**
98105
* Explain why a failed run stopped, when Seer gives a reason we can explain.
99106
*/
@@ -106,30 +113,6 @@ export function getFailureReasonGuidance(
106113
return "";
107114
}
108115

109-
/**
110-
* Whether the organization has enough Seer credits for the operation.
111-
*/
112-
export async function hasSeerCredits(
113-
apiService: SentryApiService,
114-
organizationSlug: string,
115-
operation: SeerCreditOperation,
116-
): Promise<boolean> {
117-
try {
118-
return await apiService.hasSeerCredits({ organizationSlug, operation });
119-
} catch (error) {
120-
if (error instanceof ApiValidationError || error instanceof z.ZodError) {
121-
// Sentry rejected the operation or answered in a shape we don't expect,
122-
// so our contract with Sentry drifted.
123-
logIssue(error, {
124-
contexts: { request: { organizationSlug, operation } },
125-
});
126-
} else if (!(error instanceof ApiNotFoundError)) {
127-
logWarn(error, { extra: { organizationSlug, operation } });
128-
}
129-
return true;
130-
}
131-
}
132-
133116
function escapeXmlAttribute(value: string | number): string {
134117
return String(value)
135118
.replace(/&/g, "&amp;")

‎packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,61 @@ describe("analyze_issue_with_seer", () => {
439439
},
440440
);
441441

442+
it.each([
443+
[
444+
"out of credits",
445+
{
446+
detail:
447+
"Your organization does not have enough credits to start an Autofix run.",
448+
},
449+
"Your organization does not have enough credits to start an Autofix run.",
450+
],
451+
[
452+
"out of quota",
453+
"No budget for Seer Autofix.",
454+
"Your organization does not have enough Seer budget to start an analysis.",
455+
],
456+
])(
457+
"explains why the run did not start when Sentry answers 402 (%s)",
458+
async (_label, body, expected) => {
459+
let getCallCount = 0;
460+
461+
mswServer.use(
462+
http.get(
463+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-402/autofix/",
464+
() => {
465+
getCallCount++;
466+
return HttpResponse.json({ autofix: null });
467+
},
468+
),
469+
http.post(
470+
"https://sentry.io/api/0/organizations/sentry-mcp-evals/issues/CLOUDFLARE-MCP-402/autofix/",
471+
() => HttpResponse.json(body, { status: 402 }),
472+
),
473+
);
474+
475+
const result = await analyzeIssueWithSeer.handler(
476+
{
477+
organizationSlug: "sentry-mcp-evals",
478+
regionUrl: null,
479+
issueId: "CLOUDFLARE-MCP-402",
480+
instruction: undefined,
481+
},
482+
{
483+
constraints: { organizationSlug: undefined },
484+
accessToken: "access-token",
485+
userId: "1",
486+
},
487+
);
488+
489+
expect(result).toBe(
490+
`# Seer Analysis for Issue CLOUDFLARE-MCP-402\n\n## Analysis Not Started\n\n${expected}\n`,
491+
);
492+
// No polling after a refused start.
493+
expect(getCallCount).toBe(1);
494+
},
495+
);
496+
442497
it("returns unsupported message for metric issues without calling Seer", async () => {
443498
let autofixRequests = 0;
444499

‎packages/mcp-core/src/tools/catalog/analyze-issue-with-seer.ts‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,13 @@ import {
1212
getFailureReasonGuidance,
1313
getHumanInterventionGuidance,
1414
getOutputForAutofixRun,
15+
getSeerPaymentRequiredMessage,
1516
getSeerUnsupportedIssueMessage,
1617
getStatusDisplayName,
17-
hasSeerCredits,
1818
isSeerSupportedIssue,
1919
isTerminalStatus,
2020
SEER_INITIAL_RETRY_DELAY,
2121
SEER_MAX_RETRIES,
22-
SEER_OUT_OF_CREDITS_MESSAGE,
2322
SEER_POLLING_INTERVAL,
2423
SEER_TIMEOUT,
2524
} from "../../internal/tool-helpers/seer";
@@ -144,17 +143,23 @@ export default defineTool({
144143

145144
// Step 2: Start analysis if none exists
146145
if (!autofixState.autofix) {
147-
if (!(await hasSeerCredits(apiService, orgSlug, "autofix_root_cause"))) {
148-
output += `## Analysis Not Started\n\n${SEER_OUT_OF_CREDITS_MESSAGE}\n`;
149-
return output;
146+
let startResult: Awaited<ReturnType<typeof apiService.startAutofix>>;
147+
try {
148+
startResult = await apiService.startAutofix({
149+
organizationSlug: orgSlug,
150+
issueId: parsedIssueId,
151+
instruction: params.instruction,
152+
});
153+
} catch (error) {
154+
// Sentry answers 402 before starting a run when the org is out of
155+
// Seer credits or quota.
156+
if (error instanceof ApiError && error.status === 402) {
157+
output += `## Analysis Not Started\n\n${getSeerPaymentRequiredMessage(error.responseBody)}\n`;
158+
return output;
159+
}
160+
throw error;
150161
}
151-
152162
output += `Starting new analysis...\n\n`;
153-
const startResult = await apiService.startAutofix({
154-
organizationSlug: orgSlug,
155-
issueId: parsedIssueId,
156-
instruction: params.instruction,
157-
});
158163
output += `Analysis started with Run ID: ${startResult.run_id}\n\n`;
159164

160165
// Give it a moment to initialize

‎packages/mcp-server-mocks/src/index.ts‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,11 +1318,6 @@ export const restHandlers = buildHandlers([
13181318
path: "/api/0/organizations/sentry-mcp-evals/issues/PEATED-A8/autofix/",
13191319
fetch: () => HttpResponse.json({ run_id: 123 }),
13201320
},
1321-
{
1322-
method: "get",
1323-
path: "/api/0/organizations/sentry-mcp-evals/seer/credit-check/",
1324-
fetch: () => HttpResponse.json({ hasEnoughCredits: true }),
1325-
},
13261321
{
13271322
method: "get",
13281323
path: "/api/0/organizations/sentry-mcp-evals/issues/PERF-N1-001/autofix/",

0 commit comments

Comments
 (0)