fix(model): propagate custom provider headers to model objects (#27490)

Merged via squash.

Prepared head SHA: e4183b398f
Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com>
Co-authored-by: shakkernerd <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
This commit is contained in:
Sid
2026-03-05 00:02:29 +08:00
committed by GitHub
parent dc8253a84d
commit 3fa43ec221
3 changed files with 153 additions and 0 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Models/custom provider headers: propagate `models.providers.<name>.headers` across inline, fallback, and registry-found model resolution so header-authenticated proxies consistently receive configured request headers. (#27490) thanks @Sid-Qin.
- Slack/system-event session routing: resolve reaction/member/pin/interaction system-event session keys through channel/account bindings (with sender-aware DM routing) so inbound Slack events target the correct agent session in multi-account setups instead of defaulting to `agent:main`. (#34045) Thanks @paulomcg, @daht-mad and @vincentkoc.
- Gateway/HTTP tools invoke media compatibility: preserve raw media payload access for direct `/tools/invoke` clients by allowing media `nodes` invoke commands only in HTTP tool context, while keeping agent-context media invoke blocking to prevent base64 prompt bloat. (#34365) Thanks @obviyus.
- Agents/Nodes media outputs: add dedicated `photos_latest` action handling, block media-returning `nodes invoke` commands, keep metadata-only `camera.list` invoke allowed, and normalize empty `photos_latest` results to a consistent response shape to prevent base64 context bloat. (#34332) Thanks @obviyus.

View File

@@ -149,6 +149,36 @@ describe("buildInlineProviderModels", () => {
name: "claude-opus-4.5",
});
});
it("merges provider-level headers into inline models", () => {
const providers: Parameters<typeof buildInlineProviderModels>[0] = {
proxy: {
baseUrl: "https://proxy.example.com",
api: "anthropic-messages",
headers: { "User-Agent": "custom-agent/1.0" },
models: [makeModel("claude-sonnet-4-6")],
},
};
const result = buildInlineProviderModels(providers);
expect(result).toHaveLength(1);
expect(result[0].headers).toEqual({ "User-Agent": "custom-agent/1.0" });
});
it("omits headers when neither provider nor model specifies them", () => {
const providers: Parameters<typeof buildInlineProviderModels>[0] = {
plain: {
baseUrl: "http://localhost:8000",
models: [makeModel("some-model")],
},
};
const result = buildInlineProviderModels(providers);
expect(result).toHaveLength(1);
expect(result[0].headers).toBeUndefined();
});
});
describe("resolveModel", () => {
@@ -171,6 +201,28 @@ describe("resolveModel", () => {
expect(result.model?.id).toBe("missing-model");
});
it("includes provider headers in provider fallback model", () => {
const cfg = {
models: {
providers: {
custom: {
baseUrl: "http://localhost:9000",
headers: { "X-Custom-Auth": "token-123" },
models: [makeModel("listed-model")],
},
},
},
} as OpenClawConfig;
// Requesting a non-listed model forces the providerCfg fallback branch.
const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
"X-Custom-Auth": "token-123",
});
});
it("prefers matching configured model metadata for fallback token limits", () => {
const cfg = {
models: {
@@ -379,4 +431,80 @@ describe("resolveModel", () => {
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: google-antigravity/some-model");
});
it("applies provider baseUrl override to registry-found models", () => {
mockDiscoveredModel({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
templateModel: buildForwardCompatTemplate({
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
provider: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
}),
});
const cfg = {
models: {
providers: {
anthropic: {
baseUrl: "https://my-proxy.example.com",
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModel("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect(result.model?.baseUrl).toBe("https://my-proxy.example.com");
});
it("applies provider headers override to registry-found models", () => {
mockDiscoveredModel({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
templateModel: buildForwardCompatTemplate({
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
provider: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
}),
});
const cfg = {
models: {
providers: {
anthropic: {
headers: { "X-Custom-Auth": "token-123" },
},
},
},
} as unknown as OpenClawConfig;
const result = resolveModel("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
expect(result.error).toBeUndefined();
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
"X-Custom-Auth": "token-123",
});
});
it("does not override when no provider config exists", () => {
mockDiscoveredModel({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
templateModel: buildForwardCompatTemplate({
id: "claude-sonnet-4-5",
name: "Claude Sonnet 4.5",
provider: "anthropic",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
}),
});
const result = resolveModel("anthropic", "claude-sonnet-4-5", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model?.baseUrl).toBe("https://api.anthropic.com");
});
});

View File

@@ -13,11 +13,13 @@ import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
type InlineModelEntry = ModelDefinitionConfig & {
provider: string;
baseUrl?: string;
headers?: Record<string, string>;
};
type InlineProviderConfig = {
baseUrl?: string;
api?: ModelDefinitionConfig["api"];
models?: ModelDefinitionConfig[];
headers?: Record<string, string>;
};
export { buildModelAliasLines };
@@ -35,6 +37,10 @@ export function buildInlineProviderModels(
provider: trimmed,
baseUrl: entry?.baseUrl,
api: model.api ?? entry?.api,
headers:
entry?.headers || (model as InlineModelEntry).headers
? { ...entry?.headers, ...(model as InlineModelEntry).headers }
: undefined,
}));
});
}
@@ -114,6 +120,10 @@ export function resolveModel(
configuredModel?.maxTokens ??
providerCfg?.models?.[0]?.maxTokens ??
DEFAULT_CONTEXT_TOKENS,
headers:
providerCfg?.headers || configuredModel?.headers
? { ...providerCfg?.headers, ...configuredModel?.headers }
: undefined,
} as Model<Api>);
return { model: fallbackModel, authStorage, modelRegistry };
}
@@ -123,6 +133,20 @@ export function resolveModel(
modelRegistry,
};
}
const providerOverride = cfg?.models?.providers?.[provider] as InlineProviderConfig | undefined;
if (providerOverride?.baseUrl || providerOverride?.headers) {
const overridden: Model<Api> & { headers?: Record<string, string> } = { ...model };
if (providerOverride.baseUrl) {
overridden.baseUrl = providerOverride.baseUrl;
}
if (providerOverride.headers) {
overridden.headers = {
...(model as Model<Api> & { headers?: Record<string, string> }).headers,
...providerOverride.headers,
};
}
return { model: normalizeModelCompat(overridden), authStorage, modelRegistry };
}
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
}