mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:00:47 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -41,3 +41,5 @@ export type ConfiguredProviderRequest = {
|
||||
proxy?: ConfiguredProviderRequestProxy;
|
||||
tls?: ConfiguredProviderRequestTls;
|
||||
};
|
||||
|
||||
export type ConfiguredModelProviderRequest = Pick<ConfiguredProviderRequest, "headers" | "auth">;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user