fix: honor authHeader provider config by injecting Authorization Bear… (#54390)

Merged via squash.

Prepared head SHA: 9889615571
Co-authored-by: lndyzwdxhs <16411017+lndyzwdxhs@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
9ra55
2026-04-02 04:18:37 +08:00
committed by GitHub
parent 90eb5b073f
commit 5cf254a5f7
8 changed files with 248 additions and 6 deletions

View File

@@ -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",
});
});
});

View File

@@ -586,3 +586,48 @@ export function applyLocalNoAuthHeaderOverride<T extends Model<Api>>(
headers,
};
}
/**
* When the provider config sets `authHeader: true`, inject an explicit
* `Authorization: Bearer <apiKey>` 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<T extends Model<Api>>(
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<string, string> = {};
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,
};
}

View File

@@ -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"),

View File

@@ -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<ReturnType<typeof getApiKeyForModel>> | 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();

View File

@@ -35,6 +35,7 @@ type InlineProviderConfig = {
api?: ModelDefinitionConfig["api"];
models?: ModelDefinitionConfig[];
headers?: unknown;
authHeader?: boolean;
};
type ProviderRuntimeHooks = {

View File

@@ -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,

View File

@@ -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,