fix: honor per-model provider transport overrides (#80488)

Summary:
- Honor per-model api/baseUrl overrides during custom provider auth hook lookup and transport selection.
- Keep models-add metadata safeguards intact and add focused auth/model resolver regression coverage.
- Add maintainer changelog credit for @huveewomg.

Verification:
- git diff --check
- GitHub CI green on 277629e992
- GitHub CodeQL green on 277629e992
- GitHub CodeQL Critical Quality green on 277629e992
- GitHub Real behavior proof green on 277629e992
- Local focused Vitest was stopped after 8 minutes on a busy host without producing a result; PR CI supplied the final proof.

Co-authored-by: huveewomg <wongrenthou1265@gmail.com>
This commit is contained in:
Huvee
2026-05-22 21:12:11 +08:00
committed by GitHub
parent 19ff77e9c9
commit ca2b9ad289
7 changed files with 115 additions and 11 deletions
+1
View File
@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/update: preserve managed Gateway service environment during package cutovers so macOS LaunchAgent repair/restart reads the pre-update service state instead of caller shell state. (#83026)
- Agents/providers: honor per-model `api` and `baseUrl` overrides in custom provider auth hooks and transport selection. Fixes #80487. (#80488) Thanks @huveewomg.
- Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415.
- CLI/update: start managed Gateway update handoff helpers from a stable existing directory and tolerate deleted cwd/package roots during macOS LaunchAgent handoff. Fixes #83808. (#83875) Thanks @jason-allen-oneal.
- Skills: watch each shared skill directory once across agent workspaces instead of once per agent, preventing file-descriptor exhaustion (`EMFILE`) that disposed bundle-mcp processes and stalled sessions on multi-agent gateways. Fixes #84968. (#85130) Thanks @openperf.
+54 -2
View File
@@ -72,6 +72,7 @@ vi.mock("../plugins/provider-runtime.js", async () => {
};
};
};
modelApi?: string;
context: { providerConfig?: { api?: string; baseUrl?: string; models?: unknown[] } };
}) => {
if (params.provider === "plugin-web") {
@@ -106,9 +107,11 @@ vi.mock("../plugins/provider-runtime.js", async () => {
mode: "oauth" as const,
};
}
const effectiveApi = params.modelApi ?? params.context.providerConfig?.api;
if (
params.context.providerConfig?.api === "ollama" &&
params.context.providerConfig.baseUrl?.startsWith("http://192.168.")
effectiveApi === "ollama" &&
(params.context.providerConfig?.baseUrl?.startsWith("http://192.168.") ||
params.modelApi === "ollama")
) {
return {
apiKey: "ollama-local",
@@ -127,6 +130,7 @@ let formatMissingAuthError: typeof import("./model-auth.js").formatMissingAuthEr
let hasUsableCustomProviderApiKey: typeof import("./model-auth.js").hasUsableCustomProviderApiKey;
let hasSyntheticLocalProviderAuthConfig: typeof import("./model-auth.js").hasSyntheticLocalProviderAuthConfig;
let requireApiKey: typeof import("./model-auth.js").requireApiKey;
let getApiKeyForModel: typeof import("./model-auth.js").getApiKeyForModel;
let resolveApiKeyForProvider: typeof import("./model-auth.js").resolveApiKeyForProvider;
let resolveAwsSdkEnvVarName: typeof import("./model-auth.js").resolveAwsSdkEnvVarName;
let resolveModelAuthMode: typeof import("./model-auth.js").resolveModelAuthMode;
@@ -144,6 +148,7 @@ beforeAll(async () => {
applyLocalNoAuthHeaderOverride,
formatMissingAuthError,
hasSyntheticLocalProviderAuthConfig,
getApiKeyForModel,
hasUsableCustomProviderApiKey,
requireApiKey,
resolveApiKeyForProvider,
@@ -1076,6 +1081,53 @@ describe("resolveApiKeyForProvider synthetic local auth for custom providers
});
});
it("resolves synthetic auth when model overrides api to ollama within a non-ollama provider", async () => {
const auth = await getApiKeyForModel({
model: {
id: "my-router/local-llama",
name: "Local Llama",
provider: "my-router",
api: "ollama",
baseUrl: "http://localhost:11434",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 4096,
},
cfg: {
models: {
providers: {
"my-router": {
baseUrl: "http://localhost:8080/v1",
api: "openai-completions",
models: [
{
id: "my-router/local-llama",
name: "Local Llama",
api: "ollama",
baseUrl: "http://localhost:11434",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 4096,
},
],
},
},
},
},
store: { version: 1, profiles: {} },
});
expectAuthFields(auth, {
apiKey: "ollama-local",
source: "models.providers.my-router (synthetic local key)",
mode: "api-key",
});
});
it("accepts non-secret local markers for private LAN custom OpenAI-compatible providers", async () => {
const auth = await resolveApiKeyForProvider({
provider: "custom-192-168-0-222-11434",
+10 -1
View File
@@ -389,6 +389,7 @@ type SyntheticProviderAuthResolution = {
function resolveProviderSyntheticRuntimeAuth(params: {
cfg: OpenClawConfig | undefined;
provider: string;
modelApi?: string;
}): SyntheticProviderAuthResolution {
const resolveFromConfig = (
config: OpenClawConfig | undefined,
@@ -403,6 +404,7 @@ function resolveProviderSyntheticRuntimeAuth(params: {
provider: params.provider,
providerConfig,
},
modelApi: params.modelApi,
}) ?? undefined
);
};
@@ -433,6 +435,7 @@ function resolveProviderSyntheticRuntimeAuth(params: {
function resolveSyntheticLocalProviderAuth(params: {
cfg: OpenClawConfig | undefined;
provider: string;
modelApi?: string;
}): ResolvedProviderAuth | null {
const syntheticProviderAuth = resolveProviderSyntheticRuntimeAuth(params);
if (syntheticProviderAuth.auth) {
@@ -510,12 +513,14 @@ function shouldDeferSyntheticProfileAuth(params: {
cfg: OpenClawConfig | undefined;
provider: string;
resolvedApiKey: string | undefined;
modelApi?: string;
}): boolean {
const providerConfig = resolveProviderConfig(params.cfg, params.provider);
return (
shouldDeferProviderSyntheticProfileAuthWithPlugin({
provider: params.provider,
config: params.cfg,
modelApi: params.modelApi,
context: {
config: params.cfg,
provider: params.provider,
@@ -551,6 +556,7 @@ export async function resolveApiKeyForProvider(params: {
lockedProfile?: boolean;
forceRefresh?: boolean;
credentialPrecedence?: ProviderCredentialPrecedence;
modelApi?: string;
}): Promise<ResolvedProviderAuth> {
const { provider, cfg, profileId, preferredProfile } = params;
const agentDir = params.agentDir?.trim() || (cfg ? resolveDefaultAgentDir(cfg) : undefined);
@@ -599,6 +605,7 @@ export async function resolveApiKeyForProvider(params: {
cfg,
provider,
resolvedApiKey: resolved.apiKey,
modelApi: params.modelApi,
})
) {
return resolveApiKeyForProvider({ ...params, profileId: undefined, lockedProfile: true }) //
@@ -731,6 +738,7 @@ export async function resolveApiKeyForProvider(params: {
cfg,
provider,
resolvedApiKey: resolved.apiKey,
modelApi: params.modelApi,
})
) {
deferredAuthProfileResult ??= result;
@@ -766,7 +774,7 @@ export async function resolveApiKeyForProvider(params: {
return deferredAuthProfileResult;
}
const syntheticLocalAuth = resolveSyntheticLocalProviderAuth({ cfg, provider });
const syntheticLocalAuth = resolveSyntheticLocalProviderAuth({ cfg, provider, modelApi: params.modelApi });
if (syntheticLocalAuth) {
return syntheticLocalAuth;
}
@@ -964,6 +972,7 @@ export async function getApiKeyForModel(params: {
workspaceDir: params.workspaceDir,
lockedProfile: params.lockedProfile,
credentialPrecedence: params.credentialPrecedence,
modelApi: params.model.api,
});
}
@@ -141,7 +141,7 @@ export function buildInlineProviderModels(
return (entry?.models ?? []).map((model) => {
const transport = resolveInlineProviderTransport({
api: model.api ?? entry?.api,
baseUrl: entry?.baseUrl,
baseUrl: (model as InlineModelEntry).baseUrl ?? entry?.baseUrl,
});
const modelHeaders = sanitizeModelHeaders((model as InlineModelEntry).headers, {
stripSecretRefMarkers: true,
@@ -709,6 +709,46 @@ describe("resolveModel", () => {
expect(result.error).toBe("Unknown model: openai/typoed-model");
});
it("resolves per-model api and baseUrl override in fallback model", () => {
const cfg = {
models: {
providers: {
"my-router": {
baseUrl: "http://localhost:8080",
api: "ollama",
models: [
{
id: "my-router/claude",
name: "Claude via Router",
api: "anthropic-messages",
input: ["text", "image"],
contextWindow: 200_000,
},
{
id: "my-router/gpt",
name: "GPT via Router",
api: "openai-completions",
baseUrl: "http://localhost:8080/v1",
input: ["text"],
contextWindow: 400_000,
},
],
},
},
},
} as unknown as OpenClawConfig;
const claude = resolveModelForTest("my-router", "my-router/claude", "/tmp/agent", cfg);
const claudeModel = expectResolvedModel(claude);
expect(claudeModel.api).toBe("anthropic-messages");
expect(claudeModel.baseUrl).toBe("http://localhost:8080");
const gpt = resolveModelForTest("my-router", "my-router/gpt", "/tmp/agent", cfg);
const gptModel = expectResolvedModel(gpt);
expect(gptModel.api).toBe("openai-completions");
expect(gptModel.baseUrl).toBe("http://localhost:8080/v1");
});
it("defaults baseUrl-only local custom fallback models to chat completions", () => {
const cfg = {
agents: {
+3 -3
View File
@@ -655,7 +655,7 @@ function applyConfiguredProviderOverrides(params: {
providerConfig.api ??
discoveredModel.api ??
resolveConfiguredProviderDefaultApi(providerConfig),
baseUrl: providerConfig.baseUrl ?? discoveredModel.baseUrl,
baseUrl: metadataOverrideModel?.baseUrl ?? providerConfig.baseUrl ?? discoveredModel.baseUrl,
cfg: params.cfg,
workspaceDir: params.workspaceDir,
runtimeHooks: params.runtimeHooks,
@@ -913,8 +913,8 @@ function resolveConfiguredFallbackModel(params: {
}
const fallbackTransport = resolveProviderTransport({
provider,
api: resolveConfiguredProviderDefaultApi(providerConfig) ?? "openai-responses",
baseUrl: providerConfig?.baseUrl,
api: normalizeResolvedTransportApi(configuredModel?.api) ?? resolveConfiguredProviderDefaultApi(providerConfig) ?? "openai-responses",
baseUrl: configuredModel?.baseUrl ?? providerConfig?.baseUrl,
cfg,
workspaceDir,
runtimeHooks,
+6 -4
View File
@@ -99,9 +99,9 @@ function matchesProviderPluginRef(provider: ProviderPlugin, providerId: string):
);
}
function resolveProviderHookRefs(provider: string, providerConfig?: ModelProviderConfig): string[] {
function resolveProviderHookRefs(provider: string, providerConfig?: ModelProviderConfig, modelApi?: string): string[] {
const refs = [provider];
const apiRef = normalizeOptionalString(providerConfig?.api);
const apiRef = normalizeOptionalString(modelApi ?? providerConfig?.api);
if (apiRef && normalizeProviderId(apiRef) !== normalizeProviderId(provider)) {
refs.push(apiRef);
}
@@ -851,8 +851,9 @@ export function resolveProviderSyntheticAuthWithPlugin(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderResolveSyntheticAuthContext;
modelApi?: string;
}) {
const providerRefs = resolveProviderHookRefs(params.provider, params.context.providerConfig);
const providerRefs = resolveProviderHookRefs(params.provider, params.context.providerConfig, params.modelApi);
const discoveryPluginIds = [
...new Set(
providerRefs.flatMap(
@@ -985,8 +986,9 @@ export function shouldDeferProviderSyntheticProfileAuthWithPlugin(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderDeferSyntheticProfileAuthContext;
modelApi?: string;
}) {
const providerRefs = resolveProviderHookRefs(params.provider, params.context.providerConfig);
const providerRefs = resolveProviderHookRefs(params.provider, params.context.providerConfig, params.modelApi);
for (const providerRef of providerRefs) {
const resolved = resolveProviderRuntimePlugin({
...params,