diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c9420cdc56b..8cdfc6b7b1b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 06e7ed2b5c04..b5d55b2067c0 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -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", diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index c471a026f809..2f21ac4c10ad 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -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 { 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, }); } diff --git a/src/agents/pi-embedded-runner/model.inline-provider.ts b/src/agents/pi-embedded-runner/model.inline-provider.ts index 1973f9cd7493..7ae1587101a8 100644 --- a/src/agents/pi-embedded-runner/model.inline-provider.ts +++ b/src/agents/pi-embedded-runner/model.inline-provider.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 0c8959bc5d8e..f3033301315a 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -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: { diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 3e2ea2a0c0e1..ed85c008bc1a 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -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, diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index b120ed67e399..0cb3ea9397fb 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -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,