mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 13:22:14 +00:00
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:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -35,6 +35,7 @@ type InlineProviderConfig = {
|
||||
api?: ModelDefinitionConfig["api"];
|
||||
models?: ModelDefinitionConfig[];
|
||||
headers?: unknown;
|
||||
authHeader?: boolean;
|
||||
};
|
||||
|
||||
type ProviderRuntimeHooks = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user