diff --git a/changelog.d/fixes/9250-cli-compatible-provider-apply.md b/changelog.d/fixes/9250-cli-compatible-provider-apply.md new file mode 100644 index 0000000000..e245077f66 --- /dev/null +++ b/changelog.d/fixes/9250-cli-compatible-provider-apply.md @@ -0,0 +1 @@ +- **fix(cli-tools):** keep Apply enabled for active OpenAI-compatible and Anthropic-compatible providers without static catalog entries. (thanks @lazysaltyfish) diff --git a/src/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient.tsx b/src/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient.tsx index c66bd2df80..96771fd0c6 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient.tsx @@ -133,10 +133,55 @@ export default function ToolDetailClient({ toolId, category }: ToolDetailClientP }); } }); + + if (providerModels.length === 0) { + const prefix = + typeof conn.providerSpecificData?.prefix === "string" && + conn.providerSpecificData.prefix.trim() + ? conn.providerSpecificData.prefix.trim() + : alias; + const fallbackModels: Array<{ id: string; name: string }> = []; + const addFallbackModel = (model: any) => { + const id = typeof model?.id === "string" ? model.id.trim() : ""; + if (!id || fallbackModels.some((candidate) => candidate.id === id)) return; + fallbackModels.push({ + id, + name: typeof model?.name === "string" && model.name.trim() ? model.name.trim() : id, + }); + }; + + if (typeof conn.defaultModel === "string" && conn.defaultModel.trim()) { + addFallbackModel({ id: conn.defaultModel }); + } + if (Array.isArray(conn.providerSpecificData?.customModels)) { + conn.providerSpecificData.customModels.forEach(addFallbackModel); + } + if (fallbackModels.length === 0 && conn.testStatus === "active") { + addFallbackModel({ id: "model-id", name: `${prefix}/model-id` }); + } + + fallbackModels.forEach((model) => { + const modelValue = `${prefix}/${model.id}`; + if (seenModels.has(modelValue)) return; + seenModels.add(modelValue); + models.push({ + value: modelValue, + label: modelValue, + provider: conn.provider, + alias: prefix, + connectionName: conn.name, + modelId: model.id, + }); + }); + } }); const activeAliases = new Set( - activeProviders.map((c) => PROVIDER_ID_TO_ALIAS[c.provider] || c.provider) + activeProviders.flatMap((connection) => { + const alias = PROVIDER_ID_TO_ALIAS[connection.provider] || connection.provider; + const prefix = connection.providerSpecificData?.prefix; + return typeof prefix === "string" && prefix.trim() ? [alias, prefix.trim()] : [alias]; + }) ); const activeProviderIds = new Set(activeProviders.map((c) => c.provider)); dynamicModels.forEach((dm) => { diff --git a/tests/unit/ui/ToolDetailClient.test.tsx b/tests/unit/ui/ToolDetailClient.test.tsx index 738afc19a2..e65be3684e 100644 --- a/tests/unit/ui/ToolDetailClient.test.tsx +++ b/tests/unit/ui/ToolDetailClient.test.tsx @@ -106,7 +106,13 @@ vi.mock("@/shared/constants/models", () => ({ // Stub specialized cards — render a testid so we can identify which was rendered vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/index", () => ({ - ClaudeToolCard: () =>
, + ClaudeToolCard: ({ hasActiveProviders, availableModels }: any) => ( + + ), CodexToolCard: () => , DroidToolCard: () => , OpenClawToolCard: () => , @@ -127,9 +133,8 @@ vi.mock("../../../src/app/(dashboard)/dashboard/cli-code/components/CliproxyapiT // ── Import after mocks ──────────────────────────────────────────────────────── -const { default: ToolDetailClient } = await import( - "@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient" -); +const { default: ToolDetailClient } = + await import("@/app/(dashboard)/dashboard/cli-code/components/ToolDetailClient"); // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -153,7 +158,10 @@ beforeEach(() => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; - mockFetch.mockClear(); + mockFetch.mockReset().mockResolvedValue({ + ok: true, + json: async () => ({ connections: [], keys: [], data: [], cloudEnabled: false }), + }); }); afterEach(() => { @@ -185,6 +193,93 @@ describe("ToolDetailClient", () => { expect(container.querySelector("[data-testid='CustomCliCard']")).not.toBeNull(); }); + it("keeps Apply available for an active dynamic compatible provider", async () => { + mockFetch.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/providers") { + return { + ok: true, + json: async () => ({ + connections: [ + { + provider: "openai-compatible-chat-node-123", + name: "Kimi gateway", + isActive: true, + testStatus: "active", + defaultModel: "Kimi-K3", + providerSpecificData: { prefix: "kimi-gateway" }, + }, + ], + }), + }; + } + return { + ok: true, + json: async () => ({ keys: [], data: [], cloudEnabled: false }), + }; + }); + + const container = renderDetail("claude", "code"); + await act(async () => {}); + + const card = container.querySelector("[data-testid='ClaudeToolCard']"); + expect(card?.getAttribute("data-has-active-providers")).toBe("true"); + expect(JSON.parse(card?.getAttribute("data-available-models") || "[]")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: "kimi-gateway/Kimi-K3", + provider: "openai-compatible-chat-node-123", + modelId: "Kimi-K3", + }), + ]) + ); + }); + + it("accepts compatible-provider models published under the connection prefix", async () => { + mockFetch.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/providers") { + return { + ok: true, + json: async () => ({ + connections: [ + { + provider: "anthropic-compatible-node-456", + name: "Claude gateway", + isActive: true, + providerSpecificData: { prefix: "claude-gateway" }, + }, + ], + }), + }; + } + if (url === "/v1/models") { + return { + ok: true, + json: async () => ({ data: [{ id: "claude-gateway/claude-sonnet" }] }), + }; + } + return { + ok: true, + json: async () => ({ keys: [], cloudEnabled: false }), + }; + }); + + const container = renderDetail("claude", "code"); + await act(async () => {}); + + const card = container.querySelector("[data-testid='ClaudeToolCard']"); + expect(card?.getAttribute("data-has-active-providers")).toBe("true"); + expect(JSON.parse(card?.getAttribute("data-available-models") || "[]")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: "claude-gateway/claude-sonnet", + modelId: "claude-sonnet", + }), + ]) + ); + }); + it("renders DefaultToolCard for unknown tool (forge, configType:custom)", async () => { const container = renderDetail("forge", "code"); await act(async () => {});