mirror of
https://github.com/cline/cline.git
synced 2026-09-05 05:02:27 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d62ce30f82 | |||
| 6a67a6ab54 | |||
| e5aab65200 | |||
| dbc8f7cfd1 |
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user