@stigmer/runner 3.5.1 → 3.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/call-llm.js +21 -45
- package/dist/activities/call-llm.js.map +1 -1
- package/dist/activities/execute-cursor/error-classifier.d.ts +1 -1
- package/dist/activities/execute-cursor/error-classifier.js +18 -2
- package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +8 -2
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +23 -2
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/shared/model-error.d.ts +86 -0
- package/dist/shared/model-error.js +248 -0
- package/dist/shared/model-error.js.map +1 -0
- package/package.json +2 -2
- package/src/activities/call-llm.ts +21 -75
- package/src/activities/execute-cursor/__tests__/error-classifier-billing.test.ts +106 -0
- package/src/activities/execute-cursor/error-classifier.ts +18 -2
- package/src/activities/execute-cursor/index.ts +8 -2
- package/src/activities/execute-deep-agent/index.ts +23 -2
- package/src/shared/__tests__/model-error.test.ts +252 -0
- package/src/shared/model-error.ts +303 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the billing category of the Cursor error classifier
|
|
3
|
+
* (stigmer/stigmer#330 follow-through: the Cursor arm kept working during
|
|
4
|
+
* the incident, but its classifier had no billing category — quota
|
|
5
|
+
* exhaustion of Cursor-managed keys would have classified as retryable
|
|
6
|
+
* rate-limit or unknown, burning retries against an exhausted account).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
10
|
+
import { synthesizeError } from "../error-classifier.js";
|
|
11
|
+
import type { CapturedRejection } from "../rejection-capture.js";
|
|
12
|
+
|
|
13
|
+
const FALLBACK = { model: "default", mode: "cloud", agentId: "agent-1" };
|
|
14
|
+
|
|
15
|
+
function base() {
|
|
16
|
+
return {
|
|
17
|
+
sdkResultFields: undefined,
|
|
18
|
+
streamErrorMessage: undefined,
|
|
19
|
+
capturedRejection: undefined,
|
|
20
|
+
isResumedHandle: false,
|
|
21
|
+
fallbackContext: FALLBACK,
|
|
22
|
+
} as const;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("error-classifier billing category", () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("classifies credit-balance prose as non-retryable billing", () => {
|
|
34
|
+
const result = synthesizeError({
|
|
35
|
+
...base(),
|
|
36
|
+
streamErrorMessage:
|
|
37
|
+
"Your credit balance is too low to access the Anthropic API.",
|
|
38
|
+
});
|
|
39
|
+
expect(result.category).toBe("billing");
|
|
40
|
+
expect(result.retryable).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("classifies insufficient_quota as billing even though it carries '429'", () => {
|
|
44
|
+
// Billing must be checked before rate-limit: this message matches both
|
|
45
|
+
// pattern lists, and only the billing diagnosis is terminal.
|
|
46
|
+
const result = synthesizeError({
|
|
47
|
+
...base(),
|
|
48
|
+
sdkError: { code: "insufficient_quota", status: 429, message: "You have no credits remaining." },
|
|
49
|
+
});
|
|
50
|
+
expect(result.source).toBe("sdk");
|
|
51
|
+
expect(result.category).toBe("billing");
|
|
52
|
+
expect(result.retryable).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("'usage limit' is billing (terminal), no longer a retryable rate-limit", () => {
|
|
56
|
+
const result = synthesizeError({
|
|
57
|
+
...base(),
|
|
58
|
+
streamErrorMessage: "You have reached your usage limit for this billing period.",
|
|
59
|
+
});
|
|
60
|
+
expect(result.category).toBe("billing");
|
|
61
|
+
expect(result.retryable).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("plain rate limits remain retryable rate-limit", () => {
|
|
65
|
+
const result = synthesizeError({
|
|
66
|
+
...base(),
|
|
67
|
+
streamErrorMessage: "rate limit exceeded, retry after 2s",
|
|
68
|
+
});
|
|
69
|
+
expect(result.category).toBe("rate-limit");
|
|
70
|
+
expect(result.retryable).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("the platform capacity sentinel is diagnosed as billing", () => {
|
|
74
|
+
const result = synthesizeError({
|
|
75
|
+
...base(),
|
|
76
|
+
streamErrorMessage:
|
|
77
|
+
"Model capacity unavailable [code: STIGMER_PLATFORM_MODEL_CAPACITY]",
|
|
78
|
+
});
|
|
79
|
+
expect(result.category).toBe("billing");
|
|
80
|
+
expect(result.retryable).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("billing via captured rejection is non-retryable", () => {
|
|
84
|
+
const rejection: CapturedRejection = {
|
|
85
|
+
code: "resource_exhausted",
|
|
86
|
+
message: "You exceeded your current quota",
|
|
87
|
+
timestamp: Date.now(),
|
|
88
|
+
};
|
|
89
|
+
const result = synthesizeError({
|
|
90
|
+
...base(),
|
|
91
|
+
capturedRejection: rejection,
|
|
92
|
+
});
|
|
93
|
+
expect(result.source).toBe("rejection");
|
|
94
|
+
expect(result.category).toBe("billing");
|
|
95
|
+
expect(result.retryable).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("billing is never upgraded to agent-stale on resumed handles", () => {
|
|
99
|
+
const result = synthesizeError({
|
|
100
|
+
...base(),
|
|
101
|
+
isResumedHandle: true,
|
|
102
|
+
streamErrorMessage: "Your credit balance is too low to access the Anthropic API.",
|
|
103
|
+
});
|
|
104
|
+
expect(result.category).toBe("billing");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -23,6 +23,7 @@ import type { CapturedRejection } from "./rejection-capture.js";
|
|
|
23
23
|
|
|
24
24
|
export type ErrorCategory =
|
|
25
25
|
| "auth"
|
|
26
|
+
| "billing"
|
|
26
27
|
| "rate-limit"
|
|
27
28
|
| "network"
|
|
28
29
|
| "agent-stale"
|
|
@@ -51,9 +52,19 @@ const AUTH_PATTERNS = [
|
|
|
51
52
|
"unauthenticated", "unauthorized", "401", "forbidden",
|
|
52
53
|
"permission_denied", "invalid api key", "not logged in",
|
|
53
54
|
];
|
|
55
|
+
// Billing/quota exhaustion is terminal, unlike a transient rate limit —
|
|
56
|
+
// retrying cannot succeed until someone adds credits. "usage limit" moved
|
|
57
|
+
// here from RATE_LIMIT_PATTERNS (it previously classified as retryable,
|
|
58
|
+
// which burned retries against an exhausted account). Includes the platform
|
|
59
|
+
// sentinel (see shared/model-error.ts) so a platform-attributed rewrite
|
|
60
|
+
// relayed through Cursor infrastructure is also diagnosed as billing.
|
|
61
|
+
const BILLING_PATTERNS = [
|
|
62
|
+
"credit balance is too low", "insufficient_quota",
|
|
63
|
+
"no credits remaining", "exceeded your current quota",
|
|
64
|
+
"usage limit", "stigmer_platform_model_capacity",
|
|
65
|
+
];
|
|
54
66
|
const RATE_LIMIT_PATTERNS = [
|
|
55
67
|
"resource_exhausted", "rate limit", "429", "too many",
|
|
56
|
-
"usage limit",
|
|
57
68
|
];
|
|
58
69
|
const NETWORK_PATTERNS = [
|
|
59
70
|
"unavailable", "deadline_exceeded", "503", "504",
|
|
@@ -71,6 +82,9 @@ function matchesAny(text: string, patterns: string[]): boolean {
|
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
function classifyText(text: string): { category: ErrorCategory; retryable: boolean } {
|
|
85
|
+
// Billing before auth/rate-limit: billing prose can carry "429" or quota
|
|
86
|
+
// wording that would otherwise match the transient rate-limit patterns.
|
|
87
|
+
if (matchesAny(text, BILLING_PATTERNS)) return { category: "billing", retryable: false };
|
|
74
88
|
if (matchesAny(text, AUTH_PATTERNS)) return { category: "auth", retryable: false };
|
|
75
89
|
if (matchesAny(text, RATE_LIMIT_PATTERNS)) return { category: "rate-limit", retryable: true };
|
|
76
90
|
if (matchesAny(text, NETWORK_PATTERNS)) return { category: "network", retryable: true };
|
|
@@ -183,7 +197,9 @@ function classifyFromSources(opts: SynthesizeErrorOpts): ClassifiedError {
|
|
|
183
197
|
return {
|
|
184
198
|
category: category === "unknown" ? "network" : category,
|
|
185
199
|
message: `[${opts.capturedRejection.code}] ${opts.capturedRejection.message}`,
|
|
186
|
-
retryable
|
|
200
|
+
// Rejections default to retryable (transport flakes), except the two
|
|
201
|
+
// terminal diagnoses that cannot self-heal on retry.
|
|
202
|
+
retryable: category !== "auth" && category !== "billing",
|
|
187
203
|
source: "rejection",
|
|
188
204
|
};
|
|
189
205
|
}
|
|
@@ -39,6 +39,7 @@ import type { Run, ConversationTurn } from "@cursor/sdk";
|
|
|
39
39
|
|
|
40
40
|
import type { Config } from "../../config.js";
|
|
41
41
|
import { StigmerClient } from "../../client/stigmer-client.js";
|
|
42
|
+
import { describeExecutionError } from "../../shared/model-error.js";
|
|
42
43
|
import { resolveAgentWithTransportRecovery } from "./session-lifecycle.js";
|
|
43
44
|
import type { AgentResolution, CreateAgentOptions, CreateCloudAgentOptions } from "./session-lifecycle.js";
|
|
44
45
|
import { CursorMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
|
|
@@ -1991,8 +1992,13 @@ async function executeCursorInner(
|
|
|
1991
1992
|
} return slimStatus(status);
|
|
1992
1993
|
}
|
|
1993
1994
|
|
|
1994
|
-
|
|
1995
|
-
|
|
1995
|
+
// Unwrap + classify before formatting: the structured-output extraction
|
|
1996
|
+
// path uses a LangChain model whose errors arrive MiddlewareError-wrapped
|
|
1997
|
+
// with raw provider prose — the same leak the deep-agent harness fixes.
|
|
1998
|
+
// Non-model errors keep the root error's own identity.
|
|
1999
|
+
const { errorType: errType, errorMessage: errMsg } = describeExecutionError(err, {
|
|
2000
|
+
proxyMode: !!config.proxyEndpoint,
|
|
2001
|
+
});
|
|
1996
2002
|
console.error(`ExecuteCursor failed: execution=${executionId}, [${errType}] ${errMsg}`);
|
|
1997
2003
|
|
|
1998
2004
|
status.phase = ExecutionPhase.EXECUTION_FAILED;
|
|
@@ -76,10 +76,24 @@ import {
|
|
|
76
76
|
} from "../../shared/tool-row.js";
|
|
77
77
|
import { stampFlowedFileEditRows, stampFlowedSubAgentFileEditRows } from "./stamp-flowed-rows.js";
|
|
78
78
|
import { deriveTurnCommandProvenance } from "./command-provenance.js";
|
|
79
|
+
import { describeExecutionError } from "../../shared/model-error.js";
|
|
80
|
+
import { inferProvider, type LlmProvider } from "../../shared/llm-proxy.js";
|
|
79
81
|
|
|
80
82
|
/** The harness id stamped on the deep-agent's file-review ledger events. */
|
|
81
83
|
const DEEP_AGENT_HARNESS_ID = "deep-agent";
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Best-effort provider inference for error-message wording. inferProvider
|
|
87
|
+
* throws on unrecognized names; an error path must never throw over a label.
|
|
88
|
+
*/
|
|
89
|
+
function tryInferProvider(modelName: string): LlmProvider | undefined {
|
|
90
|
+
try {
|
|
91
|
+
return inferProvider(modelName);
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
export function createDeepAgentActivities(config: Config) {
|
|
84
98
|
const client = new StigmerClient({
|
|
85
99
|
endpoint: config.stigmerBackendEndpoint,
|
|
@@ -752,8 +766,15 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
752
766
|
throw new CancelledFailure("Activity paused by orchestrator (error during cancellation)");
|
|
753
767
|
}
|
|
754
768
|
|
|
755
|
-
|
|
756
|
-
|
|
769
|
+
// Classify before formatting: model-call failures get stable codes
|
|
770
|
+
// and platform-vs-user attribution (LangChain's MiddlewareError
|
|
771
|
+
// wrapper is unwrapped to the root SDK error); non-model failures
|
|
772
|
+
// keep the root error's own identity. See shared/model-error.ts.
|
|
773
|
+
const { errorType, errorMessage } = describeExecutionError(err, {
|
|
774
|
+
proxyMode: !!config.proxyEndpoint,
|
|
775
|
+
modelId: setup?.modelName,
|
|
776
|
+
provider: setup ? tryInferProvider(setup.modelName) : undefined,
|
|
777
|
+
});
|
|
757
778
|
|
|
758
779
|
console.error(
|
|
759
780
|
`[ExecuteDeepAgent] Failed for execution ${executionId}: ` +
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared model-call error unwrapping and classification
|
|
3
|
+
* (stigmer/stigmer#330).
|
|
4
|
+
*
|
|
5
|
+
* The scenarios mirror the production incident: the platform proxy rewrites
|
|
6
|
+
* a platform-account billing rejection into a 503 carrying the sentinel
|
|
7
|
+
* code, LangChain wraps the SDK error in a MiddlewareError whose message is
|
|
8
|
+
* copied verbatim and whose `cause` is the original — and the runner must
|
|
9
|
+
* (a) attribute platform faults to the platform, (b) attribute direct-mode
|
|
10
|
+
* billing faults to the user's own provider account, and (c) never relabel
|
|
11
|
+
* a non-model error.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect } from "vitest";
|
|
15
|
+
import {
|
|
16
|
+
PLATFORM_CAPACITY_SENTINEL,
|
|
17
|
+
classifyModelCallError,
|
|
18
|
+
describeExecutionError,
|
|
19
|
+
unwrapModelError,
|
|
20
|
+
} from "../model-error.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mimic LangChain's MiddlewareError: message copied from the inner error,
|
|
24
|
+
* original preserved on `cause` (langchain dist/agents/errors.js:50-57).
|
|
25
|
+
*/
|
|
26
|
+
function middlewareWrap(inner: Error): Error {
|
|
27
|
+
const wrapped = new Error(inner.message);
|
|
28
|
+
wrapped.cause = inner;
|
|
29
|
+
return wrapped;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Mimic a provider SDK APIError: message + numeric `.status`. */
|
|
33
|
+
function sdkError(status: number, message: string): Error {
|
|
34
|
+
return Object.assign(new Error(message), { status });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The exact message shape the incident produced on the Anthropic arm. */
|
|
38
|
+
const ANTHROPIC_BILLING_MESSAGE =
|
|
39
|
+
'400 {"type":"error","error":{"type":"invalid_request_error","message":"Your credit balance is too low' +
|
|
40
|
+
' to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits."}}';
|
|
41
|
+
|
|
42
|
+
/** The rewritten message the cloud proxy now returns for platform faults. */
|
|
43
|
+
const PLATFORM_REWRITE_MESSAGE =
|
|
44
|
+
"The Stigmer platform's model capacity for anthropic is temporarily unavailable." +
|
|
45
|
+
` [code: ${PLATFORM_CAPACITY_SENTINEL}]`;
|
|
46
|
+
|
|
47
|
+
describe("unwrapModelError", () => {
|
|
48
|
+
it("returns the error itself when there is no cause", () => {
|
|
49
|
+
const err = new Error("plain");
|
|
50
|
+
expect(unwrapModelError(err)).toBe(err);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("walks a MiddlewareError-style cause chain to the root SDK error", () => {
|
|
54
|
+
const root = sdkError(400, ANTHROPIC_BILLING_MESSAGE);
|
|
55
|
+
const wrapped = middlewareWrap(root);
|
|
56
|
+
expect(unwrapModelError(wrapped)).toBe(root);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("walks nested cause chains", () => {
|
|
60
|
+
const root = sdkError(503, "boom");
|
|
61
|
+
const wrapped = middlewareWrap(middlewareWrap(root));
|
|
62
|
+
expect(unwrapModelError(wrapped)).toBe(root);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("survives a pathological cause cycle via the depth cap", () => {
|
|
66
|
+
const a = new Error("a");
|
|
67
|
+
const b = new Error("b");
|
|
68
|
+
a.cause = b;
|
|
69
|
+
b.cause = a;
|
|
70
|
+
// Any chain member is acceptable; the point is it terminates.
|
|
71
|
+
expect(unwrapModelError(a)).toBeInstanceOf(Error);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("passes non-Error values through", () => {
|
|
75
|
+
expect(unwrapModelError("string error")).toBe("string error");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("classifyModelCallError — platform sentinel", () => {
|
|
80
|
+
it("classifies the platform rewrite as non-retryable platform capacity", () => {
|
|
81
|
+
const err = middlewareWrap(sdkError(503, PLATFORM_REWRITE_MESSAGE));
|
|
82
|
+
|
|
83
|
+
const classified = classifyModelCallError(err, { proxyMode: true, provider: "anthropic" });
|
|
84
|
+
|
|
85
|
+
expect(classified?.code).toBe("LLM_PLATFORM_CAPACITY");
|
|
86
|
+
expect(classified?.retryable).toBe(false);
|
|
87
|
+
expect(classified?.message).toContain("platform-side issue");
|
|
88
|
+
expect(classified?.message).toContain("credits were not charged");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("sentinel wins over status mapping (a 503 would otherwise be retryable)", () => {
|
|
92
|
+
const classified = classifyModelCallError(
|
|
93
|
+
sdkError(503, PLATFORM_REWRITE_MESSAGE),
|
|
94
|
+
{ proxyMode: true },
|
|
95
|
+
);
|
|
96
|
+
expect(classified?.code).toBe("LLM_PLATFORM_CAPACITY");
|
|
97
|
+
expect(classified?.retryable).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("classifyModelCallError — provider billing prose", () => {
|
|
102
|
+
it("in direct mode, attributes billing to the user's own provider account", () => {
|
|
103
|
+
const err = middlewareWrap(sdkError(400, ANTHROPIC_BILLING_MESSAGE));
|
|
104
|
+
|
|
105
|
+
const classified = classifyModelCallError(err, { proxyMode: false, provider: "anthropic" });
|
|
106
|
+
|
|
107
|
+
expect(classified?.code).toBe("LLM_PROVIDER_BILLING");
|
|
108
|
+
expect(classified?.retryable).toBe(false);
|
|
109
|
+
expect(classified?.message).toContain("Your Anthropic account");
|
|
110
|
+
// Direct mode keeps the provider message — it IS the user's account.
|
|
111
|
+
expect(classified?.message).toContain("credit balance is too low");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("in proxy mode, raw billing prose (version-skewed proxy) attributes to the platform", () => {
|
|
115
|
+
const err = sdkError(400, ANTHROPIC_BILLING_MESSAGE);
|
|
116
|
+
|
|
117
|
+
const classified = classifyModelCallError(err, { proxyMode: true, provider: "anthropic" });
|
|
118
|
+
|
|
119
|
+
expect(classified?.code).toBe("LLM_PLATFORM_CAPACITY");
|
|
120
|
+
expect(classified?.message).not.toContain("Plans & Billing");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("recognizes OpenAI quota exhaustion wordings", () => {
|
|
124
|
+
for (const msg of [
|
|
125
|
+
"429 You have no credits remaining. Add credits to continue using the API.",
|
|
126
|
+
"429 You exceeded your current quota, please check your plan and billing details.",
|
|
127
|
+
'429 {"error":{"type":"insufficient_quota"}}',
|
|
128
|
+
]) {
|
|
129
|
+
const classified = classifyModelCallError(
|
|
130
|
+
sdkError(429, msg),
|
|
131
|
+
{ proxyMode: false, provider: "openai" },
|
|
132
|
+
);
|
|
133
|
+
expect(classified?.code).toBe("LLM_PROVIDER_BILLING");
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("classifyModelCallError — status mapping", () => {
|
|
139
|
+
it("maps statuses to the stable codes with call-llm's retryability policy", () => {
|
|
140
|
+
const cases: Array<[number, string, boolean]> = [
|
|
141
|
+
[401, "LLM_AUTHENTICATION_ERROR", false],
|
|
142
|
+
[403, "LLM_PERMISSION_DENIED", false],
|
|
143
|
+
[404, "LLM_MODEL_NOT_FOUND", false],
|
|
144
|
+
[400, "LLM_BAD_REQUEST", false],
|
|
145
|
+
[422, "LLM_UNPROCESSABLE_REQUEST", false],
|
|
146
|
+
[429, "LLM_RATE_LIMIT", false],
|
|
147
|
+
[500, "LLM_PROVIDER_ERROR", true],
|
|
148
|
+
[529, "LLM_PROVIDER_ERROR", true],
|
|
149
|
+
[418, "LLM_API_ERROR", false],
|
|
150
|
+
];
|
|
151
|
+
for (const [status, code, retryable] of cases) {
|
|
152
|
+
const classified = classifyModelCallError(
|
|
153
|
+
sdkError(status, `HTTP ${status}`),
|
|
154
|
+
{ proxyMode: false, provider: "openai", modelId: "gpt-4o" },
|
|
155
|
+
);
|
|
156
|
+
expect(classified?.code, `status ${status}`).toBe(code);
|
|
157
|
+
expect(classified?.retryable, `status ${status}`).toBe(retryable);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("proxy-mode 401/403 wording points at the platform session, not a user API key", () => {
|
|
162
|
+
const classified = classifyModelCallError(
|
|
163
|
+
sdkError(401, "unauthorized"),
|
|
164
|
+
{ proxyMode: true, provider: "anthropic" },
|
|
165
|
+
);
|
|
166
|
+
expect(classified?.message).toContain("Stigmer platform");
|
|
167
|
+
expect(classified?.message).not.toContain("your API key");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("unwraps before duck-typing status (the incident shape end-to-end)", () => {
|
|
171
|
+
const classified = classifyModelCallError(
|
|
172
|
+
middlewareWrap(sdkError(429, "Too many requests")),
|
|
173
|
+
{ proxyMode: false, provider: "openai" },
|
|
174
|
+
);
|
|
175
|
+
expect(classified?.code).toBe("LLM_RATE_LIMIT");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("classifyModelCallError — connection heuristics and no-signal", () => {
|
|
180
|
+
it("always recognizes the SDKs' own APIConnection* classes as retryable", () => {
|
|
181
|
+
class APIConnectionTimeoutError extends Error {}
|
|
182
|
+
class APIConnectionError extends Error {}
|
|
183
|
+
expect(
|
|
184
|
+
classifyModelCallError(new APIConnectionTimeoutError("timed out"), { proxyMode: false })?.code,
|
|
185
|
+
).toBe("LLM_CONNECTION_TIMEOUT");
|
|
186
|
+
expect(
|
|
187
|
+
classifyModelCallError(new APIConnectionError("refused"), { proxyMode: false })?.code,
|
|
188
|
+
).toBe("LLM_CONNECTION_ERROR");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("loose Timeout/Connection names classify only when the caller vouches assumeModelCall", () => {
|
|
192
|
+
class ConnectTimeoutError extends Error {}
|
|
193
|
+
|
|
194
|
+
// A model-call-only catch (call-llm) keeps the loose heuristics.
|
|
195
|
+
expect(
|
|
196
|
+
classifyModelCallError(new ConnectTimeoutError("undici timeout"), {
|
|
197
|
+
proxyMode: false,
|
|
198
|
+
assumeModelCall: true,
|
|
199
|
+
})?.code,
|
|
200
|
+
).toBe("LLM_CONNECTION_TIMEOUT");
|
|
201
|
+
|
|
202
|
+
// A broad catch must not relabel arbitrary *TimeoutError classes.
|
|
203
|
+
expect(
|
|
204
|
+
classifyModelCallError(new ConnectTimeoutError("undici timeout"), { proxyMode: false }),
|
|
205
|
+
).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("returns undefined for errors with no model-call signal", () => {
|
|
209
|
+
expect(classifyModelCallError(new Error("ENOSPC: disk full"), { proxyMode: true })).toBeUndefined();
|
|
210
|
+
expect(classifyModelCallError("not even an error", { proxyMode: false })).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("describeExecutionError", () => {
|
|
215
|
+
it("labels classified model errors with the stable code, not the wrapper class", () => {
|
|
216
|
+
const { errorType, errorMessage } = describeExecutionError(
|
|
217
|
+
middlewareWrap(sdkError(503, PLATFORM_REWRITE_MESSAGE)),
|
|
218
|
+
{ proxyMode: true },
|
|
219
|
+
);
|
|
220
|
+
expect(errorType).toBe("LLM_PLATFORM_CAPACITY");
|
|
221
|
+
expect(errorMessage).toContain("credits were not charged");
|
|
222
|
+
expect(errorMessage).not.toContain("MiddlewareError");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("keeps the root error's identity for non-model failures", () => {
|
|
226
|
+
class WorkspaceLockTimeoutError extends Error {}
|
|
227
|
+
const root = new WorkspaceLockTimeoutError("workspace busy");
|
|
228
|
+
|
|
229
|
+
const { errorType, errorMessage } = describeExecutionError(
|
|
230
|
+
middlewareWrap(root),
|
|
231
|
+
{ proxyMode: true },
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
expect(errorType).toBe("WorkspaceLockTimeoutError");
|
|
235
|
+
expect(errorMessage).toBe("workspace busy");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("handles non-Error throwables", () => {
|
|
239
|
+
const { errorType, errorMessage } = describeExecutionError("oops", { proxyMode: false });
|
|
240
|
+
expect(errorType).toBe("UnknownError");
|
|
241
|
+
expect(errorMessage).toBe("oops");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("never surfaces provider billing-console prose in proxy mode", () => {
|
|
245
|
+
const { errorMessage } = describeExecutionError(
|
|
246
|
+
middlewareWrap(sdkError(400, ANTHROPIC_BILLING_MESSAGE)),
|
|
247
|
+
{ proxyMode: true },
|
|
248
|
+
);
|
|
249
|
+
expect(errorMessage).not.toContain("Plans & Billing");
|
|
250
|
+
expect(errorMessage).toContain("platform-side issue");
|
|
251
|
+
});
|
|
252
|
+
});
|