Compare commits

...

4 Commits

Author SHA1 Message Date
Saoud Rizwan d62ce30f82 fix(cli): handle provider auth special errors 2026-06-07 23:47:10 -07:00
Saoud Rizwan 6a67a6ab54 refactor(sdk): rename structured error metadata 2026-06-07 23:45:26 -07:00
Saoud Rizwan e5aab65200 fix(sdk): tighten structured error propagation 2026-06-07 23:45:26 -07:00
Saoud Rizwan dbc8f7cfd1 fix(cli): handle structured Cline errors 2026-06-07 23:45:26 -07:00
28 changed files with 1201 additions and 79 deletions
+26 -3
View File
@@ -24,6 +24,25 @@ vi.mock("@cline/core", () => {
coreMocks.serviceOptions.push(options);
}
},
createClineAccountAuthRequiredError: () => {
const error = new Error(
"Cline account authentication requires sign in.",
) as Error & {
errorInfo: {
kind: "auth";
providerId: "cline";
code: "cline_account_auth_required";
message: string;
};
};
error.errorInfo = {
kind: "auth",
providerId: "cline",
code: "cline_account_auth_required",
message: error.message,
};
return error;
},
ProviderSettingsManager: class {
getProviderSettings(providerId: string) {
return coreMocks.getProviderSettings(providerId);
@@ -129,8 +148,12 @@ describe("createClineAccountService", () => {
await expect(
createClineAccountService({ config: makeConfig() }),
).rejects.toThrow(
"Cline account requires re-authentication. Run cline auth cline.",
);
).rejects.toMatchObject({
errorInfo: {
kind: "auth",
providerId: "cline",
code: "cline_account_auth_required",
},
});
});
});
+4 -25
View File
@@ -4,6 +4,7 @@ import {
type ClineAccountOrganizationBalance,
ClineAccountService,
type ClineAccountUser,
createClineAccountAuthRequiredError,
getValidClineCredentials,
type ProviderSettings,
ProviderSettingsManager,
@@ -14,8 +15,6 @@ import { toProviderApiKey } from "../utils/provider-auth";
import type { Config } from "../utils/types";
const WORKOS_TOKEN_PREFIX = "workos:";
export const CLINE_CREDITS_DASHBOARD_URL =
"https://app.cline.bot/dashboard/account?tab=credits";
type ClineAccountConfig = Pick<Config, "apiKey" | "providerId">;
@@ -32,24 +31,6 @@ export function formatClineCredits(value: number): string {
return formatCreditBalance(normalizeCreditBalance(value));
}
// FIXME: These message checks are temporary until structured error types are
// passed through to the CLI instead of plain error strings.
export function isClineAccountAuthErrorMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
return (
normalized === "no cline account auth token found" ||
normalized.includes("requires re-authentication")
);
}
export function isClineAccountCreditsErrorMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
return (
normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance")
);
}
function resolveAccountApiBaseUrl(input: {
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
@@ -112,9 +93,7 @@ async function resolveValidClineAccountAuthToken(input: {
{ apiBaseUrl: input.apiBaseUrl },
);
if (!credentials) {
throw new Error(
"Cline account requires re-authentication. Run cline auth cline.",
);
throw createClineAccountAuthRequiredError();
}
const nextAccessToken = toProviderApiKey("cline", credentials);
if (
@@ -179,7 +158,7 @@ export async function loadClineAccountSnapshot(input: {
}): Promise<ClineAccountSnapshot> {
const service = await createClineAccountService(input);
if (!service) {
throw new Error("No Cline account auth token found");
throw createClineAccountAuthRequiredError();
}
const user = await service.fetchMe();
@@ -214,7 +193,7 @@ export async function switchClineAccount(input: {
}): Promise<void> {
const service = await createClineAccountService(input);
if (!service) {
throw new Error("No Cline account auth token found");
throw createClineAccountAuthRequiredError();
}
await service.switchAccount(input.organizationId);
}
+71 -29
View File
@@ -3,9 +3,9 @@ import type React from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
CLINE_CREDITS_DASHBOARD_URL,
isClineAccountCreditsErrorMessage,
} from "../cline-account";
resolveSpecialErrorDisplay,
type SpecialErrorDisplay,
} from "../../utils/special-errors";
import { useTerminalBackground } from "../hooks/use-terminal-background";
import {
getDefaultForeground,
@@ -260,8 +260,12 @@ function ToolCallView(props: {
);
}
function ClineCreditsErrorView(props: { defaultFg?: string }) {
return (
function SpecialErrorBlock(props: {
display: SpecialErrorDisplay;
defaultFg?: string;
}) {
const { display, defaultFg } = props;
const renderShell = (children: React.ReactNode) => (
<box flexDirection="row">
<text fg="red" content="* " />
<box
@@ -271,23 +275,68 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
borderColor="red"
paddingX={1}
>
<text fg="red">Cline Credits depleted</text>
<text
fg={props.defaultFg}
selectable
content="You have run out of Cline credits. Add credits in the dashboard to continue."
/>
<box flexDirection="row">
<text fg="gray">Dashboard: </text>
<text fg="cyan" selectable>
<a href={CLINE_CREDITS_DASHBOARD_URL}>
{CLINE_CREDITS_DASHBOARD_URL}
</a>
</text>
</box>
{children}
</box>
</box>
);
switch (display.kind) {
case "cline_credits_depleted":
return renderShell(
<>
<text fg="red">{display.title}</text>
<text fg={defaultFg} selectable>
{display.message}
</text>
{display.balanceText && (
<text fg="gray" selectable>
Current balance: {display.balanceText}
</text>
)}
<box flexDirection="row">
<text fg="gray">Dashboard: </text>
<text fg="cyan" selectable>
<a href={display.url}>{display.url}</a>
</text>
</box>
</>,
);
case "cline_account_auth_required":
return renderShell(
<>
<text fg="red">{display.title}</text>
<text fg={defaultFg} selectable>
{display.message}
</text>
<box flexDirection="row">
<text fg="gray">Open </text>
<text fg="cyan" selectable>
{display.command}
</text>
<text fg="gray"> to sign in, then retry.</text>
</box>
</>,
);
}
}
function GenericErrorBlock(props: { text: string }) {
return (
<box flexDirection="row">
<text fg="red" content="* " />
<text fg="red" selectable content={`Error: ${props.text}`} />
</box>
);
}
function ErrorBlock(props: {
entry: Extract<ChatEntry, { kind: "error" }>;
defaultFg?: string;
}) {
const display = resolveSpecialErrorDisplay(props.entry.errorInfo);
if (!display) {
return <GenericErrorBlock text={props.entry.text} />;
}
return <SpecialErrorBlock display={display} defaultFg={props.defaultFg} />;
}
export function ChatEntryView(props: {
@@ -384,16 +433,9 @@ export function ChatEntryView(props: {
/>
);
case "error":
if (isClineAccountCreditsErrorMessage(entry.text)) {
return <ClineCreditsErrorView defaultFg={defaultFg} />;
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
<text fg="red" selectable content={`Error: ${entry.text}`} />
</box>
);
case "error": {
return <ErrorBlock entry={entry} defaultFg={defaultFg} />;
}
case "status":
return (
@@ -1,12 +1,15 @@
// @jsxImportSource @opentui/react
import type { ClineAccountOrganization } from "@cline/core";
import {
type ClineAccountOrganization,
getSdkErrorInfo,
isClineAccountAuthRequiredErrorInfo,
} from "@cline/core";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
type ClineAccountSnapshot,
formatClineCredits,
isClineAccountAuthErrorMessage,
} from "../../cline-account";
import { palette } from "../../palette";
@@ -256,10 +259,11 @@ export function AccountDialogContent(
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (generation.current === currentGeneration) {
const authRequired = isClineAccountAuthRequiredErrorInfo(
getSdkErrorInfo(error),
);
setState({
status: isClineAccountAuthErrorMessage(message)
? "unauthenticated"
: "error",
status: authRequired ? "unauthenticated" : "error",
message,
});
setSelectedAction(0);
+5 -1
View File
@@ -171,7 +171,11 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
turnErrorReportedRef.current = true;
onTurnErrorReported(true);
if (!event.recoverable || verbose) {
appendEntry({ kind: "error", text: event.error.message });
appendEntry({
kind: "error",
text: event.error.message,
errorInfo: event.errorInfo,
});
}
break;
case "notice":
@@ -1,3 +1,4 @@
import { getSdkErrorInfo } from "@cline/shared";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import type { SlashCommandRegistry } from "../commands/slash-command-registry";
@@ -368,6 +369,7 @@ export function usePromptInputController(input: {
session.appendEntry({
kind: "error",
text: error instanceof Error ? error.message : String(error),
errorInfo: getSdkErrorInfo(error),
});
}
} finally {
+2 -1
View File
@@ -6,6 +6,7 @@ import type {
} from "@cline/core";
import type {
Message,
SdkErrorInfo,
ToolApprovalRequest,
ToolApprovalResult,
} from "@cline/shared";
@@ -41,7 +42,7 @@ export type ChatEntry =
error?: string;
};
}
| { kind: "error"; text: string }
| { kind: "error"; text: string; errorInfo?: SdkErrorInfo }
| { kind: "status"; text: string }
| { kind: "team"; text: string }
| { kind: "user_submitted"; text: string; delivery?: "queue" | "steer" }
+123
View File
@@ -6,14 +6,21 @@ import type { Config } from "./types";
describe("handleEvent text formatting", () => {
let output = "";
let errorOutput = "";
beforeEach(() => {
vi.restoreAllMocks();
output = "";
errorOutput = "";
setCurrentOutputMode("text");
vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => {
output += String(chunk);
return true;
});
vi.spyOn(process.stderr, "write").mockImplementation((chunk: unknown) => {
errorOutput += String(chunk);
return true;
});
});
it("adds a ⎿ before text that follows a tool block", () => {
@@ -160,6 +167,122 @@ describe("handleEvent text formatting", () => {
expect(output).toContain("── aborted (2 iterations) ──");
});
it("prints Cline insufficient credits errors with a dashboard link", () => {
handleEvent(
{
type: "error",
error: new Error("Error: Insufficient balance"),
recoverable: false,
iteration: 1,
errorInfo: {
kind: "provider",
providerId: "cline",
modelId: "openai/gpt-5.4",
message: "Not enough credits available",
code: "insufficient_credits",
status: 402,
details: {
current_balance: -0,
buy_credits_url:
"https://app.cline.bot/dashboard/account?tab=credits",
},
},
} as unknown as AgentEvent,
{} as Config,
);
expect(errorOutput).toContain("Cline Credits depleted");
expect(errorOutput).toContain("You have run out of Cline credits");
expect(errorOutput).toContain("Current balance: $0.00");
expect(errorOutput).toContain(
"https://app.cline.bot/dashboard/account?tab=credits",
);
expect(errorOutput).not.toContain("Insufficient balance");
});
it("prints Cline account auth errors with an account command", () => {
handleEvent(
{
type: "error",
error: new Error("Cline account authentication requires sign in."),
recoverable: false,
iteration: 1,
errorInfo: {
kind: "auth",
providerId: "cline",
code: "cline_account_auth_required",
message: "Cline account authentication requires sign in.",
},
} as unknown as AgentEvent,
{} as Config,
);
expect(errorOutput).toContain("Cline account sign-in required");
expect(errorOutput).toContain("Sign in to your Cline account to continue");
expect(errorOutput).toContain("Open /account to sign in");
expect(errorOutput).not.toContain("authentication requires sign in.");
});
it("prints provider-stream Cline account auth errors with an account command", () => {
handleEvent(
{
type: "error",
error: new Error("Unauthorized"),
recoverable: false,
iteration: 1,
errorInfo: {
kind: "provider",
providerId: "cline",
modelId: "openai/gpt-5.4",
code: "cline_account_auth_required",
status: 401,
message: "Cline account authentication requires sign in.",
},
} as unknown as AgentEvent,
{} as Config,
);
expect(errorOutput).toContain("Cline account sign-in required");
expect(errorOutput).toContain("Sign in to your Cline account to continue");
expect(errorOutput).toContain("Open /account to sign in");
expect(errorOutput).not.toContain("Unauthorized");
});
it("emits special errors as structured agent events in JSON mode", () => {
setCurrentOutputMode("json");
handleEvent(
{
type: "error",
error: new Error("Error: Insufficient balance"),
recoverable: false,
iteration: 1,
errorInfo: {
kind: "provider",
providerId: "cline",
modelId: "openai/gpt-5.4",
message: "Not enough credits available",
code: "insufficient_credits",
status: 402,
},
} as unknown as AgentEvent,
{} as Config,
);
expect(errorOutput).toBe("");
const record: unknown = JSON.parse(output);
expect(record).toMatchObject({
type: "agent_event",
event: {
type: "error",
errorInfo: {
kind: "provider",
providerId: "cline",
code: "insufficient_credits",
},
},
});
});
it("suppresses heartbeat-only team progress messages", () => {
handleTeamEvent({
type: "run_progress",
+8 -1
View File
@@ -5,8 +5,10 @@ import {
emitJsonLine,
getCurrentOutputMode,
write,
writeDiagnostic,
writeErr,
} from "./output";
import { formatSpecialErrorText } from "./special-errors";
import type { Config } from "./types";
// =============================================================================
@@ -176,7 +178,12 @@ export function handleEvent(event: AgentEvent, config: Config): void {
case "error":
closeInlineStreamIfNeeded();
if (!event.recoverable || config.verbose) {
writeErr(event.error.message);
const specialErrorText = formatSpecialErrorText(event.errorInfo);
if (specialErrorText) {
writeDiagnostic(specialErrorText);
} else {
writeErr(event.error.message);
}
}
break;
case "notice":
+189
View File
@@ -0,0 +1,189 @@
import {
CLINE_ACCOUNT_AUTH_REQUIRED_CODE,
getClineEnvironmentConfig,
isClineAccountAuthRequiredErrorInfo,
isClineInsufficientCreditsErrorInfo,
type SdkErrorInfo,
type SdkProviderErrorInfo,
} from "@cline/shared";
import { formatCreditBalance } from "./output";
export type SpecialErrorDisplay =
| {
kind: "cline_credits_depleted";
title: string;
message: string;
balanceText?: string;
url: string;
}
| {
kind: "cline_account_auth_required";
title: string;
message: string;
command: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function getDetailsValue(
errorInfo: SdkProviderErrorInfo,
...keys: string[]
): unknown {
const details = errorInfo.details;
if (!isRecord(details)) {
return undefined;
}
for (const key of keys) {
if (key in details) {
return details[key];
}
}
return undefined;
}
function getDetailsString(
errorInfo: SdkProviderErrorInfo,
...keys: string[]
): string | undefined {
const value = getDetailsValue(errorInfo, ...keys);
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
function getDetailsNumber(
errorInfo: SdkProviderErrorInfo,
...keys: string[]
): number | undefined {
const value = getDetailsValue(errorInfo, ...keys);
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (
typeof value === "string" &&
value.trim().length > 0 &&
Number.isFinite(Number(value))
) {
return Number(value);
}
return undefined;
}
function isHttpUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === "https:" || parsed.protocol === "http:";
} catch {
return false;
}
}
function resolveClineCreditsUrl(errorInfo: SdkProviderErrorInfo): string {
const detailUrl = getDetailsString(
errorInfo,
"buy_credits_url",
"buyCreditsUrl",
"dashboard_url",
"dashboardUrl",
);
if (detailUrl && isHttpUrl(detailUrl)) {
return detailUrl;
}
const { appBaseUrl } = getClineEnvironmentConfig();
return `${appBaseUrl}/dashboard/account?tab=credits`;
}
function resolveClineCreditsDisplay(
errorInfo: SdkErrorInfo,
): SpecialErrorDisplay | undefined {
if (!isClineInsufficientCreditsErrorInfo(errorInfo)) {
return undefined;
}
const currentBalance = getDetailsNumber(
errorInfo,
"current_balance",
"currentBalance",
);
const balance =
currentBalance === undefined
? undefined
: Object.is(currentBalance, -0)
? 0
: currentBalance;
return {
kind: "cline_credits_depleted",
title: "Cline Credits depleted",
message:
"You have run out of Cline credits. Add credits in the dashboard to continue.",
...(balance !== undefined
? { balanceText: formatCreditBalance(balance) }
: {}),
url: resolveClineCreditsUrl(errorInfo),
};
}
function resolveClineAccountAuthDisplay(
errorInfo: SdkErrorInfo,
): SpecialErrorDisplay | undefined {
const isAuthRequired =
isClineAccountAuthRequiredErrorInfo(errorInfo) ||
(errorInfo.kind === "provider" &&
errorInfo.providerId === "cline" &&
errorInfo.code === CLINE_ACCOUNT_AUTH_REQUIRED_CODE);
if (!isAuthRequired) {
return undefined;
}
return {
kind: "cline_account_auth_required",
title: "Cline account sign-in required",
message: "Sign in to your Cline account to continue.",
command: "/account",
};
}
export function resolveSpecialErrorDisplay(
errorInfo: SdkErrorInfo | undefined,
): SpecialErrorDisplay | undefined {
if (!errorInfo) {
return undefined;
}
switch (errorInfo.kind) {
case "provider":
return (
resolveClineCreditsDisplay(errorInfo) ??
resolveClineAccountAuthDisplay(errorInfo)
);
case "auth":
return resolveClineAccountAuthDisplay(errorInfo);
}
}
export function formatSpecialErrorText(
errorInfo: SdkErrorInfo | undefined,
): string | undefined {
const display = resolveSpecialErrorDisplay(errorInfo);
if (!display) {
return undefined;
}
switch (display.kind) {
case "cline_credits_depleted":
return [
display.title,
display.message,
display.balanceText
? `Current balance: ${display.balanceText}`
: undefined,
`Dashboard: ${display.url}`,
]
.filter((line): line is string => Boolean(line))
.join("\n");
case "cline_account_auth_required":
return [
display.title,
display.message,
`Open ${display.command} to sign in, then retry your message.`,
].join("\n");
}
}
@@ -3,10 +3,12 @@ import type {
AgentModel,
AgentModelEvent,
AgentModelRequest,
AgentRuntimeEvent,
AgentRuntimePlugin,
AgentTool,
ITelemetryService,
} from "@cline/shared";
import { createClineAccountAuthRequiredError } from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import { AgentRuntime } from "./index";
@@ -1345,6 +1347,67 @@ describe("AgentRuntime", () => {
expect(telemetry.capture).toHaveBeenCalled();
});
it("propagates provider error info through failed runs", async () => {
const errorInfo = {
kind: "provider" as const,
providerId: "cline",
modelId: "openai/gpt-5.4",
message: "Not enough credits available",
code: "insufficient_credits",
status: 402,
details: { current_balance: -0 },
};
const model = new ScriptedModel([
() => [
{
type: "finish",
reason: "error",
error: "Not enough credits available",
errorInfo,
},
],
]);
const runtime = new AgentRuntime({ model });
const events: AgentRuntimeEvent[] = [];
runtime.subscribe((event) => {
events.push(event);
});
const result = await runtime.run("Fail");
expect(result.status).toBe("failed");
expect(result.errorInfo).toEqual(errorInfo);
const failed = events.find(
(event): event is Extract<AgentRuntimeEvent, { type: "run-failed" }> =>
event.type === "run-failed",
);
expect(failed?.errorInfo).toEqual(errorInfo);
});
it("propagates structured error info from thrown errors", async () => {
const authError = createClineAccountAuthRequiredError();
const model = new ScriptedModel([
() => {
throw authError;
},
]);
const runtime = new AgentRuntime({ model });
const events: AgentRuntimeEvent[] = [];
runtime.subscribe((event) => {
events.push(event);
});
const result = await runtime.run("Fail");
expect(result.status).toBe("failed");
expect(result.errorInfo).toEqual(authError.errorInfo);
const failed = events.find(
(event): event is Extract<AgentRuntimeEvent, { type: "run-failed" }> =>
event.type === "run-failed",
);
expect(failed?.errorInfo).toEqual(authError.errorInfo);
});
it("propagates agent identity including role through snapshots and plugin setup", async () => {
const setup = vi.fn(() => undefined);
const plugin: AgentRuntimePlugin = {
+17 -1
View File
@@ -19,11 +19,16 @@ import type {
AgentToolResult,
AgentUsage,
AgentRuntimeConfig as BaseAgentRuntimeConfig,
SdkErrorInfo,
TelemetryProperties,
ToolApprovalResult,
ToolPolicy,
} from "@cline/shared";
import { captureSdkError, estimateTokens } from "@cline/shared";
import {
captureSdkError,
estimateTokens,
getSdkErrorInfo,
} from "@cline/shared";
import { nanoid } from "nanoid";
// Local `createUID` helper. The clinee source imports this from
@@ -380,6 +385,7 @@ export class AgentRuntime {
pendingToolCalls: [] as string[],
usage: cloneUsage(DEFAULT_USAGE),
lastError: undefined as string | undefined,
lastErrorInfo: undefined as SdkErrorInfo | undefined,
};
private initialization?: Promise<void>;
private abortController?: AbortController;
@@ -442,6 +448,7 @@ export class AgentRuntime {
this.state.pendingToolCalls = [];
this.state.usage = cloneUsage(DEFAULT_USAGE);
this.state.lastError = undefined;
this.state.lastErrorInfo = undefined;
this.state.messages = cloneMessages(messages);
this.config = {
...this.config,
@@ -551,6 +558,7 @@ export class AgentRuntime {
this.state.iteration = 0;
this.state.pendingToolCalls = [];
this.state.lastError = undefined;
this.state.lastErrorInfo = undefined;
this.state.usage = cloneUsage(DEFAULT_USAGE);
try {
@@ -689,6 +697,10 @@ export class AgentRuntime {
: "failed";
this.state.status = status;
this.state.lastError = normalized.message;
const errorInfo =
status === "failed"
? (this.state.lastErrorInfo ?? getSdkErrorInfo(normalized))
: undefined;
const result: AgentRunResult = {
agentId: this.state.agentId,
agentRole: this.state.agentRole,
@@ -699,6 +711,7 @@ export class AgentRuntime {
messages: cloneMessages(this.state.messages),
usage: cloneUsage(this.state.usage),
error: status === "failed" ? normalized : undefined,
...(errorInfo ? { errorInfo } : {}),
};
await this.callAfterRunHooks(result);
if (status === "failed") {
@@ -706,6 +719,7 @@ export class AgentRuntime {
type: "run-failed",
snapshot: this.snapshot(),
error: normalized,
...(errorInfo ? { errorInfo } : {}),
});
} else {
await this.emit({
@@ -905,6 +919,8 @@ export class AgentRuntime {
if (event.error) {
this.state.lastError = event.error;
}
this.state.lastErrorInfo =
event.reason === "error" ? event.errorInfo : undefined;
break;
}
}
+17 -1
View File
@@ -37,10 +37,26 @@ export type {
AgentToolDefinition,
AgentToolResult,
AgentUsage,
ErrorWithSdkInfo,
SdkAuthErrorInfo,
SdkErrorInfo,
SdkProviderErrorInfo,
ToolApprovalResult,
ToolPolicy,
} from "@cline/shared";
export { createTool } from "@cline/shared";
export {
CLINE_ACCOUNT_AUTH_REQUIRED_CODE,
CLINE_INSUFFICIENT_CREDITS_CODE,
createClineAccountAuthRequiredError,
createErrorWithSdkInfo,
createTool,
getSdkErrorInfo,
isClineAccountAuthRequiredErrorInfo,
isClineInsufficientCreditsErrorInfo,
isSdkAuthErrorInfo,
isSdkErrorInfo,
isSdkProviderErrorInfo,
} from "@cline/shared";
export type {
AgentEventListener,
AgentRunInput,
+16
View File
@@ -1,6 +1,22 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const rootDir = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: [
{
find: /^@cline\/shared\/(.+)$/,
replacement: resolve(rootDir, "../shared/src/$1"),
},
{
find: /^@cline\/shared$/,
replacement: resolve(rootDir, "../shared/src/index.ts"),
},
],
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
+14
View File
@@ -32,6 +32,7 @@ export type {
ClineAccountActionRequest,
ConnectorHookEvent,
ContentBlock,
ErrorWithSdkInfo,
FileContent,
GetProviderModelsActionRequest,
HookSessionContext,
@@ -48,6 +49,9 @@ export type {
ProviderOAuthLoginResponse,
RuntimeLoggerConfig,
SaveProviderSettingsActionRequest,
SdkAuthErrorInfo,
SdkErrorInfo,
SdkProviderErrorInfo,
SdkTelemetryErrorComponent,
SdkTelemetryErrorSeverity,
SessionLineage,
@@ -75,14 +79,24 @@ export type {
export {
buildClineSystemPrompt as getClineDefaultSystemPrompt,
buildSdkErrorProperties,
CLINE_ACCOUNT_AUTH_REQUIRED_CODE,
CLINE_INSUFFICIENT_CREDITS_CODE,
ContributionRegistry,
captureSdkError,
createClineAccountAuthRequiredError,
createClineTelemetryServiceConfig,
createClineTelemetryServiceMetadata,
createContributionRegistry,
createErrorWithSdkInfo,
createTool,
emptyWorkspaceManifest,
formatDisplayUserInput,
getSdkErrorInfo,
isClineAccountAuthRequiredErrorInfo,
isClineInsufficientCreditsErrorInfo,
isSdkAuthErrorInfo,
isSdkErrorInfo,
isSdkProviderErrorInfo,
noopBasicLogger,
normalizeSdkError,
normalizeUserInput,
@@ -7,6 +7,7 @@ import {
type AgentEvent,
type AgentResult,
captureSdkError,
createClineAccountAuthRequiredError,
createSessionId,
type ITelemetryService,
isLikelyAuthError,
@@ -1554,6 +1555,9 @@ export class LocalRuntimeHost implements RuntimeHost {
});
} catch (error) {
if (error instanceof OAuthReauthRequiredError) {
if (error.providerId === "cline") {
throw createClineAccountAuthRequiredError();
}
throw new Error(`${error.providerId} requires re-authentication.`);
}
throw error;
@@ -715,6 +715,32 @@ describe("RuntimeEventAdapter — run lifecycle", () => {
iteration: 4,
});
});
it("propagates provider error info on run-failed events", () => {
const err = new Error("Not enough credits available");
const errorInfo = {
kind: "provider" as const,
providerId: "cline",
modelId: "openai/gpt-5.4",
message: "Not enough credits available",
code: "insufficient_credits",
status: 402,
details: { current_balance: -0 },
};
const out = adapter.translate({
type: "run-failed",
snapshot: makeSnapshot({ iteration: 4 }),
error: err,
errorInfo,
});
expect(out[0]).toEqual({
type: "error",
error: err,
recoverable: false,
iteration: 4,
errorInfo,
});
});
});
// ---------------------------------------------------------------------------
@@ -241,6 +241,7 @@ export class RuntimeEventAdapter {
error: event.error,
recoverable: false,
iteration: event.snapshot.iteration,
...(event.errorInfo ? { errorInfo: event.errorInfo } : {}),
},
];
default: {
+83 -11
View File
@@ -6,6 +6,7 @@ import type {
GatewayProviderFactory,
GatewayResolvedProviderConfig,
GatewayStreamRequest,
SdkProviderErrorInfo,
} from "@cline/shared";
import {
type AiSdkFormatterMessage,
@@ -44,6 +45,48 @@ interface GatewayNormalizedUsage {
}
type ProviderModuleKind = AiSdkProviderOptionsTarget;
interface AiSdkProviderErrorInput {
error: unknown;
message: string;
request: GatewayStreamRequest;
context: GatewayProviderContext;
}
interface AiSdkProviderAdapterOptions {
resolveErrorInfo?: (
input: AiSdkProviderErrorInput,
) => SdkProviderErrorInfo | undefined;
}
interface CapturedAiSdkError {
error?: unknown;
message?: string;
}
function resolveAiSdkProviderError(input: {
error: unknown;
capturedError?: CapturedAiSdkError;
request: GatewayStreamRequest;
context: GatewayProviderContext;
options: AiSdkProviderAdapterOptions;
}): {
message: string;
errorInfo?: SdkProviderErrorInfo;
} {
const rawError = input.capturedError?.error ?? input.error;
const message = input.capturedError?.message ?? extractErrorMessage(rawError);
const errorInfo = input.options.resolveErrorInfo?.({
error: rawError,
message,
request: input.request,
context: input.context,
});
return {
message,
...(errorInfo ? { errorInfo } : {}),
};
}
function buildCachedAiSdkMessages(
request: GatewayStreamRequest,
context: GatewayProviderContext,
@@ -626,15 +669,31 @@ async function* emitAiSdkEvents(
request: GatewayStreamRequest,
context: GatewayProviderContext,
pricingValue?: unknown,
capturedError?: { current: string | undefined },
capturedError?: CapturedAiSdkError,
options: AiSdkProviderAdapterOptions = {},
): AsyncIterable<AgentModelEvent> {
let sawToolCalls = false;
const emittedToolCallIds = new Set<string>();
let finishReason: unknown;
let streamError: string | undefined;
let streamErrorInfo: SdkProviderErrorInfo | undefined;
let finishUsage: unknown;
let finishProviderMetadata: unknown;
const resolveStreamError = (error: unknown): string => {
const resolved = resolveAiSdkProviderError({
error,
capturedError,
request,
context,
options,
});
if (!streamErrorInfo) {
streamErrorInfo = resolved.errorInfo;
}
return resolved.message;
};
try {
if (stream.fullStream) {
for await (const part of stream.fullStream) {
@@ -740,8 +799,7 @@ async function* emitAiSdkEvents(
}
if (part.type === "error") {
streamError =
capturedError?.current ?? extractErrorMessage(part.error);
streamError = resolveStreamError(part.error);
break;
}
@@ -758,7 +816,7 @@ async function* emitAiSdkEvents(
} catch (error) {
// Prefer the real provider error from onError over the generic
// NoOutputGeneratedError the AI SDK throws when 0 steps are recorded.
streamError = capturedError?.current ?? extractErrorMessage(error);
streamError = resolveStreamError(error);
}
// Prefer stream.usage (has raw cost data) over finish part usage.
@@ -773,7 +831,7 @@ async function* emitAiSdkEvents(
usageToEmit = await stream.usage;
} catch (error) {
if (!streamError) {
streamError = capturedError?.current ?? extractErrorMessage(error);
streamError = resolveStreamError(error);
}
usageToEmit = finishUsage;
metadataToUse = finishProviderMetadata;
@@ -794,6 +852,7 @@ async function* emitAiSdkEvents(
type: "finish",
reason: streamError ? "error" : mapFinishReason(finishReason, sawToolCalls),
error: streamError,
...(streamErrorInfo ? { errorInfo: streamErrorInfo } : {}),
};
}
@@ -866,14 +925,15 @@ async function createProviderModule(
}
}
function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
export function createAiSdkProvider(
kind: ProviderModuleKind,
options: AiSdkProviderAdapterOptions = {},
): GatewayProviderFactory {
return async (config) => ({
async *stream(request, context) {
const log = context.logger;
let stream: AiSdkStreamResult | undefined;
const capturedError: { current: string | undefined } = {
current: undefined,
};
const capturedError: CapturedAiSdkError = {};
try {
const provider = await createProviderModule(kind, config, context);
const langfuse = await ensureGatewayLangfuseTelemetry(
@@ -908,7 +968,8 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
) as never,
onError: ({ error: streamError }) => {
const msg = extractErrorMessage(streamError);
capturedError.current = msg;
capturedError.error = streamError;
capturedError.message = msg;
if (log?.error) {
log.error("[ai-sdk] stream error", {
providerId: request.providerId,
@@ -948,12 +1009,20 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
context,
context.model.metadata?.pricing,
capturedError,
options,
);
} catch (error) {
suppressDanglingStreamPromises(stream);
// Prefer the real provider error captured in onError over the generic
// NoOutputGeneratedError that the AI SDK throws when 0 steps are recorded.
const msg = capturedError.current ?? extractErrorMessage(error);
const resolvedError = resolveAiSdkProviderError({
error,
capturedError,
request,
context,
options,
});
const msg = resolvedError.message;
if (log?.error) {
log.error("[ai-sdk] provider error", {
providerId: request.providerId,
@@ -982,6 +1051,9 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
type: "finish",
reason: "error",
error: msg,
...(resolvedError.errorInfo
? { errorInfo: resolvedError.errorInfo }
: {}),
};
}
},
@@ -87,6 +87,16 @@ function resolveRuntimeFamily(
return spec.family;
}
async function loadProviderFactory(
spec: (typeof BUILTIN_SPECS)[number],
): Promise<GatewayProviderFactory> {
if (spec.id === "cline") {
const module = await import("./cline");
return module.createClineProvider;
}
return loadFamilyFactory(resolveRuntimeFamily(spec));
}
export const BUILTIN_PROVIDER_REGISTRATIONS: GatewayProviderRegistration[] =
BUILTIN_SPECS.map((spec) => ({
manifest: toManifest(spec),
@@ -96,6 +106,6 @@ export const BUILTIN_PROVIDER_REGISTRATIONS: GatewayProviderRegistration[] =
baseUrl: spec.defaults?.baseUrl,
},
loadProvider: async () => ({
createProvider: await loadFamilyFactory(resolveRuntimeFamily(spec)),
createProvider: await loadProviderFactory(spec),
}),
}));
@@ -0,0 +1,276 @@
import type {
GatewayProviderContext,
GatewayStreamRequest,
SdkProviderErrorInfo,
} from "@cline/shared";
interface ResolveClineProviderErrorInfoInput {
error: unknown;
message: string;
request: GatewayStreamRequest;
context: GatewayProviderContext;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function parseStructuredString(value: string): unknown | undefined {
const trimmed = value.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
return undefined;
}
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
function collectStructuredRecords(
value: unknown,
records: Record<string, unknown>[],
seen: WeakSet<object>,
): void {
if (typeof value === "string") {
const parsed = parseStructuredString(value);
if (parsed !== undefined) {
collectStructuredRecords(parsed, records, seen);
}
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectStructuredRecords(item, records, seen);
}
return;
}
if (!isRecord(value)) {
return;
}
if (seen.has(value)) {
return;
}
seen.add(value);
records.push(value);
for (const key of [
"error",
"errors",
"detail",
"details",
"message",
"data",
"body",
"response",
"responseBody",
"cause",
]) {
if (key in value) {
collectStructuredRecords(value[key], records, seen);
}
}
}
function getString(
record: Record<string, unknown>,
...keys: string[]
): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return undefined;
}
function getNumber(
record: Record<string, unknown>,
...keys: string[]
): number | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (
typeof value === "string" &&
value.trim().length > 0 &&
Number.isFinite(Number(value))
) {
return Number(value);
}
}
return undefined;
}
function getHeaderValue(headers: unknown, name: string): string | undefined {
if (!headers) {
return undefined;
}
const get = isRecord(headers) ? headers.get : undefined;
if (typeof get === "function") {
const value = get.call(headers, name);
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
if (!isRecord(headers)) {
return undefined;
}
const direct = getString(headers, name, name.toLowerCase());
if (direct) {
return direct;
}
const normalizedName = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (
key.toLowerCase() === normalizedName &&
typeof value === "string" &&
value.trim().length > 0
) {
return value.trim();
}
}
return undefined;
}
function findStatus(
records: readonly Record<string, unknown>[],
): number | undefined {
for (const record of records) {
const status = getNumber(record, "status", "statusCode", "status_code");
if (status !== undefined) {
return status;
}
}
return undefined;
}
function findRequestId(
records: readonly Record<string, unknown>[],
): string | undefined {
for (const record of records) {
const requestId = getString(
record,
"request_id",
"requestId",
"x-request-id",
);
if (requestId) {
return requestId;
}
const headerRequestId =
getHeaderValue(record.headers, "x-request-id") ??
getHeaderValue(record.headers, "request-id");
if (headerRequestId) {
return headerRequestId;
}
}
return undefined;
}
function findCodeRecord(records: readonly Record<string, unknown>[]):
| {
record: Record<string, unknown>;
code: string;
}
| undefined {
for (const record of records) {
const code = getString(record, "code", "error_code", "errorCode");
if (code) {
return { record, code };
}
}
return undefined;
}
const DETAIL_IDENTITY_KEYS = new Set([
"code",
"error_code",
"errorCode",
"status",
"statusCode",
"status_code",
"request_id",
"requestId",
"message",
"detail",
]);
const DETAIL_STRUCTURAL_KEYS = new Set([
"error",
"errors",
"data",
"body",
"response",
"responseBody",
"cause",
"headers",
]);
function shouldSkipDetailKey(key: string): boolean {
return DETAIL_IDENTITY_KEYS.has(key) || DETAIL_STRUCTURAL_KEYS.has(key);
}
function copyNestedDetails(
value: unknown,
target: Record<string, unknown>,
): void {
if (!isRecord(value)) {
return;
}
for (const [key, nestedValue] of Object.entries(value)) {
if (key === "details" || shouldSkipDetailKey(key)) {
continue;
}
target[key] = nestedValue;
}
}
function buildDetails(
record: Record<string, unknown>,
): Record<string, unknown> {
const details: Record<string, unknown> = {};
for (const [key, value] of Object.entries(record)) {
if (key === "details") {
copyNestedDetails(value, details);
continue;
}
if (shouldSkipDetailKey(key)) {
continue;
}
details[key] = value;
}
return details;
}
export function resolveClineProviderErrorInfo(
input: ResolveClineProviderErrorInfoInput,
): SdkProviderErrorInfo | undefined {
const records: Record<string, unknown>[] = [];
collectStructuredRecords(input.error, records, new WeakSet());
const codeRecord = findCodeRecord(records);
if (!codeRecord) {
return undefined;
}
const message =
getString(codeRecord.record, "message", "detail") ?? input.message;
const status = findStatus(records);
const requestId = findRequestId(records);
const details = buildDetails(codeRecord.record);
return {
kind: "provider",
providerId: input.request.providerId,
modelId: input.request.modelId,
message,
code: codeRecord.code,
...(status !== undefined ? { status } : {}),
...(requestId ? { requestId } : {}),
...(Object.keys(details).length > 0 ? { details } : {}),
};
}
+6
View File
@@ -0,0 +1,6 @@
import { createAiSdkProvider } from "./ai-sdk";
import { resolveClineProviderErrorInfo } from "./cline-errors";
export const createClineProvider = createAiSdkProvider("openai-compatible", {
resolveErrorInfo: resolveClineProviderErrorInfo,
});
@@ -881,6 +881,80 @@ describe("sdk-gateway", () => {
});
});
it("attaches Cline provider error info for insufficient credits", async () => {
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([
{
type: "error",
error: {
status: 402,
headers: { "x-request-id": "req_credits" },
responseBody: JSON.stringify({
error: JSON.stringify({
code: "insufficient_credits",
message: "Not enough credits available",
error: { duplicate: true },
responseBody: { duplicate: true },
details: {
current_balance: -0,
buy_credits_url:
"https://app.cline.bot/dashboard/account?tab=credits",
},
}),
}),
},
},
]),
});
const gateway = createGateway({
providerConfigs: [
{
providerId: "cline",
apiKey: "cline-key",
},
],
});
const events = await collect(
await gateway.stream({
providerId: "cline",
modelId: "openai/gpt-5.4",
messages: baseMessages,
}),
);
const finish = events.find(
(event): event is Extract<AgentModelEvent, { type: "finish" }> =>
event.type === "finish",
);
expect(finish).toMatchObject({
type: "finish",
reason: "error",
errorInfo: {
kind: "provider",
providerId: "cline",
modelId: "openai/gpt-5.4",
message: "Not enough credits available",
code: "insufficient_credits",
status: 402,
requestId: "req_credits",
details: expect.objectContaining({
current_balance: 0,
buy_credits_url:
"https://app.cline.bot/dashboard/account?tab=credits",
}),
},
});
if (!finish?.errorInfo || finish.errorInfo.kind !== "provider") {
throw new Error("Expected Cline provider error info");
}
const details = finish.errorInfo.details ?? {};
expect(details).not.toHaveProperty("error");
expect(details).not.toHaveProperty("responseBody");
expect(details).not.toHaveProperty("details");
});
it("preserves explicit zero cost instead of falling back to catalog pricing", async () => {
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([
+4
View File
@@ -5,6 +5,7 @@
*
*/
import type { SdkErrorInfo, SdkProviderErrorInfo } from "./errors/error-info";
import type { ModelInfo } from "./llms/model-info";
import type {
ToolApprovalRequest,
@@ -252,6 +253,7 @@ export type AgentModelEvent =
type: "finish";
reason: AgentModelFinishReason;
error?: string;
errorInfo?: SdkProviderErrorInfo;
};
export interface AgentModel {
@@ -542,6 +544,7 @@ export type AgentRuntimeEvent =
type: "run-failed";
snapshot: AgentRuntimeStateSnapshot;
error: Error;
errorInfo?: SdkErrorInfo;
};
// =============================================================================
@@ -558,4 +561,5 @@ export interface AgentRunResult {
messages: readonly AgentMessage[];
usage: AgentUsage;
error?: Error;
errorInfo?: SdkErrorInfo;
}
+3
View File
@@ -12,6 +12,7 @@
import { z } from "zod";
import type { AgentRuntimeHooks, AgentTool } from "../agent";
import type { SdkErrorInfo } from "../errors/error-info";
import type { ExtensionContext } from "../extensions/context";
import type {
AgentExtensionApi,
@@ -186,6 +187,8 @@ export interface AgentErrorEvent extends AgentEventMetadata {
recoverable: boolean;
/** Current iteration when error occurred */
iteration: number;
// Structured error metadata, when available
errorInfo?: SdkErrorInfo;
}
export interface ConsecutiveMistakeLimitContext {
@@ -0,0 +1,111 @@
export interface SdkProviderErrorInfo {
kind: "provider";
providerId: string;
modelId?: string;
message: string;
code?: string;
status?: number;
requestId?: string;
details?: Record<string, unknown>;
}
export interface SdkAuthErrorInfo {
kind: "auth";
providerId: string;
message: string;
code: string;
details?: Record<string, unknown>;
}
export type SdkErrorInfo = SdkProviderErrorInfo | SdkAuthErrorInfo;
export type ErrorWithSdkInfo = Error & { errorInfo: SdkErrorInfo };
export const CLINE_INSUFFICIENT_CREDITS_CODE = "insufficient_credits";
export const CLINE_ACCOUNT_AUTH_REQUIRED_CODE = "cline_account_auth_required";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function isSdkProviderErrorInfo(
value: unknown,
): value is SdkProviderErrorInfo {
if (!isRecord(value)) {
return false;
}
return (
value.kind === "provider" &&
typeof value.providerId === "string" &&
typeof value.message === "string"
);
}
export function isSdkAuthErrorInfo(value: unknown): value is SdkAuthErrorInfo {
if (!isRecord(value)) {
return false;
}
return (
value.kind === "auth" &&
typeof value.providerId === "string" &&
typeof value.message === "string" &&
typeof value.code === "string"
);
}
export function isSdkErrorInfo(value: unknown): value is SdkErrorInfo {
return isSdkProviderErrorInfo(value) || isSdkAuthErrorInfo(value);
}
export function isClineInsufficientCreditsErrorInfo(
value: unknown,
): value is SdkProviderErrorInfo & {
providerId: "cline";
code: typeof CLINE_INSUFFICIENT_CREDITS_CODE;
} {
return (
isSdkProviderErrorInfo(value) &&
value.providerId === "cline" &&
value.code === CLINE_INSUFFICIENT_CREDITS_CODE
);
}
export function isClineAccountAuthRequiredErrorInfo(
value: unknown,
): value is SdkAuthErrorInfo & {
providerId: "cline";
code: typeof CLINE_ACCOUNT_AUTH_REQUIRED_CODE;
} {
return (
isSdkAuthErrorInfo(value) &&
value.providerId === "cline" &&
value.code === CLINE_ACCOUNT_AUTH_REQUIRED_CODE
);
}
export function getSdkErrorInfo(error: unknown): SdkErrorInfo | undefined {
if (!isRecord(error)) {
return undefined;
}
return isSdkErrorInfo(error.errorInfo) ? error.errorInfo : undefined;
}
export function createErrorWithSdkInfo(
errorInfo: SdkErrorInfo,
message = errorInfo.message,
): ErrorWithSdkInfo {
const error = new Error(message) as ErrorWithSdkInfo;
error.errorInfo = errorInfo;
return error;
}
export function createClineAccountAuthRequiredError(
message = "Cline account authentication requires sign in.",
): ErrorWithSdkInfo {
return createErrorWithSdkInfo({
kind: "auth",
providerId: "cline",
code: CLINE_ACCOUNT_AUTH_REQUIRED_CODE,
message,
});
}
+18
View File
@@ -18,6 +18,24 @@ export {
} from "./connectors/events";
export type * from "./connectors/options";
export type { AutomationEventEnvelope } from "./cron";
export type {
ErrorWithSdkInfo,
SdkAuthErrorInfo,
SdkErrorInfo,
SdkProviderErrorInfo,
} from "./errors/error-info";
export {
CLINE_ACCOUNT_AUTH_REQUIRED_CODE,
CLINE_INSUFFICIENT_CREDITS_CODE,
createClineAccountAuthRequiredError,
createErrorWithSdkInfo,
getSdkErrorInfo,
isClineAccountAuthRequiredErrorInfo,
isClineInsufficientCreditsErrorInfo,
isSdkAuthErrorInfo,
isSdkErrorInfo,
isSdkProviderErrorInfo,
} from "./errors/error-info";
export type {
ClientContext,
ClientName,
+18
View File
@@ -32,6 +32,24 @@ export type {
} from "./cron";
export type { Disposable } from "./dispose";
export { disposeAll, registerDisposable } from "./dispose";
export type {
ErrorWithSdkInfo,
SdkAuthErrorInfo,
SdkErrorInfo,
SdkProviderErrorInfo,
} from "./errors/error-info";
export {
CLINE_ACCOUNT_AUTH_REQUIRED_CODE,
CLINE_INSUFFICIENT_CREDITS_CODE,
createClineAccountAuthRequiredError,
createErrorWithSdkInfo,
getSdkErrorInfo,
isClineAccountAuthRequiredErrorInfo,
isClineInsufficientCreditsErrorInfo,
isSdkAuthErrorInfo,
isSdkErrorInfo,
isSdkProviderErrorInfo,
} from "./errors/error-info";
export type {
ClientContext,
ClientName,