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 a724d358242..b2118d9c9fb 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 2874b481866..d3e753f6ba1 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 f1b3efc8099..3eb0ba40a02 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 12cdc1cffa6..6a72130c9c5 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 015e64bcc55..d5275218892 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 4ce7ece5786..765f44f0e75 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 8e0d1b43b75..66eaf6d3ecb 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 022664a48e8..a9b80e387b4 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 055ae8c54c0..9f3439db84a 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 ead14d53e13..f9719749935 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 af6f83a1655..3d66b4e141f 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, }; }