fix(cli-tools): enable Apply for compatible providers (#9250)

* fix(cli-tools): resolve models for compatible providers

Keep the CLI tools Apply flow usable when a dynamic OpenAI-compatible or
Anthropic-compatible connection has no static catalog entry. Resolve its
public prefix, connection default model, and prefix-backed catalog entries
before gating the cards.

Co-authored-by: lazysaltyfish <7127935+lazysaltyfish@users.noreply.github.com>
Inspired-by: https://github.com/decolua/9router/pull/2995

* chore(changelog): fragment for #9250

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: lazysaltyfish <7127935+lazysaltyfish@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-04 10:06:37 -03:00
committed by GitHub
parent 2cb77bbca7
commit f11d883f22
3 changed files with 147 additions and 6 deletions

View File

@@ -0,0 +1 @@
- **fix(cli-tools):** keep Apply enabled for active OpenAI-compatible and Anthropic-compatible providers without static catalog entries. (thanks @lazysaltyfish)

View File

@@ -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) => {

View File

@@ -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: () => <div data-testid="ClaudeToolCard" />,
ClaudeToolCard: ({ hasActiveProviders, availableModels }: any) => (
<div
data-testid="ClaudeToolCard"
data-has-active-providers={String(hasActiveProviders)}
data-available-models={JSON.stringify(availableModels)}
/>
),
CodexToolCard: () => <div data-testid="CodexToolCard" />,
DroidToolCard: () => <div data-testid="DroidToolCard" />,
OpenClawToolCard: () => <div data-testid="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 () => {});