diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d0a134850b..40df1cbf857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - `/context detail` now compares the tracked prompt estimate with cached context usage and surfaces untracked provider/runtime overhead when present. (#28391) thanks @ImLukeF. - Gateway/session reset: emit the typed `before_reset` hook for gateway `/new` and `/reset`, preserving reset-hook behavior even when the previous transcript has already been archived. (#53872) thanks @VACInc - Plugins/commands: pass the active host `sessionKey` into plugin command contexts, and include `sessionId` when it is already available from the active session entry, so bundled and third-party commands can resolve the current conversation reliably. (#59044) Thanks @jalehman. +- Agents/auth: honor `models.providers.*.authHeader` for pi embedded runner model requests by injecting `Authorization: Bearer ` when requested. (#54390) Thanks @lndyzwdxhs. ## 2026.4.2 diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 11a25a13b0f..fe6bdf877a6 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -10,6 +10,7 @@ import { NON_ENV_SECRETREF_MARKER, } from "./model-auth-markers.js"; import { + applyAuthHeaderOverride, applyLocalNoAuthHeaderOverride, hasUsableCustomProviderApiKey, requireApiKey, @@ -709,3 +710,176 @@ describe("applyLocalNoAuthHeaderOverride", () => { expect(capturedXTest).toBe("1"); }); }); + +describe("applyAuthHeaderOverride", () => { + const baseModel: Model<"openai-completions"> = { + id: "gemini-3.1-flash-lite-preview", + name: "gemini-3.1-flash-lite-preview", + api: "openai-completions" as const, + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + reasoning: false, + input: ["text"] as ("text" | "image")[], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 8192, + }; + + it("injects Authorization Bearer header when authHeader is true", () => { + const result = applyAuthHeaderOverride( + baseModel, + { apiKey: "test-api-key", source: "env", mode: "api-key" }, + { + models: { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + api: "openai-completions", + authHeader: true, + models: [], + }, + }, + }, + }, + ); + + expect(result.headers).toEqual({ Authorization: "Bearer test-api-key" }); + }); + + it("preserves existing model headers when injecting Authorization", () => { + const result = applyAuthHeaderOverride( + { ...baseModel, headers: { "X-Custom": "value" } }, + { apiKey: "test-api-key", source: "env", mode: "api-key" }, + { + models: { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + api: "openai-completions", + authHeader: true, + models: [], + }, + }, + }, + }, + ); + + expect(result.headers).toEqual({ + "X-Custom": "value", + Authorization: "Bearer test-api-key", + }); + }); + + it("returns model unchanged when authHeader is not set", () => { + const result = applyAuthHeaderOverride( + baseModel, + { apiKey: "test-api-key", source: "env", mode: "api-key" }, + { + models: { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + api: "openai-completions", + models: [], + }, + }, + }, + }, + ); + + expect(result).toBe(baseModel); + }); + + it("returns model unchanged when authHeader is false", () => { + const result = applyAuthHeaderOverride( + baseModel, + { apiKey: "test-api-key", source: "env", mode: "api-key" }, + { + models: { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + api: "openai-completions", + authHeader: false, + models: [], + }, + }, + }, + }, + ); + + expect(result).toBe(baseModel); + }); + + it("returns model unchanged when no API key is available", () => { + const result = applyAuthHeaderOverride(baseModel, null, { + models: { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + api: "openai-completions", + authHeader: true, + models: [], + }, + }, + }, + }); + + expect(result).toBe(baseModel); + }); + + it("returns model unchanged when provider config is missing", () => { + const result = applyAuthHeaderOverride( + baseModel, + { apiKey: "test-api-key", source: "env", mode: "api-key" }, + undefined, + ); + + expect(result).toBe(baseModel); + }); + + it("rejects synthetic marker API keys", () => { + const result = applyAuthHeaderOverride( + baseModel, + { apiKey: CUSTOM_LOCAL_AUTH_MARKER, source: "synthetic", mode: "api-key" }, + { + models: { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + api: "openai-completions", + authHeader: true, + models: [], + }, + }, + }, + }, + ); + + expect(result).toBe(baseModel); + }); + + it("strips existing authorization header case-insensitively before injection", () => { + const result = applyAuthHeaderOverride( + { ...baseModel, headers: { authorization: "old-value", "X-Custom": "keep" } }, + { apiKey: "test-api-key", source: "env", mode: "api-key" }, + { + models: { + providers: { + google: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + api: "openai-completions", + authHeader: true, + models: [], + }, + }, + }, + }, + ); + + expect(result.headers).toEqual({ + "X-Custom": "keep", + Authorization: "Bearer test-api-key", + }); + }); +}); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 75b22bc02e4..74aa56a48e7 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -586,3 +586,48 @@ export function applyLocalNoAuthHeaderOverride>( headers, }; } + +/** + * When the provider config sets `authHeader: true`, inject an explicit + * `Authorization: Bearer ` header into the model so downstream SDKs + * (e.g. `@google/genai`) send credentials via the standard HTTP Authorization + * header instead of vendor-specific headers like `x-goog-api-key`. + * + * This is a no-op when `authHeader` is not `true`, when no API key is + * available, or when the API key is a synthetic marker (e.g. local-server + * placeholders) rather than a real credential. + */ +export function applyAuthHeaderOverride>( + model: T, + auth: ResolvedProviderAuth | null | undefined, + cfg: OpenClawConfig | undefined, +): T { + if (!auth?.apiKey) { + return model; + } + // Reject synthetic marker values that are not real credentials. + if (isNonSecretApiKeyMarker(auth.apiKey)) { + return model; + } + const providerConfig = resolveProviderConfig(cfg, model.provider); + if (!providerConfig?.authHeader) { + return model; + } + + // Strip any existing authorization header (case-insensitive) before + // injecting the canonical one so we don't produce a comma-joined value. + const headers: Record = {}; + if (model.headers) { + for (const [key, value] of Object.entries(model.headers)) { + if (key.toLowerCase() !== "authorization") { + headers[key] = value; + } + } + } + headers.Authorization = `Bearer ${auth.apiKey}`; + + return { + ...model, + headers, + }; +} diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index 70e76ec8fef..b880f5759f7 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -259,6 +259,7 @@ export async function loadCompactHooksHarness(): Promise<{ })); vi.doMock("../model-auth.js", () => ({ + applyAuthHeaderOverride: vi.fn((model: unknown) => model), applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })), resolveModelAuthMode: vi.fn(() => "env"), diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index cb26bbd13dd..1a24e85e0e4 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -44,6 +44,7 @@ import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../d import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { resolveOpenClawDocsPath } from "../docs-path.js"; import { + applyAuthHeaderOverride, applyLocalNoAuthHeaderOverride, getApiKeyForModel, resolveModelAuthMode, @@ -328,6 +329,7 @@ export async function compactEmbeddedPiSessionDirect( } let runtimeModel = model; let apiKeyInfo: Awaited> | null = null; + let hasRuntimeAuthExchange = false; try { apiKeyInfo = await getApiKeyForModel({ model: runtimeModel, @@ -365,6 +367,7 @@ export async function compactEmbeddedPiSessionDirect( runtimeModel = { ...runtimeModel, baseUrl: preparedAuth.baseUrl }; } const runtimeApiKey = preparedAuth?.apiKey ?? apiKeyInfo.apiKey; + hasRuntimeAuthExchange = Boolean(preparedAuth?.apiKey); if (!runtimeApiKey) { throw new Error(`Provider "${runtimeModel.provider}" runtime auth returned no apiKey.`); } @@ -439,11 +442,18 @@ export async function compactEmbeddedPiSessionDirect( modelContextWindow: runtimeModel.contextWindow, defaultTokens: DEFAULT_CONTEXT_TOKENS, }); - const effectiveModel = applyLocalNoAuthHeaderOverride( - ctxInfo.tokens < (runtimeModel.contextWindow ?? Infinity) - ? { ...runtimeModel, contextWindow: ctxInfo.tokens } - : runtimeModel, - apiKeyInfo, + const effectiveModel = applyAuthHeaderOverride( + applyLocalNoAuthHeaderOverride( + ctxInfo.tokens < (runtimeModel.contextWindow ?? Infinity) + ? { ...runtimeModel, contextWindow: ctxInfo.tokens } + : runtimeModel, + apiKeyInfo, + ), + // Skip header injection when runtime auth exchange produced a + // different credential — the SDK reads the exchanged token from + // authStorage automatically. + hasRuntimeAuthExchange ? null : apiKeyInfo, + params.config, ); const runAbortController = new AbortController(); diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index da77bb2e6ac..a6c3ada20db 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -35,6 +35,7 @@ type InlineProviderConfig = { api?: ModelDefinitionConfig["api"]; models?: ModelDefinitionConfig[]; headers?: unknown; + authHeader?: boolean; }; type ProviderRuntimeHooks = { diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts index 001fe728330..d7513450f49 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts @@ -412,6 +412,7 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ })); vi.doMock("../model-auth.js", () => ({ + applyAuthHeaderOverride: vi.fn((model: unknown) => model), applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model), ensureAuthProfileStore: vi.fn(() => ({})), getApiKeyForModel: mockedGetApiKeyForModel, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 1b9963fac2f..f5162fb623b 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -31,6 +31,7 @@ import { consumeLiveSessionModelSwitch, } from "../live-model-switch.js"; import { + applyAuthHeaderOverride, applyLocalNoAuthHeaderOverride, ensureAuthProfileStore, type ResolvedProviderAuth, @@ -518,7 +519,15 @@ export async function runEmbeddedPiAgent( disableTools: params.disableTools, provider, modelId, - model: applyLocalNoAuthHeaderOverride(effectiveModel, apiKeyInfo), + model: applyAuthHeaderOverride( + applyLocalNoAuthHeaderOverride(effectiveModel, apiKeyInfo), + // When runtime auth exchange produced a different credential + // (runtimeAuthState is set), the exchanged token lives in + // authStorage and the SDK will pick it up automatically. + // Skip header injection to avoid leaking the pre-exchange key. + runtimeAuthState ? null : apiKeyInfo, + params.config, + ), authProfileId: lastProfileId, authProfileIdSource: lockedProfileId ? "user" : "auto", authStorage,