From d5b2749fd8131bbd1a3bfef9ce55dc8db44ceec9 Mon Sep 17 00:00:00 2001 From: Wynne668 Date: Sun, 19 Jul 2026 05:33:54 +0800 Subject: [PATCH] fix(ai): skip blank environment credentials during provider auth (#109691) * fix(ai): ignore blank environment API keys * test(ai): avoid credential-shaped fixture assignments Co-authored-by: ZengWen-DT --------- Co-authored-by: Peter Steinberger --- packages/ai/src/env-api-keys.ts | 2 +- .../providers/azure-openai-responses.test.ts | 36 ++++++- .../src/providers/azure-openai-responses.ts | 11 +-- src/llm/env-api-keys.test.ts | 95 +++++++++++++++++++ 4 files changed, 135 insertions(+), 9 deletions(-) diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index c76db410c8f9..f07d6cf6e71c 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -109,7 +109,7 @@ function getProcEnv(key: string): string | undefined { } function getEnvValue(key: string): string | undefined { - return getProcessEnv()?.[key] || getProcEnv(key); + return (getProcessEnv()?.[key] || getProcEnv(key))?.trim() || undefined; } let cachedVertexAdcCredentialsExists: true | null = null; diff --git a/packages/ai/src/providers/azure-openai-responses.test.ts b/packages/ai/src/providers/azure-openai-responses.test.ts index 7fc13ae8ccae..184c127410e1 100644 --- a/packages/ai/src/providers/azure-openai-responses.test.ts +++ b/packages/ai/src/providers/azure-openai-responses.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import { configureAiTransportHost } from "../host.js"; import type { Context, Model } from "../types.js"; -import { streamSimpleAzureOpenAIResponses, testing } from "./azure-openai-responses.js"; +import { + streamAzureOpenAIResponses, + streamSimpleAzureOpenAIResponses, + testing, +} from "./azure-openai-responses.js"; const azureResponsesModel = { id: "gpt-5.5", @@ -105,6 +109,36 @@ describe("azure-openai-responses", () => { } }); + it("rejects a blank environment API key before sending a request", async () => { + const previousApiKey = process.env.AZURE_OPENAI_API_KEY; + let fetchCalled = false; + configureAiTransportHost({ + buildModelFetch: () => async () => { + fetchCalled = true; + return Response.json({ error: { message: "captured" } }, { status: 400 }); + }, + }); + process.env.AZURE_OPENAI_API_KEY = " "; + try { + const result = await streamAzureOpenAIResponses( + { ...azureResponsesModel, provider: "azure-openai-responses" }, + context, + ).result(); + + expect(fetchCalled).toBe(false); + expect(result.errorMessage).toBe( + "Azure OpenAI API key is required. Set AZURE_OPENAI_API_KEY environment variable or pass it as an argument.", + ); + } finally { + configureAiTransportHost({}); + if (previousApiKey === undefined) { + delete process.env.AZURE_OPENAI_API_KEY; + } else { + process.env.AZURE_OPENAI_API_KEY = previousApiKey; + } + } + }); + it("disables response storage and clamps small output limits", async () => { let sentParams: { max_output_tokens?: unknown; store?: unknown } | undefined; const hostFetch: typeof fetch = async (input, init) => { diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 41a77cac04a7..5649688e6bf8 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -183,14 +183,11 @@ function createClient( apiKeyInput: string, options?: AzureOpenAIResponsesOptions, ) { - let apiKey = apiKeyInput; + const apiKey = apiKeyInput.trim(); if (!apiKey) { - if (!process.env.AZURE_OPENAI_API_KEY) { - throw new Error( - "Azure OpenAI API key is required. Set AZURE_OPENAI_API_KEY environment variable or pass it as an argument.", - ); - } - apiKey = process.env.AZURE_OPENAI_API_KEY; + throw new Error( + "Azure OpenAI API key is required. Set AZURE_OPENAI_API_KEY environment variable or pass it as an argument.", + ); } const headers = { ...model.headers }; diff --git a/src/llm/env-api-keys.test.ts b/src/llm/env-api-keys.test.ts index a21cfe9159aa..c300a500c086 100644 --- a/src/llm/env-api-keys.test.ts +++ b/src/llm/env-api-keys.test.ts @@ -6,12 +6,22 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { captureEnv, withEnvAsync } from "../test-utils/env.js"; const envKeys = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_OAUTH_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_PROFILE", + "AWS_SECRET_ACCESS_KEY", + "AWS_WEB_IDENTITY_TOKEN_FILE", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION", "GOOGLE_CLOUD_PROJECT", "KIMI_API_KEY", "KIMICODE_API_KEY", "MOONSHOT_API_KEY", + "OPENAI_API_KEY", ] as const; const originalEnv = captureEnv([...envKeys]); @@ -87,6 +97,72 @@ describe("getEnvApiKey", () => { }); }); + it("skips blank API keys and trims the selected fallback", async () => { + const env = { + ANTHROPIC_OAUTH_TOKEN: " ", + OPENAI_API_KEY: " \t ", + } as NodeJS.ProcessEnv; + Reflect.set(env, "ANTHROPIC_API_KEY", " test-anthropic-key "); + + await withEnvAsync(env, async () => { + vi.resetModules(); + const { findEnvKeys, getEnvApiKey } = await import("@openclaw/ai/internal/runtime"); + + expect(findEnvKeys("anthropic")).toEqual(["ANTHROPIC_API_KEY"]); + expect(getEnvApiKey("anthropic")).toBe("test-anthropic-key"); + expect(findEnvKeys("openai")).toBeUndefined(); + expect(getEnvApiKey("openai")).toBeUndefined(); + }); + }); + + it("does not report blank AWS credential markers as authentication", async () => { + await withEnvAsync( + { + AWS_ACCESS_KEY_ID: " ", + AWS_SECRET_ACCESS_KEY: "\t", + AWS_PROFILE: " ", + AWS_BEARER_TOKEN_BEDROCK: "\n", + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: " ", + AWS_CONTAINER_CREDENTIALS_FULL_URI: " ", + AWS_WEB_IDENTITY_TOKEN_FILE: " ", + }, + async () => { + vi.resetModules(); + const { getEnvApiKey } = await import("@openclaw/ai/internal/runtime"); + + expect(getEnvApiKey("amazon-bedrock")).toBeUndefined(); + }, + ); + }); + + it("keeps non-blank AWS profile authentication available", async () => { + await withEnvAsync({ AWS_PROFILE: " production " }, async () => { + vi.resetModules(); + const { getEnvApiKey } = await import("@openclaw/ai/internal/runtime"); + + expect(getEnvApiKey("amazon-bedrock")).toBe(""); + }); + }); + + it("requires non-blank Google Vertex project and location markers", async () => { + const dir = await mkdtemp(join(tmpdir(), "openclaw-vertex-adc-")); + tempDirs.push(dir); + const credentialsPath = join(dir, "application_default_credentials.json"); + await writeFile(credentialsPath, "{}", "utf-8"); + const env = { + GOOGLE_CLOUD_LOCATION: " ", + GOOGLE_CLOUD_PROJECT: "\t", + } as NodeJS.ProcessEnv; + Reflect.set(env, "GOOGLE_APPLICATION_CREDENTIALS", credentialsPath); + + await withEnvAsync(env, async () => { + vi.resetModules(); + const { getEnvApiKey } = await import("@openclaw/ai/internal/runtime"); + + expect(getEnvApiKey("google-vertex")).toBeUndefined(); + }); + }); + it("does not cache missing Google Vertex ADC credentials", async () => { const dir = await mkdtemp(join(tmpdir(), "openclaw-vertex-adc-")); tempDirs.push(dir); @@ -107,4 +183,23 @@ describe("getEnvApiKey", () => { }, ); }); + + it("trims the Google Vertex credentials path before checking it", async () => { + const dir = await mkdtemp(join(tmpdir(), "openclaw-vertex-adc-")); + tempDirs.push(dir); + const credentialsPath = join(dir, "application_default_credentials.json"); + await writeFile(credentialsPath, "{}", "utf-8"); + const env = { + GOOGLE_CLOUD_LOCATION: "us-central1", + GOOGLE_CLOUD_PROJECT: "vertex-project", + } as NodeJS.ProcessEnv; + Reflect.set(env, "GOOGLE_APPLICATION_CREDENTIALS", ` ${credentialsPath} `); + + await withEnvAsync(env, async () => { + vi.resetModules(); + const { getEnvApiKey } = await import("@openclaw/ai/internal/runtime"); + + expect(getEnvApiKey("google-vertex")).toBe(""); + }); + }); });