Skip to content

Commit 927d171

Browse files
committed
fix(agents): actionable copy for exhausted auth-profile failover
The pi-embedded runner threw a generic "No available auth profile for <provider> (all in cooldown or unavailable)" message whenever every configured profile was in cooldown, even though the failover machinery had already resolved a concrete reason (auth, billing, rate_limit, session_expired, etc.). The user-facing copy never used that reason and never told the user how to recover. Route the resolved reason through a single presenter (`formatAuthProfileFailureMessage`) that composes a reason-specific sentence with `buildProviderAuthRecoveryHint`, so FailoverError.message ships with the right `openclaw models auth login --provider <id>` hint when the cause is authentication/session/billing, and falls back to the underlying provider error text otherwise. Helper moved out of `src/commands/` into `src/agents/` because `src/agents/` cannot depend on `src/commands/`.
1 parent ba88b7a commit 927d171

6 files changed

Lines changed: 323 additions & 78 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,7 @@ Docs: https://docs.openclaw.ai
657657
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
658658
- Agents/embedded runner: classify HTML auth provider responses as `auth_html` and return a re-authentication hint instead of the CDN-blocked copy that `upstream_html` returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.
659659
- TUI/streaming watchdog: dismiss the `This response is taking longer than expected` notice as soon as a chat event for the same run arrives, so the message no longer sits next to the recovered response when the run was only briefly silent. Refs #67052, #69081 (closed), prior attempt #69026. Thanks @jpruit20 and @romneyda.
660+
- Agents/auth profiles: replace the bare `No available auth profile for <provider> (all in cooldown or unavailable)` TUI error with a reason-driven message that names what was observed (authentication failing, cooldown after rate-limits, billing block, etc.) and suggests the matching `openclaw models auth login --provider <provider>` recovery command for auth/session/billing causes, while falling back to the underlying provider error for cases without a clear recovery path. Thanks @romneyda.
660661
- Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.
661662

662663
## 2026.5.20
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("../provider-auth-recovery-hint.js", () => ({
4+
buildProviderAuthRecoveryHint: (params: { provider: string }) =>
5+
`Run \`openclaw models auth login --provider ${params.provider}\`.`,
6+
}));
7+
8+
import { formatAuthProfileFailureMessage } from "./failure-copy.js";
9+
10+
describe("formatAuthProfileFailureMessage", () => {
11+
describe("allInCooldown: true", () => {
12+
it("renders the auth reason with a login recovery hint", () => {
13+
const message = formatAuthProfileFailureMessage({
14+
reason: "auth",
15+
provider: "openai-codex",
16+
allInCooldown: true,
17+
});
18+
expect(message).toBe(
19+
"Every auth profile for openai-codex is currently failing authentication; sessions look expired or credentials were rejected. Run `openclaw models auth login --provider openai-codex`.",
20+
);
21+
});
22+
23+
it("renders the billing reason with a login recovery hint", () => {
24+
const message = formatAuthProfileFailureMessage({
25+
reason: "billing",
26+
provider: "anthropic",
27+
allInCooldown: true,
28+
});
29+
expect(message).toBe(
30+
"Every auth profile for anthropic is blocked for billing on the provider account. Run `openclaw models auth login --provider anthropic`.",
31+
);
32+
});
33+
34+
it("does not append a login hint for transient rate_limit cooldowns", () => {
35+
const message = formatAuthProfileFailureMessage({
36+
reason: "rate_limit",
37+
provider: "openai-codex",
38+
allInCooldown: true,
39+
});
40+
expect(message).toBe(
41+
"Every auth profile for openai-codex is cooling down after recent rate-limit responses.",
42+
);
43+
});
44+
45+
it("falls back to a generic cooldown sentence for unknown reasons", () => {
46+
const message = formatAuthProfileFailureMessage({
47+
reason: "unknown",
48+
provider: "openai-codex",
49+
allInCooldown: true,
50+
});
51+
expect(message).toBe(
52+
"No openai-codex auth profile is currently available; all are in cooldown or blocked. Run `openclaw models auth login --provider openai-codex`.",
53+
);
54+
});
55+
});
56+
57+
describe("allInCooldown: false", () => {
58+
it("renders the auth reason with a login hint when a credential is broken", () => {
59+
const message = formatAuthProfileFailureMessage({
60+
reason: "auth",
61+
provider: "openai-codex",
62+
allInCooldown: false,
63+
cause: new Error("invalid_grant"),
64+
});
65+
expect(message).toBe(
66+
"Authentication with openai-codex did not succeed. Run `openclaw models auth login --provider openai-codex`. (invalid_grant)",
67+
);
68+
});
69+
70+
it("returns the underlying error message when no actionable reason matches", () => {
71+
const message = formatAuthProfileFailureMessage({
72+
reason: "unknown",
73+
provider: "openai-codex",
74+
allInCooldown: false,
75+
cause: new Error("upstream provider returned 502"),
76+
});
77+
expect(message).toBe("upstream provider returned 502");
78+
});
79+
80+
it("returns the generic cooldown sentence when no reason and no cause apply", () => {
81+
const message = formatAuthProfileFailureMessage({
82+
reason: "unknown",
83+
provider: "openai-codex",
84+
allInCooldown: false,
85+
});
86+
expect(message).toBe(
87+
"No openai-codex auth profile is currently available; all are in cooldown or blocked.",
88+
);
89+
});
90+
91+
it("does not duplicate the cause text when it already appears in the description", () => {
92+
const message = formatAuthProfileFailureMessage({
93+
reason: "auth",
94+
provider: "openai-codex",
95+
allInCooldown: false,
96+
cause: new Error("Authentication with openai-codex did not succeed"),
97+
});
98+
expect(message).toBe(
99+
"Authentication with openai-codex did not succeed. Run `openclaw models auth login --provider openai-codex`.",
100+
);
101+
});
102+
});
103+
});
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
2+
import { formatErrorMessage } from "../../infra/errors.js";
3+
import type { FailoverReason } from "../embedded-agent-helpers/types.js";
4+
import { buildProviderAuthRecoveryHint } from "../provider-auth-recovery-hint.js";
5+
6+
export type AuthProfileFailureCopyParams = {
7+
reason: FailoverReason;
8+
provider: string;
9+
/**
10+
* True when the failure was reached because every configured profile is in
11+
* cooldown / blocked. False when an attempt to use a specific profile threw
12+
* (e.g. credential lookup failed). The two paths produce different copy
13+
* because only the cooldown case implies "wait or rotate"; the other case
14+
* implies "the credential itself is broken".
15+
*/
16+
allInCooldown: boolean;
17+
/**
18+
* Underlying error that triggered the failover, if any. Used to append a
19+
* short diagnostic suffix and to fall back to the original message when no
20+
* structured recovery copy applies.
21+
*/
22+
cause?: unknown;
23+
config?: OpenClawConfig;
24+
workspaceDir?: string;
25+
env?: NodeJS.ProcessEnv;
26+
};
27+
28+
function describeReason(
29+
reason: FailoverReason,
30+
provider: string,
31+
allInCooldown: boolean,
32+
): string | null {
33+
if (allInCooldown) {
34+
switch (reason) {
35+
case "auth":
36+
case "session_expired":
37+
return `Every auth profile for ${provider} is currently failing authentication; sessions look expired or credentials were rejected.`;
38+
case "auth_permanent":
39+
return `Every auth profile for ${provider} has been permanently denied by the provider.`;
40+
case "billing":
41+
return `Every auth profile for ${provider} is blocked for billing on the provider account.`;
42+
case "rate_limit":
43+
return `Every auth profile for ${provider} is cooling down after recent rate-limit responses.`;
44+
case "overloaded":
45+
return `Every auth profile for ${provider} is cooling down while the provider is reporting overload.`;
46+
case "timeout":
47+
return `Every auth profile for ${provider} is cooling down after recent requests timed out.`;
48+
case "model_not_found":
49+
return `Every auth profile for ${provider} was rejected with a model-not-found error.`;
50+
case "server_error":
51+
return `Every auth profile for ${provider} is cooling down after recent provider server errors.`;
52+
default:
53+
return `No ${provider} auth profile is currently available; all are in cooldown or blocked.`;
54+
}
55+
}
56+
switch (reason) {
57+
case "auth":
58+
case "session_expired":
59+
return `Authentication with ${provider} did not succeed.`;
60+
case "auth_permanent":
61+
return `Authentication with ${provider} was permanently denied.`;
62+
case "billing":
63+
return `Provider ${provider} reported a billing problem on this account.`;
64+
default:
65+
return null;
66+
}
67+
}
68+
69+
function shouldIncludeRecoveryHint(reason: FailoverReason): boolean {
70+
switch (reason) {
71+
case "auth":
72+
case "auth_permanent":
73+
case "session_expired":
74+
case "billing":
75+
return true;
76+
case "rate_limit":
77+
case "overloaded":
78+
case "timeout":
79+
case "server_error":
80+
case "model_not_found":
81+
return false;
82+
default:
83+
return true;
84+
}
85+
}
86+
87+
function diagnosticSuffix(cause: unknown, primary: string): string | null {
88+
if (cause === undefined || cause === null) {
89+
return null;
90+
}
91+
const text = formatErrorMessage(cause).trim();
92+
if (!text || primary.includes(text)) {
93+
return null;
94+
}
95+
return ` (${text})`;
96+
}
97+
98+
/**
99+
* Single source of truth for user-facing copy when an auth-profile rotation
100+
* fails. Composes a reason-specific sentence with an actionable next-step
101+
* derived from the provider's plugin manifest (`buildProviderAuthRecoveryHint`).
102+
*
103+
* Falls back to the underlying error's text when the reason maps to nothing
104+
* actionable, so we never produce worse copy than the raw error.
105+
*/
106+
export function formatAuthProfileFailureMessage(params: AuthProfileFailureCopyParams): string {
107+
const description = describeReason(params.reason, params.provider, params.allInCooldown);
108+
if (!description) {
109+
const causeText = params.cause ? formatErrorMessage(params.cause).trim() : "";
110+
if (causeText) {
111+
return causeText;
112+
}
113+
return `No ${params.provider} auth profile is currently available; all are in cooldown or blocked.`;
114+
}
115+
const hint = shouldIncludeRecoveryHint(params.reason)
116+
? buildProviderAuthRecoveryHint({
117+
provider: params.provider,
118+
config: params.config,
119+
workspaceDir: params.workspaceDir,
120+
env: params.env,
121+
})
122+
: null;
123+
const suffix = diagnosticSuffix(params.cause, description);
124+
const parts = [description];
125+
if (hint) {
126+
parts.push(hint);
127+
}
128+
const message = parts.join(" ");
129+
return suffix ? `${message}${suffix}` : message;
130+
}

src/agents/embedded-agent-runner/run/auth-controller.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
isProfileInCooldown,
88
resolveProfilesUnavailableReason,
99
} from "../../auth-profiles.js";
10+
import { formatAuthProfileFailureMessage } from "../../auth-profiles/failure-copy.js";
1011
import {
1112
classifyFailoverReason,
1213
isFailoverErrorMessage,
@@ -324,16 +325,25 @@ export function createEmbeddedRunAuthController(params: {
324325
}): never => {
325326
const provider = params.getProvider();
326327
const modelId = params.getModelId();
327-
const fallbackMessage = `No available auth profile for ${provider} (all in cooldown or unavailable).`;
328-
const message =
328+
const messageForReason =
329329
failoverParams.message?.trim() ||
330-
(failoverParams.error ? formatErrorMessage(failoverParams.error).trim() : "") ||
331-
fallbackMessage;
330+
(failoverParams.error ? formatErrorMessage(failoverParams.error).trim() : "");
332331
const reason = resolveAuthProfileFailoverReason({
333332
allInCooldown: failoverParams.allInCooldown,
334-
message,
333+
message: messageForReason,
335334
profileIds: params.profileCandidates,
336335
});
336+
const message =
337+
failoverParams.message?.trim() ||
338+
formatAuthProfileFailureMessage({
339+
reason,
340+
provider,
341+
allInCooldown: failoverParams.allInCooldown,
342+
cause: failoverParams.error,
343+
config: params.config,
344+
workspaceDir: params.workspaceDir,
345+
env: process.env,
346+
});
337347
if (params.fallbackConfigured) {
338348
throw new FailoverError(message, {
339349
reason,
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { formatCliCommand } from "../cli/command-format.js";
2+
import type { OpenClawConfig } from "../config/types.openclaw.js";
3+
import { resolveManifestProviderAuthChoices } from "../plugins/provider-auth-choices.js";
4+
import { normalizeProviderId } from "./model-selection.js";
5+
import { resolveProviderAuthAliasMap } from "./provider-auth-aliases.js";
6+
7+
function normalizeProviderIdForAuth(
8+
providerId: string,
9+
aliases: Readonly<Record<string, string>>,
10+
): string {
11+
const normalized = normalizeProviderId(providerId);
12+
return normalized ? (aliases[normalized] ?? normalized) : normalized;
13+
}
14+
15+
function matchesProviderAuthChoice(
16+
choice: { providerId: string },
17+
providerId: string,
18+
aliases: Readonly<Record<string, string>>,
19+
): boolean {
20+
const normalized = normalizeProviderIdForAuth(providerId, aliases);
21+
if (!normalized) {
22+
return false;
23+
}
24+
return normalizeProviderIdForAuth(choice.providerId, aliases) === normalized;
25+
}
26+
27+
function resolveProviderAuthLoginCommand(params: {
28+
provider: string;
29+
config?: OpenClawConfig;
30+
workspaceDir?: string;
31+
env?: NodeJS.ProcessEnv;
32+
}): string | undefined {
33+
const aliases = resolveProviderAuthAliasMap(params);
34+
const choice = resolveManifestProviderAuthChoices(params).find((candidate) =>
35+
matchesProviderAuthChoice(candidate, params.provider, aliases),
36+
);
37+
if (!choice) {
38+
return undefined;
39+
}
40+
const providerId = normalizeProviderIdForAuth(choice.providerId, aliases);
41+
return formatCliCommand(`openclaw models auth login --provider ${providerId}`);
42+
}
43+
44+
export function buildProviderAuthRecoveryHint(params: {
45+
provider: string;
46+
config?: OpenClawConfig;
47+
workspaceDir?: string;
48+
env?: NodeJS.ProcessEnv;
49+
includeConfigure?: boolean;
50+
includeEnvVar?: boolean;
51+
}): string {
52+
const loginCommand = resolveProviderAuthLoginCommand(params);
53+
const parts: string[] = [];
54+
if (loginCommand) {
55+
parts.push(`Run \`${loginCommand}\``);
56+
}
57+
if (params.includeConfigure !== false) {
58+
parts.push(`\`${formatCliCommand("openclaw configure")}\``);
59+
}
60+
if (params.includeEnvVar) {
61+
parts.push("set an API key env var");
62+
}
63+
if (parts.length === 0) {
64+
return `Run \`${formatCliCommand("openclaw configure")}\`.`;
65+
}
66+
if (parts.length === 1) {
67+
return `${parts[0]}.`;
68+
}
69+
if (parts.length === 2) {
70+
return `${parts[0]} or ${parts[1]}.`;
71+
}
72+
return `${parts[0]}, ${parts[1]}, or ${parts[2]}.`;
73+
}

0 commit comments

Comments
 (0)