feat(providers): add model request transport overrides (#60200)

* feat(providers): add model request transport overrides

* chore(providers): finalize request override follow-ups

* fix(providers): narrow model request overrides
This commit is contained in:
Vincent Koc
2026-04-03 19:00:06 +09:00
committed by GitHub
parent 55e43cbc7f
commit 61f13173c2
25 changed files with 782 additions and 99 deletions

View File

@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
- Channels/context visibility: add configurable `contextVisibility` per channel (`all`, `allowlist`, `allowlist_quote`) so supplemental quote, thread, and fetched history context can be filtered by sender allowlists instead of always passing through as received.
- Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras.
- Providers/StepFun: add the bundled StepFun provider plugin with standard and Step Plan endpoints, China/global onboarding choices, `step-3.5-flash` on both catalogs, and `step-3.5-flash-2603` currently exposed on Step Plan. (#60032) Thanks @hengm3467.
- Providers/config: add `models.providers.*.request` overrides for headers and auth on model-provider paths, and full request transport overrides for media provider HTTP paths.
### Fixes
@@ -22,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Telegram/replies: preserve explicit topic targets when `replyTo` is present while still inheriting the current topic for same-chat replies without an explicit topic. (#59634) Thanks @dashhuang.
- Telegram/native commands: clean up metadata-driven progress placeholders when replies fall back, edits fail, or local exec approval prompts are suppressed. (#59300) Thanks @jalehman.
- Media/request overrides: resolve shared and capability-filtered media request SecretRefs correctly and expose media transport override fields to schema-driven config consumers. (#59848) Thanks @vincentkoc.
- Providers/request overrides: stop advertising unsupported proxy and TLS transport settings on `models.providers.*.request`, and fail closed if unvalidated config tries to route LLM model-provider traffic through dead transport fields. Thanks @vincentkoc.
- Matrix: allow secret-storage recreation during automatic repair bootstrap so clients that lose their recovery key can recover and persist new cross-signing keys. (#59846) Thanks @al3mart.
- Matrix/crypto persistence: capture and write the IndexedDB snapshot while holding the snapshot file lock so concurrent gateway and CLI persists cannot overwrite newer crypto state. (#59851) Thanks @al3mart.
- Ollama/auth: prefer real cloud auth over local marker during model auth resolution so cloud-backed Ollama auth does not get shadowed by stale local-only markers.
@@ -69,6 +71,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Providers/transport policy: centralize request auth, proxy, TLS, and header shaping across shared HTTP, stream, and websocket paths, block insecure TLS/runtime transport overrides, and keep proxy-hop TLS separate from target mTLS settings. (#59682) Thanks @vincentkoc.
- Providers/OpenRouter: gate documented OpenRouter attribution to native OpenRouter endpoints or the default route so custom proxy base URLs do not inherit OpenRouter request headers.
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. (#59644) Thanks @vincentkoc.
- Providers/streaming headers: centralize default and attribution header merging across OpenAI websocket, embedded-runner, and proxy stream paths so provider-specific headers stay consistent and caller overrides only win where intended. (#59542) Thanks @vincentkoc.
- Providers/media HTTP: centralize base URL normalization, default auth/header injection, and explicit header override handling across shared OpenAI-compatible audio, Deepgram audio, Gemini media/image, and Moonshot video request paths. (#59469) Thanks @vincentkoc.

View File

@@ -24,6 +24,9 @@ Scope intent:
- `models.providers.*.apiKey`
- `models.providers.*.headers.*`
- `models.providers.*.request.auth.token`
- `models.providers.*.request.auth.value`
- `models.providers.*.request.headers.*`
- `skills.entries.*.apiKey`
- `agents.defaults.memorySearch.remote.apiKey`
- `agents.list[].memorySearch.remote.apiKey`

View File

@@ -440,6 +440,27 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.auth.token",
"configFile": "openclaw.json",
"path": "models.providers.*.request.auth.token",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.auth.value",
"configFile": "openclaw.json",
"path": "models.providers.*.request.auth.value",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "models.providers.*.request.headers.*",
"configFile": "openclaw.json",
"path": "models.providers.*.request.headers.*",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "plugins.entries.brave.config.webSearch.apiKey",
"configFile": "openclaw.json",

View File

@@ -212,6 +212,44 @@ describe("buildInlineProviderModels", () => {
expect(result[0].headers).toEqual({ "User-Agent": "custom-agent/1.0" });
});
it("merges provider request headers into inline models", () => {
const providers: Parameters<typeof buildInlineProviderModels>[0] = {
proxy: {
baseUrl: "https://proxy.example.com/v1",
api: "openai-completions",
request: {
headers: {
"X-Tenant": "acme",
},
},
models: [makeModel("proxy-model")],
},
};
const result = buildInlineProviderModels(providers);
expect(result).toHaveLength(1);
expect(result[0].headers).toEqual({ "X-Tenant": "acme" });
});
it("rejects inline provider transport overrides that the llm model path cannot carry", () => {
expect(() =>
buildInlineProviderModels({
proxy: {
baseUrl: "https://proxy.example.com/v1",
api: "openai-completions",
request: {
proxy: {
mode: "explicit-proxy",
url: "http://proxy.internal:8443",
},
},
models: [makeModel("proxy-model")],
},
} as unknown as Parameters<typeof buildInlineProviderModels>[0]),
).toThrow(/models\.providers\.\*\.request only supports headers and auth overrides/i);
});
it("omits headers when neither provider nor model specifies them", () => {
const providers: Parameters<typeof buildInlineProviderModels>[0] = {
plain: {

View File

@@ -1,7 +1,7 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../../config/config.js";
import type { ModelDefinitionConfig } from "../../config/types.js";
import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.js";
import {
applyProviderResolvedModelCompatWithPlugins,
applyProviderResolvedTransportWithPlugin,
@@ -22,7 +22,10 @@ import {
shouldSuppressBuiltInModel,
} from "../model-suppression.js";
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
import { resolveProviderRequestConfig } from "../provider-request-config.js";
import {
resolveProviderRequestConfig,
sanitizeConfiguredModelProviderRequest,
} from "../provider-request-config.js";
import { normalizeResolvedProviderModel } from "./model.provider-normalization.js";
type InlineModelEntry = Omit<ModelDefinitionConfig, "api"> & {
@@ -37,6 +40,7 @@ type InlineProviderConfig = {
models?: ModelDefinitionConfig[];
headers?: unknown;
authHeader?: boolean;
request?: ModelProviderConfig["request"];
};
type ProviderRuntimeHooks = {
@@ -307,10 +311,17 @@ function applyConfiguredProviderOverrides(params: {
const providerHeaders = sanitizeModelHeaders(providerConfig.headers, {
stripSecretRefMarkers: true,
});
const providerRequest = sanitizeConfiguredModelProviderRequest(providerConfig.request);
const configuredHeaders = sanitizeModelHeaders(configuredModel?.headers, {
stripSecretRefMarkers: true,
});
if (!configuredModel && !providerConfig.baseUrl && !providerConfig.api && !providerHeaders) {
if (
!configuredModel &&
!providerConfig.baseUrl &&
!providerConfig.api &&
!providerHeaders &&
!providerRequest
) {
return {
...discoveredModel,
headers: discoveredHeaders,
@@ -340,6 +351,7 @@ function applyConfiguredProviderOverrides(params: {
providerHeaders,
modelHeaders: configuredHeaders,
authHeader: providerConfig.authHeader,
request: providerRequest,
capability: "llm",
transport: "stream",
});
@@ -368,6 +380,7 @@ export function buildInlineProviderModels(
const providerHeaders = sanitizeModelHeaders(entry?.headers, {
stripSecretRefMarkers: true,
});
const providerRequest = sanitizeConfiguredModelProviderRequest(entry?.request);
return (entry?.models ?? []).map((model) => {
const transport = resolveProviderTransport({
provider: trimmed,
@@ -384,6 +397,7 @@ export function buildInlineProviderModels(
providerHeaders,
modelHeaders,
authHeader: entry?.authHeader,
request: providerRequest,
capability: "llm",
transport: "stream",
});
@@ -528,6 +542,7 @@ function resolveConfiguredFallbackModel(params: {
const providerHeaders = sanitizeModelHeaders(providerConfig?.headers, {
stripSecretRefMarkers: true,
});
const providerRequest = sanitizeConfiguredModelProviderRequest(providerConfig?.request);
const modelHeaders = sanitizeModelHeaders(configuredModel?.headers, {
stripSecretRefMarkers: true,
});
@@ -548,6 +563,7 @@ function resolveConfiguredFallbackModel(params: {
providerHeaders,
modelHeaders,
authHeader: providerConfig?.authHeader,
request: providerRequest,
capability: "llm",
transport: "stream",
});

View File

@@ -222,7 +222,7 @@ describe("provider attribution", () => {
});
});
it("keeps documented OpenRouter attribution centralized while leaving host-gating deferred", () => {
it("gates documented OpenRouter attribution to known OpenRouter endpoints", () => {
expect(
resolveProviderRequestPolicy({
provider: "openrouter",
@@ -244,11 +244,7 @@ describe("provider attribution", () => {
transport: "stream",
capability: "llm",
}),
).toEqual({
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
"X-OpenRouter-Categories": "cli-agent",
});
).toBeUndefined();
});
it("models other provider families without enabling hidden attribution", () => {

View File

@@ -450,12 +450,11 @@ export function resolveProviderRequestPolicy(
) {
attributionProvider = "openai-codex";
} else if (provider === "openrouter" && policy?.enabledByDefault) {
// OpenRouter attribution is documented and intentionally remains
// provider-key-gated for this pass, including custom base URLs configured
// under the openrouter provider. The endpoint class is still surfaced so a
// later host-gating decision can reuse the same classifier without changing
// callers again.
attributionProvider = "openrouter";
// OpenRouter attribution is documented, but only apply it to known
// OpenRouter endpoints or the default (unset) baseUrl path.
if (endpointClass === "openrouter" || endpointClass === "default") {
attributionProvider = "openrouter";
}
}
const attributionHeaders = attributionProvider

View File

@@ -5,6 +5,7 @@ import {
resolveProviderRequestPolicyConfig,
resolveProviderRequestConfig,
resolveProviderRequestHeaders,
sanitizeConfiguredModelProviderRequest,
sanitizeConfiguredProviderRequest,
sanitizeRuntimeProviderRequestOverrides,
} from "./provider-request-config.js";
@@ -309,6 +310,20 @@ describe("provider request config", () => {
).toThrow(/request\.(headers\.X-Tenant|auth\.token|tls\.cert): unresolved SecretRef/i);
});
it("rejects model-provider transport overrides that the llm path cannot carry", () => {
expect(() =>
sanitizeConfiguredModelProviderRequest({
headers: {
"X-Tenant": "acme",
},
proxy: {
mode: "explicit-proxy",
url: "http://proxy.internal:8443",
},
}),
).toThrow(/models\.providers\.\*\.request only supports headers and auth overrides/i);
});
it("merges configured request overrides with later entries winning", () => {
expect(
mergeProviderRequestOverrides(

View File

@@ -1,6 +1,9 @@
import type { Api } from "@mariozechner/pi-ai";
import type { ModelDefinitionConfig } from "../config/types.js";
import type { ConfiguredProviderRequest } from "../config/types.provider-request.js";
import type {
ConfiguredModelProviderRequest,
ConfiguredProviderRequest,
} from "../config/types.provider-request.js";
import { assertSecretInputResolved } from "../config/types.secrets.js";
import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js";
import type {
@@ -297,6 +300,25 @@ export function sanitizeConfiguredProviderRequest(
};
}
const MODEL_PROVIDER_REQUEST_TRANSPORT_MESSAGE =
"models.providers.*.request only supports headers and auth overrides; proxy and TLS transport settings are not wired for model-provider requests";
export function sanitizeConfiguredModelProviderRequest(
request: ConfiguredModelProviderRequest | ConfiguredProviderRequest | undefined,
): ProviderRequestTransportOverrides | undefined {
const sanitized = sanitizeConfiguredProviderRequest(request);
if (!sanitized) {
return undefined;
}
if (sanitized.proxy || sanitized.tls) {
throw new Error(MODEL_PROVIDER_REQUEST_TRANSPORT_MESSAGE);
}
return {
...(sanitized.headers ? { headers: sanitized.headers } : {}),
...(sanitized.auth ? { auth: sanitized.auth } : {}),
};
}
export function mergeProviderRequestOverrides(
...overrides: Array<ProviderRequestTransportOverrides | undefined>
): ProviderRequestTransportOverrides | undefined {

View File

@@ -136,6 +136,59 @@ describe("config secret refs schema", () => {
expect(result.ok).toBe(true);
});
it("accepts model provider request secret refs for auth and headers", () => {
const result = validateConfigObjectRaw({
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
request: {
headers: {
"X-Tenant": { source: "env", provider: "default", id: "OPENAI_TENANT_HEADER" },
},
auth: {
mode: "authorization-bearer",
token: { source: "env", provider: "default", id: "OPENAI_PROVIDER_TOKEN" },
},
},
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
});
expect(result.ok).toBe(true);
});
it("rejects model provider request proxy and tls overrides", () => {
const result = validateConfigObjectRaw({
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
request: {
proxy: {
mode: "explicit-proxy",
url: "http://proxy.example:8080",
},
tls: {
cert: { source: "file", provider: "filemain", id: "/tls/provider-cert" },
},
},
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(
result.issues.some((issue) => issue.path.includes("models.providers.openai.request")),
).toBe(true);
}
});
it('accepts file refs with id "value" for singleValue mode providers', () => {
const result = validateConfigObjectRaw({
secrets: {

View File

@@ -10,5 +10,7 @@ export const redactSnapshotTestHints: ConfigUiHints = {
"gateway.auth.password": { sensitive: true },
"models.providers.*.apiKey": { sensitive: true },
"models.providers.*.baseUrl": { sensitive: true },
"models.providers.*.request.headers.*": { sensitive: true },
"models.providers.*.request.auth.token": { sensitive: true },
"skills.entries.*.env.GEMINI_API_KEY": { sensitive: true },
};

View File

@@ -282,6 +282,54 @@ describe("redactConfigSnapshot", () => {
);
});
it("redacts model provider request auth secrets from config snapshots", () => {
const hints = buildConfigSchema().uiHints;
const raw = `{
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [],
request: {
auth: {
mode: "authorization-bearer",
token: "provider-secret-token",
},
},
},
},
},
}`;
const snapshot = makeSnapshot(
{
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [],
request: {
auth: {
mode: "authorization-bearer",
token: "provider-secret-token",
},
},
},
},
},
},
raw,
);
const result = redactConfigSnapshot(snapshot, hints);
const cfg = result.config as typeof snapshot.config;
expect(cfg.models.providers.openai.request.auth.token).toBe(REDACTED_SENTINEL);
expect(result.raw).toContain(REDACTED_SENTINEL);
expect(result.raw).not.toContain("provider-secret-token");
const restored = restoreRedactedValues(result.config, snapshot.config, hints);
expect(restored.models.providers.openai.request.auth.token).toBe("provider-secret-token");
});
it("does not redact maxTokens-style fields", () => {
const snapshot = makeSnapshot({
maxTokens: 16384,

View File

@@ -1134,6 +1134,260 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
authHeader: {
type: "boolean",
},
request: {
type: "object",
properties: {
headers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
anyOf: [
{
type: "string",
},
{
oneOf: [
{
type: "object",
properties: {
source: {
type: "string",
const: "env",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "file",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "exec",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
},
auth: {
anyOf: [
{
type: "object",
properties: {
mode: {
type: "string",
const: "provider-default",
},
},
required: ["mode"],
additionalProperties: false,
},
{
type: "object",
properties: {
mode: {
type: "string",
const: "authorization-bearer",
},
token: {
anyOf: [
{
type: "string",
},
{
oneOf: [
{
type: "object",
properties: {
source: {
type: "string",
const: "env",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "file",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "exec",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
},
required: ["mode", "token"],
additionalProperties: false,
},
{
type: "object",
properties: {
mode: {
type: "string",
const: "header",
},
headerName: {
type: "string",
minLength: 1,
},
value: {
anyOf: [
{
type: "string",
},
{
oneOf: [
{
type: "object",
properties: {
source: {
type: "string",
const: "env",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "file",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "exec",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
prefix: {
type: "string",
},
},
required: ["mode", "headerName", "value"],
additionalProperties: false,
},
],
},
},
additionalProperties: false,
},
models: {
type: "array",
items: {
@@ -21352,6 +21606,48 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
help: "When true, credentials are sent via the HTTP Authorization header even if alternate auth is possible. Use this only when your provider or proxy explicitly requires Authorization forwarding.",
tags: ["models"],
},
"models.providers.*.request": {
label: "Model Provider Request Overrides",
help: "Optional request overrides for model-provider requests. Today this path supports header and auth overrides only; proxy and TLS transport settings are reserved for request paths that can carry them end to end.",
tags: ["models"],
},
"models.providers.*.request.headers": {
label: "Model Provider Request Headers",
help: "Extra headers merged into provider requests after default attribution and auth resolution.",
tags: ["models"],
},
"models.providers.*.request.auth": {
label: "Model Provider Request Auth Override",
help: "Override provider request authentication behavior for this provider.",
tags: ["models"],
},
"models.providers.*.request.auth.mode": {
label: "Model Provider Request Auth Mode",
help: 'Auth override mode: "provider-default", "authorization-bearer", or "header".',
tags: ["models"],
},
"models.providers.*.request.auth.token": {
label: "Model Provider Request Bearer Token",
help: "Bearer token used when auth mode is authorization-bearer.",
tags: ["security", "auth", "models"],
sensitive: true,
},
"models.providers.*.request.auth.headerName": {
label: "Model Provider Request Auth Header Name",
help: "Custom auth header name used when auth mode is header.",
tags: ["models"],
},
"models.providers.*.request.auth.value": {
label: "Model Provider Request Auth Header Value",
help: "Custom auth header value used when auth mode is header.",
tags: ["security", "models"],
sensitive: true,
},
"models.providers.*.request.auth.prefix": {
label: "Model Provider Request Auth Header Prefix",
help: "Optional prefix prepended to request.auth.value when auth mode is header.",
tags: ["models"],
},
"models.providers.*.models": {
label: "Model Provider Model List",
help: "Declared model list for a provider including identifiers, metadata, and optional compatibility/cost hints. Keep IDs exact to provider catalog values so selection and fallback resolve correctly.",
@@ -22975,6 +23271,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
sensitive: true,
tags: ["security", "models"],
},
"models.providers.*.request.headers.*": {
sensitive: true,
tags: ["security", "models"],
},
"agents.defaults.sandbox.ssh.identityData": {
sensitive: true,
tags: ["security", "storage"],

View File

@@ -113,6 +113,7 @@ const TARGET_KEYS = [
"models.mode",
"models.providers.*.auth",
"models.providers.*.authHeader",
"models.providers.*.request",
"gateway.reload.mode",
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
"gateway.controlUi.allowInsecureAuth",

View File

@@ -747,6 +747,22 @@ export const FIELD_HELP: Record<string, string> = {
"Static HTTP headers merged into provider requests for tenant routing, proxy auth, or custom gateway requirements. Use this sparingly and keep sensitive header values in secrets.",
"models.providers.*.authHeader":
"When true, credentials are sent via the HTTP Authorization header even if alternate auth is possible. Use this only when your provider or proxy explicitly requires Authorization forwarding.",
"models.providers.*.request":
"Optional request overrides for model-provider requests. Today this path supports header and auth overrides only; proxy and TLS transport settings are reserved for request paths that can carry them end to end.",
"models.providers.*.request.headers":
"Extra headers merged into provider requests after default attribution and auth resolution.",
"models.providers.*.request.auth":
"Override provider request authentication behavior for this provider.",
"models.providers.*.request.auth.mode":
'Auth override mode: "provider-default", "authorization-bearer", or "header".',
"models.providers.*.request.auth.token":
"Bearer token used when auth mode is authorization-bearer.",
"models.providers.*.request.auth.headerName":
"Custom auth header name used when auth mode is header.",
"models.providers.*.request.auth.value":
"Custom auth header value used when auth mode is header.",
"models.providers.*.request.auth.prefix":
"Optional prefix prepended to request.auth.value when auth mode is header.",
"models.providers.*.models":
"Declared model list for a provider including identifiers, metadata, and optional compatibility/cost hints. Keep IDs exact to provider catalog values so selection and fallback resolve correctly.",
"models.bedrockDiscovery":

View File

@@ -168,6 +168,7 @@ describe("mapSensitivePaths", () => {
expect(hints["agents.list[].memorySearch.remote.apiKey"]?.sensitive).toBe(true);
expect(hints["gateway.auth.token"]?.sensitive).toBe(true);
expect(hints["models.providers.*.headers.*"]?.sensitive).toBe(true);
expect(hints["models.providers.*.request.headers.*"]?.sensitive).toBe(true);
expect(hints["skills.entries.*.apiKey"]?.sensitive).toBe(true);
});
@@ -193,6 +194,7 @@ describe("collectMatchingSchemaPaths", () => {
expect(paths.has("mcp.servers.*.url")).toBe(true);
expect(paths.has("models.providers.*.baseUrl")).toBe(true);
expect(paths.has("models.providers.*.request.proxy.url")).toBe(false);
expect(paths.has("tools.media.audio.request.proxy.url")).toBe(true);
});
});

View File

@@ -448,6 +448,14 @@ export const FIELD_LABELS: Record<string, string> = {
"models.providers.*.injectNumCtxForOpenAICompat": "Model Provider Inject num_ctx (OpenAI Compat)",
"models.providers.*.headers": "Model Provider Headers",
"models.providers.*.authHeader": "Model Provider Authorization Header",
"models.providers.*.request": "Model Provider Request Overrides",
"models.providers.*.request.headers": "Model Provider Request Headers",
"models.providers.*.request.auth": "Model Provider Request Auth Override",
"models.providers.*.request.auth.mode": "Model Provider Request Auth Mode",
"models.providers.*.request.auth.token": "Model Provider Request Bearer Token",
"models.providers.*.request.auth.headerName": "Model Provider Request Auth Header Name",
"models.providers.*.request.auth.value": "Model Provider Request Auth Header Value",
"models.providers.*.request.auth.prefix": "Model Provider Request Auth Header Prefix",
"models.providers.*.models": "Model Provider Model List",
"models.bedrockDiscovery": "Bedrock Model Discovery",
"models.bedrockDiscovery.enabled": "Bedrock Discovery Enabled",

View File

@@ -1,4 +1,5 @@
import type { OpenAICompletionsCompat } from "@mariozechner/pi-ai";
import type { ConfiguredModelProviderRequest } from "./types.provider-request.js";
import type { SecretInput } from "./types.secrets.js";
export const MODEL_APIS = [
@@ -72,6 +73,7 @@ export type ModelProviderConfig = {
injectNumCtxForOpenAICompat?: boolean;
headers?: Record<string, SecretInput>;
authHeader?: boolean;
request?: ConfiguredModelProviderRequest;
models: ModelDefinitionConfig[];
};

View File

@@ -41,3 +41,5 @@ export type ConfiguredProviderRequest = {
proxy?: ConfiguredProviderRequestProxy;
tls?: ConfiguredProviderRequestTls;
};
export type ConfiguredModelProviderRequest = Pick<ConfiguredProviderRequest, "headers" | "auth">;

View File

@@ -224,6 +224,78 @@ type _ModelCompatTypeAssignableToSchema = AssertAssignable<
z.infer<typeof ModelCompatSchema>
>;
const ConfiguredProviderRequestTlsSchema = z
.object({
ca: SecretInputSchema.optional().register(sensitive),
cert: SecretInputSchema.optional().register(sensitive),
key: SecretInputSchema.optional().register(sensitive),
passphrase: SecretInputSchema.optional().register(sensitive),
serverName: z.string().optional(),
insecureSkipVerify: z.boolean().optional(),
})
.strict()
.optional();
const ConfiguredProviderRequestAuthSchema = z
.union([
z
.object({
mode: z.literal("provider-default"),
})
.strict(),
z
.object({
mode: z.literal("authorization-bearer"),
token: SecretInputSchema.register(sensitive),
})
.strict(),
z
.object({
mode: z.literal("header"),
headerName: z.string().min(1),
value: SecretInputSchema.register(sensitive),
prefix: z.string().optional(),
})
.strict(),
])
.optional();
const ConfiguredProviderRequestProxySchema = z
.union([
z
.object({
mode: z.literal("env-proxy"),
tls: ConfiguredProviderRequestTlsSchema,
})
.strict(),
z
.object({
mode: z.literal("explicit-proxy"),
url: z.string().min(1),
tls: ConfiguredProviderRequestTlsSchema,
})
.strict(),
])
.optional();
const ConfiguredProviderRequestSchema = z
.object({
headers: z.record(z.string(), SecretInputSchema.register(sensitive)).optional(),
auth: ConfiguredProviderRequestAuthSchema,
proxy: ConfiguredProviderRequestProxySchema,
tls: ConfiguredProviderRequestTlsSchema,
})
.strict()
.optional();
const ConfiguredModelProviderRequestSchema = z
.object({
headers: z.record(z.string(), SecretInputSchema.register(sensitive)).optional(),
auth: ConfiguredProviderRequestAuthSchema,
})
.strict()
.optional();
export const ModelDefinitionSchema = z
.object({
id: z.string().min(1),
@@ -258,6 +330,7 @@ export const ModelProviderSchema = z
injectNumCtxForOpenAICompat: z.boolean().optional(),
headers: z.record(z.string(), SecretInputSchema.register(sensitive)).optional(),
authHeader: z.boolean().optional(),
request: ConfiguredModelProviderRequestSchema,
models: z.array(ModelDefinitionSchema),
})
.strict();
@@ -639,70 +712,6 @@ const ProviderOptionsSchema = z
.record(z.string(), z.record(z.string(), ProviderOptionValueSchema))
.optional();
const ConfiguredProviderRequestTlsSchema = z
.object({
ca: SecretInputSchema.optional().register(sensitive),
cert: SecretInputSchema.optional().register(sensitive),
key: SecretInputSchema.optional().register(sensitive),
passphrase: SecretInputSchema.optional().register(sensitive),
serverName: z.string().optional(),
insecureSkipVerify: z.boolean().optional(),
})
.strict()
.optional();
const ConfiguredProviderRequestAuthSchema = z
.union([
z
.object({
mode: z.literal("provider-default"),
})
.strict(),
z
.object({
mode: z.literal("authorization-bearer"),
token: SecretInputSchema.register(sensitive),
})
.strict(),
z
.object({
mode: z.literal("header"),
headerName: z.string().min(1),
value: SecretInputSchema.register(sensitive),
prefix: z.string().optional(),
})
.strict(),
])
.optional();
const ConfiguredProviderRequestProxySchema = z
.union([
z
.object({
mode: z.literal("env-proxy"),
tls: ConfiguredProviderRequestTlsSchema,
})
.strict(),
z
.object({
mode: z.literal("explicit-proxy"),
url: z.string().min(1),
tls: ConfiguredProviderRequestTlsSchema,
})
.strict(),
])
.optional();
const ConfiguredProviderRequestSchema = z
.object({
headers: z.record(z.string(), SecretInputSchema.register(sensitive)).optional(),
auth: ConfiguredProviderRequestAuthSchema,
proxy: ConfiguredProviderRequestProxySchema,
tls: ConfiguredProviderRequestTlsSchema,
})
.strict()
.optional();
const MediaUnderstandingRuntimeFields = {
prompt: z.string().optional(),
timeoutSeconds: z.number().int().positive().optional(),

View File

@@ -89,6 +89,9 @@ describe("exec SecretRef id parity", () => {
if (canonicalId.startsWith("models.providers.") && canonicalId.includes(".headers.")) {
return "models.headers";
}
if (canonicalId.startsWith("models.providers.") && canonicalId.includes(".request.")) {
return "models.request";
}
if (canonicalId.startsWith("models.providers.")) {
return "models.apiKey";
}

View File

@@ -17,6 +17,7 @@ import { isRecord } from "./shared.js";
type ProviderLike = {
apiKey?: unknown;
headers?: unknown;
request?: unknown;
enabled?: unknown;
};
@@ -52,21 +53,33 @@ function collectModelProviderAssignments(params: {
},
});
const headers = isRecord(provider.headers) ? provider.headers : undefined;
if (!headers) {
continue;
if (headers) {
for (const [headerKey, headerValue] of Object.entries(headers)) {
collectSecretInputAssignment({
value: headerValue,
path: `models.providers.${providerId}.headers.${headerKey}`,
expected: "string",
defaults: params.defaults,
context: params.context,
active: providerIsActive,
inactiveReason: "provider is disabled.",
apply: (value) => {
headers[headerKey] = value;
},
});
}
}
for (const [headerKey, headerValue] of Object.entries(headers)) {
collectSecretInputAssignment({
value: headerValue,
path: `models.providers.${providerId}.headers.${headerKey}`,
expected: "string",
const request = isRecord(provider.request) ? provider.request : undefined;
if (request) {
collectProviderRequestAssignments({
request,
pathPrefix: `models.providers.${providerId}.request`,
defaults: params.defaults,
context: params.context,
active: providerIsActive,
inactiveReason: "provider is disabled.",
apply: (value) => {
headers[headerKey] = value;
},
collectTransportSecrets: false,
});
}
}
@@ -295,6 +308,7 @@ function collectProviderRequestAssignments(params: {
context: ResolverContext;
active?: boolean;
inactiveReason?: string;
collectTransportSecrets?: boolean;
}): void {
const headers = isRecord(params.request.headers) ? params.request.headers : undefined;
if (headers) {
@@ -362,15 +376,17 @@ function collectProviderRequestAssignments(params: {
}
};
collectTlsAssignments(
isRecord(params.request.tls) ? params.request.tls : undefined,
`${params.pathPrefix}.tls`,
);
const proxy = isRecord(params.request.proxy) ? params.request.proxy : undefined;
collectTlsAssignments(
isRecord(proxy?.tls) ? proxy.tls : undefined,
`${params.pathPrefix}.proxy.tls`,
);
if (params.collectTransportSecrets !== false) {
collectTlsAssignments(
isRecord(params.request.tls) ? params.request.tls : undefined,
`${params.pathPrefix}.tls`,
);
const proxy = isRecord(params.request.proxy) ? params.request.proxy : undefined;
collectTlsAssignments(
isRecord(proxy?.tls) ? proxy.tls : undefined,
`${params.pathPrefix}.proxy.tls`,
);
}
}
function collectMediaRequestAssignments(params: {

View File

@@ -181,6 +181,14 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string)
provider: "default",
id: resolvedEnvId,
});
if (entry.id.startsWith("models.providers.")) {
setPathCreateStrict(
config,
["models", "providers", "sample", "baseUrl"],
"https://api.example/v1",
);
setPathCreateStrict(config, ["models", "providers", "sample", "models"], []);
}
if (entry.id === "gateway.auth.password") {
setPathCreateStrict(config, ["gateway", "auth", "mode"], "password");
}
@@ -255,6 +263,25 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string)
if (entry.id === "plugins.entries.tavily.config.webSearch.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "tavily");
}
if (entry.id === "models.providers.*.request.auth.token") {
setPathCreateStrict(
config,
["models", "providers", "sample", "request", "auth", "mode"],
"authorization-bearer",
);
}
if (entry.id === "models.providers.*.request.auth.value") {
setPathCreateStrict(
config,
["models", "providers", "sample", "request", "auth", "mode"],
"header",
);
setPathCreateStrict(
config,
["models", "providers", "sample", "request", "auth", "headerName"],
"x-api-key",
);
}
return config;
}

View File

@@ -982,6 +982,48 @@ describe("secrets runtime snapshot", () => {
expect(second?.search.selectedProvider).toBe("gemini");
});
it("resolves model provider request secret refs for headers and auth", async () => {
const config = asConfig({
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
request: {
headers: {
"X-Tenant": { source: "env", provider: "default", id: "OPENAI_PROVIDER_TENANT" },
},
auth: {
mode: "authorization-bearer",
token: { source: "env", provider: "default", id: "OPENAI_PROVIDER_TOKEN" },
},
},
models: [],
},
},
},
});
const snapshot = await prepareSecretsRuntimeSnapshot({
config,
env: {
OPENAI_PROVIDER_TENANT: "tenant-acme",
OPENAI_PROVIDER_TOKEN: "sk-provider-runtime", // pragma: allowlist secret
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect(snapshot.config.models?.providers?.openai?.request).toEqual({
headers: {
"X-Tenant": "tenant-acme",
},
auth: {
mode: "authorization-bearer",
token: "sk-provider-runtime",
},
});
});
it("resolves file refs via configured file provider", async () => {
if (process.platform === "win32") {
return;

View File

@@ -669,6 +669,45 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
includeInAudit: true,
providerIdPathSegmentIndex: 2,
},
{
id: "models.providers.*.request.headers.*",
targetType: "models.providers.request.headers",
targetTypeAliases: ["models.providers.*.request.headers.*"],
configFile: "openclaw.json",
pathPattern: "models.providers.*.request.headers.*",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
providerIdPathSegmentIndex: 2,
},
{
id: "models.providers.*.request.auth.token",
targetType: "models.providers.request.auth.token",
targetTypeAliases: ["models.providers.*.request.auth.token"],
configFile: "openclaw.json",
pathPattern: "models.providers.*.request.auth.token",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
providerIdPathSegmentIndex: 2,
},
{
id: "models.providers.*.request.auth.value",
targetType: "models.providers.request.auth.value",
targetTypeAliases: ["models.providers.*.request.auth.value"],
configFile: "openclaw.json",
pathPattern: "models.providers.*.request.auth.value",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
providerIdPathSegmentIndex: 2,
},
{
id: "skills.entries.*.apiKey",
targetType: "skills.entries.apiKey",