mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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 on277629e992- GitHub CodeQL green on277629e992- GitHub CodeQL Critical Quality green on277629e992- GitHub Real behavior proof green on277629e992- 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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user