From 9f112a1a7a4090295bfefdb0f89159aa18f0ecda Mon Sep 17 00:00:00 2001 From: gleb <116607327+loeclos@users.noreply.github.com> Date: Sat, 16 May 2026 19:21:57 -0700 Subject: [PATCH] fix: include checked credential source in missing auth errors Include the checked credential source in missing API key errors so users can see which env var, profile, or config path to fix. Fixes #82785. Co-authored-by: gleb <116607327+loeclos@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/anthropic-transport-stream.ts | 3 +-- src/agents/model-auth-runtime-shared.ts | 6 ++++- src/agents/model-auth.test.ts | 20 +++++++++++++++- src/agents/model-auth.ts | 6 ++++- .../compact.hooks.harness.ts | 10 +++++++- src/agents/pi-embedded-runner/compact.ts | 5 ++-- .../run/auth-controller.test.ts | 24 +++++++++++++++++++ .../pi-embedded-runner/run/auth-controller.ts | 10 ++++---- src/agents/simple-completion-runtime.test.ts | 7 +++++- src/agents/simple-completion-runtime.ts | 3 ++- 11 files changed, 80 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a724d358242a..b2118d9c9fbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/auth: include the checked credential source in missing API key errors, so users can see which env var, profile, or config path to fix. Fixes #82785. Thanks @loeclos. - Providers/GitHub Copilot: hash Responses replay item ids with sha256 instead of a weak 32-bit hash and build same-provider Copilot tool-call ids distinctly, so concurrent tool-call replays no longer collide and reject follow-up turns. - Providers/Anthropic-messages: extract `reasoning_content` from `thinking` blocks during assistant replay so proxy providers that route through the Anthropic-messages transport preserve reasoning context across tool-call follow-up turns. Thanks @Sunnyone2three. - Agents/GitHub Copilot: normalize replayed Responses tool-call IDs before dispatch so resumed sessions with historical overlong tool IDs continue instead of failing Copilot schema validation. (#82750) Thanks @galiniliev. diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index 2874b481866a..d3e753f6ba19 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -1193,8 +1193,7 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn { const hasNativeAnthropicDelta = (delta?.type === "text_delta" && typeof delta.text === "string") || (delta?.type === "thinking_delta" && typeof delta.thinking === "string") || - (delta?.type === "input_json_delta" && - typeof delta.partial_json === "string") || + (delta?.type === "input_json_delta" && typeof delta.partial_json === "string") || (delta?.type === "signature_delta" && typeof delta.signature === "string"); let appendedContent = false; if ( diff --git a/src/agents/model-auth-runtime-shared.ts b/src/agents/model-auth-runtime-shared.ts index f1b3efc8099c..3eb0ba40a02a 100644 --- a/src/agents/model-auth-runtime-shared.ts +++ b/src/agents/model-auth-runtime-shared.ts @@ -25,10 +25,14 @@ export function resolveAwsSdkEnvVarName(env: NodeJS.ProcessEnv = process.env): s return undefined; } +export function formatMissingAuthError(auth: ResolvedProviderAuth, provider: string): string { + return `No API key resolved for provider "${provider}" (auth mode: ${auth.mode}, checked: ${auth.source}).`; +} + export function requireApiKey(auth: ResolvedProviderAuth, provider: string): string { const key = normalizeSecretInput(auth.apiKey); if (key) { return key; } - throw new Error(`No API key resolved for provider "${provider}" (auth mode: ${auth.mode}).`); + throw new Error(formatMissingAuthError(auth, provider)); } diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 12cdc1cffa63..6a72130c9c5b 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -123,6 +123,7 @@ vi.mock("../plugins/provider-runtime.js", async () => { let applyAuthHeaderOverride: typeof import("./model-auth.js").applyAuthHeaderOverride; let applyLocalNoAuthHeaderOverride: typeof import("./model-auth.js").applyLocalNoAuthHeaderOverride; +let formatMissingAuthError: typeof import("./model-auth.js").formatMissingAuthError; let hasUsableCustomProviderApiKey: typeof import("./model-auth.js").hasUsableCustomProviderApiKey; let hasSyntheticLocalProviderAuthConfig: typeof import("./model-auth.js").hasSyntheticLocalProviderAuthConfig; let requireApiKey: typeof import("./model-auth.js").requireApiKey; @@ -141,6 +142,7 @@ beforeAll(async () => { ({ applyAuthHeaderOverride, applyLocalNoAuthHeaderOverride, + formatMissingAuthError, hasSyntheticLocalProviderAuthConfig, hasUsableCustomProviderApiKey, requireApiKey, @@ -354,6 +356,20 @@ describe("resolveModelAuthMode", () => { }); describe("requireApiKey", () => { + it("formats missing auth errors with the checked credential source", () => { + expect( + formatMissingAuthError( + { + source: "env: OPENAI_API_KEY", + mode: "api-key", + }, + "openai", + ), + ).toBe( + 'No API key resolved for provider "openai" (auth mode: api-key, checked: env: OPENAI_API_KEY).', + ); + }); + it("normalizes line breaks in resolved API keys", () => { const key = requireApiKey( { @@ -376,7 +392,9 @@ describe("requireApiKey", () => { }, "openai", ), - ).toThrow('No API key resolved for provider "openai"'); + ).toThrow( + 'No API key resolved for provider "openai" (auth mode: api-key, checked: env: OPENAI_API_KEY).', + ); }); }); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 015e64bcc553..d52752188926 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -46,7 +46,11 @@ export { ensureAuthProfileStoreWithoutExternalProfiles, resolveAuthProfileOrder, } from "./auth-profiles.js"; -export { requireApiKey, resolveAwsSdkEnvVarName } from "./model-auth-runtime-shared.js"; +export { + formatMissingAuthError, + requireApiKey, + resolveAwsSdkEnvVarName, +} from "./model-auth-runtime-shared.js"; export type { ResolvedProviderAuth } from "./model-auth-runtime-shared.js"; export type ProviderCredentialPrecedence = "profile-first" | "env-first"; diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index 4ce7ece5786f..765f44f0e759 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -437,7 +437,15 @@ export async function loadCompactHooksHarness(): Promise<{ applyAuthHeaderOverride: vi.fn((model: unknown) => model), applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), ensureAuthProfileStoreWithoutExternalProfiles: vi.fn(() => ({})), - getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })), + formatMissingAuthError: vi.fn( + (auth: { mode: string; source: string }, provider: string) => + `No API key resolved for provider "${provider}" (auth mode: ${auth.mode}, checked: ${auth.source}).`, + ), + getApiKeyForModel: vi.fn(async () => ({ + apiKey: "test", + mode: "env", + source: "test harness", + })), resolveModelAuthMode: vi.fn(() => "env"), })); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 8e0d1b43b75f..66eaf6d3ecb4 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -63,6 +63,7 @@ import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-promp import { applyAuthHeaderOverride, applyLocalNoAuthHeaderOverride, + formatMissingAuthError, getApiKeyForModel, resolveModelAuthMode, } from "../model-auth.js"; @@ -537,9 +538,7 @@ async function compactEmbeddedPiSessionDirectOnce( if (!apiKeyInfo.apiKey) { if (apiKeyInfo.mode !== "aws-sdk") { - throw new Error( - `No API key resolved for provider "${runtimeModel.provider}" (auth mode: ${apiKeyInfo.mode}).`, - ); + throw new Error(formatMissingAuthError(apiKeyInfo, runtimeModel.provider)); } } else { const preparedAuth = await prepareProviderRuntimeAuth({ diff --git a/src/agents/pi-embedded-runner/run/auth-controller.test.ts b/src/agents/pi-embedded-runner/run/auth-controller.test.ts index 022664a48e83..a9b80e387b48 100644 --- a/src/agents/pi-embedded-runner/run/auth-controller.test.ts +++ b/src/agents/pi-embedded-runner/run/auth-controller.test.ts @@ -198,6 +198,30 @@ describe("createEmbeddedRunAuthController", () => { expect(harness.runtimeAuthState?.profileId).toBe("default"); }); + it("includes the checked credential source when an api key is missing", async () => { + const harness = createMutableAuthControllerHarness(); + const setRuntimeApiKey = vi.fn<(provider: string, apiKey: string) => void>(); + + mocks.getApiKeyForModel.mockResolvedValue({ + mode: "api-key", + source: "models.providers.custom-openai", + }); + + const controller = createMutableEmbeddedRunAuthController({ + harness, + setRuntimeApiKey, + }); + + await expect(controller.initializeAuthProfile()).rejects.toThrow( + 'No API key resolved for provider "custom-openai" (auth mode: api-key, checked: models.providers.custom-openai).', + ); + expect(setRuntimeApiKey).not.toHaveBeenCalled(); + expect(harness.apiKeyInfo).toMatchObject({ + mode: "api-key", + source: "models.providers.custom-openai", + }); + }); + it("rejects privileged runtime transport overrides on the first auth exchange", async () => { let runtimeModel = createTestModel(); diff --git a/src/agents/pi-embedded-runner/run/auth-controller.ts b/src/agents/pi-embedded-runner/run/auth-controller.ts index 055ae8c54c03..9f3439db84a7 100644 --- a/src/agents/pi-embedded-runner/run/auth-controller.ts +++ b/src/agents/pi-embedded-runner/run/auth-controller.ts @@ -9,7 +9,11 @@ import { } from "../../auth-profiles.js"; import { FailoverError, resolveFailoverStatus } from "../../failover-error.js"; import { shouldAllowCooldownProbeForReason } from "../../failover-policy.js"; -import { getApiKeyForModel, type ResolvedProviderAuth } from "../../model-auth.js"; +import { + formatMissingAuthError, + getApiKeyForModel, + type ResolvedProviderAuth, +} from "../../model-auth.js"; import { classifyFailoverReason, isFailoverErrorMessage, @@ -365,9 +369,7 @@ export function createEmbeddedRunAuthController(params: { if (!apiKeyInfo.apiKey) { if (apiKeyInfo.mode !== "aws-sdk") { const runtimeModel = params.getRuntimeModel(); - throw new Error( - `No API key resolved for provider "${runtimeModel.provider}" (auth mode: ${apiKeyInfo.mode}).`, - ); + throw new Error(formatMissingAuthError(apiKeyInfo, runtimeModel.provider)); } // AWS SDK auth via IMDS / instance role / ECS task role: no explicit API // key is available but the SDK default credential chain can resolve diff --git a/src/agents/simple-completion-runtime.test.ts b/src/agents/simple-completion-runtime.test.ts index ead14d53e138..f9719749935c 100644 --- a/src/agents/simple-completion-runtime.test.ts +++ b/src/agents/simple-completion-runtime.test.ts @@ -28,6 +28,10 @@ vi.mock("./simple-completion-transport.js", () => ({ })); vi.mock("./model-auth.js", () => ({ + formatMissingAuthError: vi.fn( + (auth: { source: string; mode: string }, provider: string) => + `No API key resolved for provider "${provider}" (auth mode: ${auth.mode}, checked: ${auth.source}).`, + ), getApiKeyForModel: hoisted.getApiKeyForModelMock, applyLocalNoAuthHeaderOverride: hoisted.applyLocalNoAuthHeaderOverrideMock, })); @@ -170,7 +174,8 @@ describe("prepareSimpleCompletionModel", () => { }); expect(result).toEqual({ - error: 'No API key resolved for provider "anthropic" (auth mode: api-key).', + error: + 'No API key resolved for provider "anthropic" (auth mode: api-key, checked: models.providers.anthropic).', auth: { source: "models.providers.anthropic", mode: "api-key", diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index af6f83a16555..3d66b4e141ff 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -13,6 +13,7 @@ import { DEFAULT_PROVIDER } from "./defaults.js"; import { resolveAgentHarnessPolicy } from "./harness/policy.js"; import { applyLocalNoAuthHeaderOverride, + formatMissingAuthError, getApiKeyForModel, type ResolvedProviderAuth, } from "./model-auth.js"; @@ -235,7 +236,7 @@ export async function prepareSimpleCompletionModel(params: { }) ) { return { - error: `No API key resolved for provider "${resolved.model.provider}" (auth mode: ${auth.mode}).`, + error: formatMissingAuthError(auth, resolved.model.provider), auth, }; }