diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bb015f7f..3131358f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Status: unreleased. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94. - Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355. - macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee. - Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index b59735623..cdcb5fe8e 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { buildInlineProviderModels } from "./model.js"; +vi.mock("@mariozechner/pi-coding-agent", () => ({ + discoverAuthStorage: vi.fn(() => ({ mocked: true })), + discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })), +})); + +import type { MoltbotConfig } from "../../config/config.js"; +import { buildInlineProviderModels, resolveModel } from "./model.js"; const makeModel = (id: string) => ({ id, @@ -15,15 +21,110 @@ const makeModel = (id: string) => ({ describe("buildInlineProviderModels", () => { it("attaches provider ids to inline models", () => { const providers = { - " alpha ": { models: [makeModel("alpha-model")] }, - beta: { models: [makeModel("beta-model")] }, + " alpha ": { baseUrl: "http://alpha.local", models: [makeModel("alpha-model")] }, + beta: { baseUrl: "http://beta.local", models: [makeModel("beta-model")] }, }; const result = buildInlineProviderModels(providers); expect(result).toEqual([ - { ...makeModel("alpha-model"), provider: "alpha" }, - { ...makeModel("beta-model"), provider: "beta" }, + { + ...makeModel("alpha-model"), + provider: "alpha", + baseUrl: "http://alpha.local", + api: undefined, + }, + { + ...makeModel("beta-model"), + provider: "beta", + baseUrl: "http://beta.local", + api: undefined, + }, ]); }); + + it("inherits baseUrl from provider when model does not specify it", () => { + const providers = { + custom: { + baseUrl: "http://localhost:8000", + models: [makeModel("custom-model")], + }, + }; + + const result = buildInlineProviderModels(providers); + + expect(result).toHaveLength(1); + expect(result[0].baseUrl).toBe("http://localhost:8000"); + }); + + it("inherits api from provider when model does not specify it", () => { + const providers = { + custom: { + baseUrl: "http://localhost:8000", + api: "anthropic-messages", + models: [makeModel("custom-model")], + }, + }; + + const result = buildInlineProviderModels(providers); + + expect(result).toHaveLength(1); + expect(result[0].api).toBe("anthropic-messages"); + }); + + it("model-level api takes precedence over provider-level api", () => { + const providers = { + custom: { + baseUrl: "http://localhost:8000", + api: "openai-responses", + models: [{ ...makeModel("custom-model"), api: "anthropic-messages" as const }], + }, + }; + + const result = buildInlineProviderModels(providers); + + expect(result).toHaveLength(1); + expect(result[0].api).toBe("anthropic-messages"); + }); + + it("inherits both baseUrl and api from provider config", () => { + const providers = { + custom: { + baseUrl: "http://localhost:10000", + api: "anthropic-messages", + models: [makeModel("claude-opus-4.5")], + }, + }; + + const result = buildInlineProviderModels(providers); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + provider: "custom", + baseUrl: "http://localhost:10000", + api: "anthropic-messages", + name: "claude-opus-4.5", + }); + }); +}); + +describe("resolveModel", () => { + it("includes provider baseUrl in fallback model", () => { + const cfg = { + models: { + providers: { + custom: { + baseUrl: "http://localhost:9000", + models: [], + }, + }, + }, + } as MoltbotConfig; + + const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg); + + expect(result.model?.baseUrl).toBe("http://localhost:9000"); + expect(result.model?.provider).toBe("custom"); + expect(result.model?.id).toBe("missing-model"); + }); }); diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 1d7201ea9..1792e6706 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -8,15 +8,25 @@ import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { normalizeModelCompat } from "../model-compat.js"; import { normalizeProviderId } from "../model-selection.js"; -type InlineModelEntry = ModelDefinitionConfig & { provider: string }; +type InlineModelEntry = ModelDefinitionConfig & { provider: string; baseUrl?: string }; +type InlineProviderConfig = { + baseUrl?: string; + api?: ModelDefinitionConfig["api"]; + models?: ModelDefinitionConfig[]; +}; export function buildInlineProviderModels( - providers: Record, + providers: Record, ): InlineModelEntry[] { return Object.entries(providers).flatMap(([providerId, entry]) => { const trimmed = providerId.trim(); if (!trimmed) return []; - return (entry?.models ?? []).map((model) => ({ ...model, provider: trimmed })); + return (entry?.models ?? []).map((model) => ({ + ...model, + provider: trimmed, + baseUrl: entry?.baseUrl, + api: model.api ?? entry?.api, + })); }); } @@ -72,6 +82,7 @@ export function resolveModel( name: modelId, api: providerCfg?.api ?? "openai-responses", provider, + baseUrl: providerCfg?.baseUrl, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },