From f70e4396996ebd4dfe2a153fd12eb8dc6ccb9111 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 02:28:06 +0100 Subject: [PATCH] fix(amazon-bedrock): skip auto memory embeddings without credentials (#71245) Co-authored-by: bitloi --- CHANGELOG.md | 1 + .../amazon-bedrock/embedding-provider.test.ts | 65 +++++++++++ .../amazon-bedrock/embedding-provider.ts | 21 ++-- .../memory-embedding-adapter.test.ts | 100 +++++++++++++++++ .../memory-embedding-adapter.ts | 10 ++ .../memory-core/src/memory/embeddings.test.ts | 105 ++++++++++++++++++ 6 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 extensions/amazon-bedrock/embedding-provider.test.ts create mode 100644 extensions/amazon-bedrock/memory-embedding-adapter.test.ts create mode 100644 extensions/memory-core/src/memory/embeddings.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a97aae6cea3..41375167489 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Docs: https://docs.openclaw.ai - Telegram/model picker: show configured model display names when browsing models through provider buttons, matching typed `/models ` output. Fixes #70560. (#71016) Thanks @iskim77. - Plugins/runtime deps: stage bundled plugin runtime dependencies for packaged/global installs in an external runtime root and retain already staged deps across repairs, avoiding package-tree update races and npm pruning after upgrades. Thanks @steipete. - Plugins/runtime deps: log bundled plugin runtime-dependency staging before synchronous npm installs start and include elapsed timing afterward, so first boot after upgrades no longer looks hung while dependencies are being repaired. Thanks @steipete. +- Memory/Bedrock: skip Bedrock during automatic memory embedding selection when AWS credentials are unavailable, so `memory_search` can fall back to lexical search instead of failing on the first embed call. Fixes #71143 via #71245. Thanks @bitloi. - Agents/failover: forward embedded run abort signals into provider-owned model streams, cap implicit LLM idle watchdogs below long run timeouts, and mark 429 responses without usable retry timing as non-retryable so GitHub Copilot rate limits fail over or surface promptly instead of hanging until run timeout. Fixes #71120. Thanks @steipete. - Plugins/Google Meet: make meeting creation join by default, with an explicit URL-only opt-out, so agents that create a Meet also enter it. Thanks @steipete. - Telegram/polling: persist accepted update offsets before long-running handlers complete so poller restarts do not replay already-ingested updates, while keeping same-process retries for handler failures. Thanks @steipete. diff --git a/extensions/amazon-bedrock/embedding-provider.test.ts b/extensions/amazon-bedrock/embedding-provider.test.ts new file mode 100644 index 00000000000..9854302fb64 --- /dev/null +++ b/extensions/amazon-bedrock/embedding-provider.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from "vitest"; +import { hasAwsCredentials } from "./embedding-provider.js"; + +describe("hasAwsCredentials", () => { + it("accepts static AWS key credentials without loading the credential chain", async () => { + const loadCredentialProvider = vi.fn(); + + await expect( + hasAwsCredentials( + { + AWS_ACCESS_KEY_ID: "access-key", + AWS_SECRET_ACCESS_KEY: "secret-key", + }, + loadCredentialProvider, + ), + ).resolves.toBe(true); + + expect(loadCredentialProvider).not.toHaveBeenCalled(); + }); + + it("accepts the Bedrock bearer token without loading the credential chain", async () => { + const loadCredentialProvider = vi.fn(); + + await expect( + hasAwsCredentials( + { + AWS_BEARER_TOKEN_BEDROCK: "bearer-token", + }, + loadCredentialProvider, + ), + ).resolves.toBe(true); + + expect(loadCredentialProvider).not.toHaveBeenCalled(); + }); + + it("requires AWS profile credentials to resolve through the credential chain", async () => { + const loadCredentialProvider = vi.fn().mockResolvedValue({ + defaultProvider: () => async () => ({ accessKeyId: "resolved-access-key" }), + }); + + await expect(hasAwsCredentials({ AWS_PROFILE: "work" }, loadCredentialProvider)).resolves.toBe( + true, + ); + + expect(loadCredentialProvider).toHaveBeenCalledOnce(); + }); + + it("rejects AWS profile markers when the credential chain cannot resolve", async () => { + const loadCredentialProvider = vi.fn().mockResolvedValue({ + defaultProvider: () => async () => { + throw new Error("Could not load credentials from any providers"); + }, + }); + + await expect( + hasAwsCredentials({ AWS_PROFILE: "missing" }, loadCredentialProvider), + ).resolves.toBe(false); + }); + + it("returns false when the AWS credential provider package is unavailable", async () => { + const loadCredentialProvider = vi.fn().mockResolvedValue(null); + + await expect(hasAwsCredentials({}, loadCredentialProvider)).resolves.toBe(false); + }); +}); diff --git a/extensions/amazon-bedrock/embedding-provider.ts b/extensions/amazon-bedrock/embedding-provider.ts index 5e8ccb1c5bb..b2e92db5ca1 100644 --- a/extensions/amazon-bedrock/embedding-provider.ts +++ b/extensions/amazon-bedrock/embedding-provider.ts @@ -122,6 +122,8 @@ interface AwsCredentialProviderSdk { }>; } +type AwsCredentialProviderLoader = () => Promise; + let sdkCache: AwsSdk | null = null; let credentialProviderSdkCache: AwsCredentialProviderSdk | null | undefined; @@ -368,24 +370,17 @@ export function resolveBedrockEmbeddingClient( // Credential detection // --------------------------------------------------------------------------- -const CREDENTIAL_ENV_VARS = [ - "AWS_PROFILE", - "AWS_BEARER_TOKEN_BEDROCK", - "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", - "AWS_CONTAINER_CREDENTIALS_FULL_URI", - "AWS_EC2_METADATA_SERVICE_ENDPOINT", - "AWS_WEB_IDENTITY_TOKEN_FILE", - "AWS_ROLE_ARN", -] as const; - -export async function hasAwsCredentials(env: NodeJS.ProcessEnv = process.env): Promise { +export async function hasAwsCredentials( + env: NodeJS.ProcessEnv = process.env, + loadCredentialProvider: AwsCredentialProviderLoader = loadCredentialProviderSdk, +): Promise { if (env.AWS_ACCESS_KEY_ID?.trim() && env.AWS_SECRET_ACCESS_KEY?.trim()) { return true; } - if (CREDENTIAL_ENV_VARS.some((k) => env[k]?.trim())) { + if (env.AWS_BEARER_TOKEN_BEDROCK?.trim()) { return true; } - const credentialProviderSdk = await loadCredentialProviderSdk(); + const credentialProviderSdk = await loadCredentialProvider(); if (!credentialProviderSdk) { return false; } diff --git a/extensions/amazon-bedrock/memory-embedding-adapter.test.ts b/extensions/amazon-bedrock/memory-embedding-adapter.test.ts new file mode 100644 index 00000000000..66003ae5dfc --- /dev/null +++ b/extensions/amazon-bedrock/memory-embedding-adapter.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const hasAwsCredentialsMock = vi.hoisted(() => vi.fn()); +const createBedrockEmbeddingProviderMock = vi.hoisted(() => vi.fn()); + +vi.mock("./embedding-provider.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hasAwsCredentials: hasAwsCredentialsMock, + createBedrockEmbeddingProvider: createBedrockEmbeddingProviderMock, + }; +}); + +import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js"; + +function defaultCreateOptions() { + return { + config: {} as Record, + agentDir: "/tmp/test-agent", + model: "", + }; +} + +function stubCreate(client: { region: string; model: string; dimensions?: number }) { + createBedrockEmbeddingProviderMock.mockResolvedValue({ + provider: { + id: "bedrock", + model: client.model, + embedQuery: async () => [], + embedBatch: async () => [], + }, + client, + }); +} + +describe("bedrockMemoryEmbeddingProviderAdapter", () => { + beforeEach(() => { + hasAwsCredentialsMock.mockReset(); + createBedrockEmbeddingProviderMock.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("registers the expected adapter metadata", () => { + expect(bedrockMemoryEmbeddingProviderAdapter.id).toBe("bedrock"); + expect(bedrockMemoryEmbeddingProviderAdapter.transport).toBe("remote"); + expect(bedrockMemoryEmbeddingProviderAdapter.authProviderId).toBe("amazon-bedrock"); + expect(bedrockMemoryEmbeddingProviderAdapter.autoSelectPriority).toBe(60); + expect(bedrockMemoryEmbeddingProviderAdapter.allowExplicitWhenConfiguredAuto).toBe(true); + }); + + it("throws a missing-api-key sentinel error when AWS credentials are unavailable", async () => { + hasAwsCredentialsMock.mockResolvedValue(false); + + await expect( + bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()), + ).rejects.toThrow(/No API key found for provider "bedrock"/); + await expect( + bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()), + ).rejects.toThrow(/AWS credentials are not available/); + + expect(createBedrockEmbeddingProviderMock).not.toHaveBeenCalled(); + }); + + it("creates the provider when AWS credentials are available", async () => { + hasAwsCredentialsMock.mockResolvedValue(true); + stubCreate({ region: "us-east-1", model: "amazon.titan-embed-text-v2:0", dimensions: 1024 }); + + const result = await bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()); + + expect(result.provider?.id).toBe("bedrock"); + expect(result.runtime).toEqual({ + id: "bedrock", + cacheKeyData: { + provider: "bedrock", + region: "us-east-1", + model: "amazon.titan-embed-text-v2:0", + dimensions: 1024, + }, + }); + expect(createBedrockEmbeddingProviderMock).toHaveBeenCalledOnce(); + }); + + it("lets the auto-select loop skip bedrock when credentials are unavailable", async () => { + hasAwsCredentialsMock.mockResolvedValue(false); + + let thrown: unknown; + try { + await bedrockMemoryEmbeddingProviderAdapter.create(defaultCreateOptions()); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(Error); + expect(bedrockMemoryEmbeddingProviderAdapter.shouldContinueAutoSelection?.(thrown)).toBe(true); + }); +}); diff --git a/extensions/amazon-bedrock/memory-embedding-adapter.ts b/extensions/amazon-bedrock/memory-embedding-adapter.ts index 5b003f72116..151b97590d8 100644 --- a/extensions/amazon-bedrock/memory-embedding-adapter.ts +++ b/extensions/amazon-bedrock/memory-embedding-adapter.ts @@ -5,6 +5,7 @@ import { import { createBedrockEmbeddingProvider, DEFAULT_BEDROCK_EMBEDDING_MODEL, + hasAwsCredentials, } from "./embedding-provider.js"; export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = { @@ -16,6 +17,15 @@ export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapt allowExplicitWhenConfiguredAuto: true, shouldContinueAutoSelection: isMissingEmbeddingApiKeyError, create: async (options) => { + if (!(await hasAwsCredentials())) { + throw new Error( + 'No API key found for provider "bedrock". ' + + "AWS credentials are not available. " + + "Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, AWS_PROFILE, or AWS_BEARER_TOKEN_BEDROCK, " + + "configure an EC2/ECS/EKS role, " + + "or set agents.defaults.memorySearch.provider to another provider.", + ); + } const { provider, client } = await createBedrockEmbeddingProvider({ ...options, provider: "bedrock", diff --git a/extensions/memory-core/src/memory/embeddings.test.ts b/extensions/memory-core/src/memory/embeddings.test.ts new file mode 100644 index 00000000000..7d04fe89ef9 --- /dev/null +++ b/extensions/memory-core/src/memory/embeddings.test.ts @@ -0,0 +1,105 @@ +import type { MemoryEmbeddingProviderAdapter } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../../src/config/types.openclaw.js"; +import { + clearMemoryEmbeddingProviders, + registerMemoryEmbeddingProvider, +} from "../../../../src/plugins/memory-embedding-providers.js"; +import { createEmbeddingProvider } from "./embeddings.js"; + +const missingBedrockCredentialsError = new Error( + 'No API key found for provider "bedrock". AWS credentials are not available.', +); + +function createOptions(provider: string) { + return { + config: { + plugins: { + deny: [ + "amazon-bedrock", + "github-copilot", + "google", + "lmstudio", + "memory-core", + "mistral", + "ollama", + "openai", + "voyage", + ], + }, + } as OpenClawConfig, + agentDir: "/tmp/openclaw-agent", + provider, + fallback: "none", + model: "", + }; +} + +function createMissingCredentialsAdapter( + overrides: Partial = {}, +): MemoryEmbeddingProviderAdapter { + return { + id: "bedrock", + transport: "remote", + autoSelectPriority: 60, + formatSetupError: (err) => (err instanceof Error ? err.message : String(err)), + shouldContinueAutoSelection: (err) => + err instanceof Error && err.message.includes("No API key found for provider"), + create: async () => { + throw missingBedrockCredentialsError; + }, + ...overrides, + }; +} + +describe("createEmbeddingProvider", () => { + beforeEach(() => { + clearMemoryEmbeddingProviders(); + }); + + afterEach(() => { + clearMemoryEmbeddingProviders(); + }); + + it("returns no provider in auto mode when all candidates are skippable setup failures", async () => { + registerMemoryEmbeddingProvider(createMissingCredentialsAdapter()); + + const result = await createEmbeddingProvider(createOptions("auto")); + + expect(result).toEqual({ + provider: null, + requestedProvider: "auto", + providerUnavailableReason: missingBedrockCredentialsError.message, + }); + }); + + it("still throws missing credentials for an explicit provider request", async () => { + registerMemoryEmbeddingProvider(createMissingCredentialsAdapter()); + + await expect(createEmbeddingProvider(createOptions("bedrock"))).rejects.toThrow( + missingBedrockCredentialsError.message, + ); + }); + + it("continues auto-selection after a skippable setup failure", async () => { + registerMemoryEmbeddingProvider(createMissingCredentialsAdapter({ autoSelectPriority: 10 })); + registerMemoryEmbeddingProvider({ + id: "openai", + transport: "remote", + autoSelectPriority: 20, + create: async () => ({ + provider: { + id: "openai", + model: "text-embedding-3-small", + embedQuery: async () => [1], + embedBatch: async (texts) => texts.map(() => [1]), + }, + }), + }); + + const result = await createEmbeddingProvider(createOptions("auto")); + + expect(result.provider?.id).toBe("openai"); + expect(result.requestedProvider).toBe("auto"); + }); +});