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, }; }