diff --git a/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx
index dcdee025c2..f72b609b69 100644
--- a/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx
+++ b/src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx
@@ -23,6 +23,8 @@ const HERMES_ROLES: Role[] = [
{ id: "approval", label: "Approval", description: "Safety and approval decisions" },
];
+const HERMES_AGENT_ZERO_CONFIG_PROVIDERS = ["opencode"];
+
export default function HermesAgentToolCard({
tool,
isExpanded = false,
@@ -517,6 +519,7 @@ export default function HermesAgentToolCard({
}}
showCombos={true}
activeProviders={activeProviders}
+ alwaysIncludeProviders={HERMES_AGENT_ZERO_CONFIG_PROVIDERS}
/>
);
diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx
index 8c30a2dfa4..9736ed3866 100644
--- a/src/shared/components/ModelSelectModal.tsx
+++ b/src/shared/components/ModelSelectModal.tsx
@@ -37,6 +37,7 @@ type ModelSelectModalProps = {
addedModelValues?: string[];
multiSelect?: boolean;
showCombos?: boolean;
+ alwaysIncludeProviders?: string[] | null;
};
export default function ModelSelectModal({
@@ -51,6 +52,7 @@ export default function ModelSelectModal({
addedModelValues = [],
multiSelect = false,
showCombos = true,
+ alwaysIncludeProviders = [],
}: ModelSelectModalProps) {
const t = useTranslations("common");
const resolvedTitle = title ?? t("selectModel");
@@ -111,6 +113,11 @@ export default function ModelSelectModal({
() => ({ ...OAUTH_PROVIDERS, ...NOAUTH_PROVIDERS, ...APIKEY_PROVIDERS }),
[]
);
+ const alwaysIncludeProvidersKey = Array.isArray(alwaysIncludeProviders)
+ ? alwaysIncludeProviders
+ .filter((providerId) => typeof providerId === "string" && providerId)
+ .join("\0")
+ : "";
// Group models by provider with priority order
const groupedModels = useMemo(() => {
@@ -118,10 +125,14 @@ export default function ModelSelectModal({
// Get all active provider IDs from connections
const activeConnectionIds = activeProviders.map((p) => p.provider);
+ const explicitProviderIds = alwaysIncludeProvidersKey
+ ? alwaysIncludeProvidersKey.split("\0")
+ : [];
// Only show connected providers (including both standard and custom)
const providerIdsToShow = new Set([
- ...activeConnectionIds, // Only connected providers
+ ...activeConnectionIds, // Connected providers
+ ...explicitProviderIds, // Zero-config providers required by specific clients
]);
// Sort by PROVIDER_ORDER
@@ -262,7 +273,14 @@ export default function ModelSelectModal({
});
return groups;
- }, [activeProviders, modelAliases, allProviders, providerNodes, customModels]);
+ }, [
+ activeProviders,
+ alwaysIncludeProvidersKey,
+ modelAliases,
+ allProviders,
+ providerNodes,
+ customModels,
+ ]);
// Filter combos by search query
const filteredCombos = useMemo(() => {
diff --git a/tests/unit/ui/hermes-agent-opencode-free.test.tsx b/tests/unit/ui/hermes-agent-opencode-free.test.tsx
new file mode 100644
index 0000000000..57988fda70
--- /dev/null
+++ b/tests/unit/ui/hermes-agent-opencode-free.test.tsx
@@ -0,0 +1,76 @@
+// @vitest-environment jsdom
+import React from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+let lastModelSelectProps: any = null;
+
+vi.mock("next-intl", () => ({
+ useTranslations: () => (key: string) => key,
+}));
+
+vi.mock("@/shared/components", () => ({
+ Card: ({ children }: any) =>
{children}
,
+ Button: ({ children, onClick, disabled, ...props }: any) => (
+
+ ),
+ ModelSelectModal: (props: any) => {
+ lastModelSelectProps = props;
+ return ;
+ },
+}));
+
+const { default: HermesAgentToolCard } =
+ await import("@/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard");
+
+const containers: HTMLElement[] = [];
+
+function renderCard() {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ containers.push(container);
+
+ const root = createRoot(container);
+ act(() => {
+ root.render(
+
+ );
+ });
+
+ return container;
+}
+
+beforeEach(() => {
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
+ lastModelSelectProps = null;
+});
+
+afterEach(() => {
+ while (containers.length > 0) {
+ containers.pop()?.remove();
+ }
+ document.body.innerHTML = "";
+});
+
+describe("HermesAgentToolCard", () => {
+ it("keeps OpenCode Free available in the model picker even with no active connections", async () => {
+ renderCard();
+ await act(async () => {});
+
+ expect(lastModelSelectProps).toBeTruthy();
+ expect(lastModelSelectProps.activeProviders).toEqual([]);
+ expect(lastModelSelectProps.alwaysIncludeProviders).toContain("opencode");
+ });
+});
diff --git a/tests/unit/ui/model-select-modal-zero-config.test.tsx b/tests/unit/ui/model-select-modal-zero-config.test.tsx
new file mode 100644
index 0000000000..422207a34a
--- /dev/null
+++ b/tests/unit/ui/model-select-modal-zero-config.test.tsx
@@ -0,0 +1,88 @@
+// @vitest-environment jsdom
+import React from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next-intl", () => ({
+ useTranslations: () => (key: string) => key,
+}));
+
+const { default: ModelSelectModal } = await import("@/shared/components/ModelSelectModal");
+
+const containers: HTMLElement[] = [];
+
+async function renderModal(props: Partial> = {}) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ containers.push(container);
+
+ const root = createRoot(container);
+ await act(async () => {
+ root.render(
+ {}}
+ onSelect={() => {}}
+ showCombos={false}
+ activeProviders={[]}
+ {...props}
+ />
+ );
+ });
+
+ await act(async () => {});
+ return container;
+}
+
+beforeEach(() => {
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (url: string) => {
+ if (url === "/api/provider-nodes") {
+ return { ok: true, json: async () => ({ nodes: [] }) };
+ }
+ if (url === "/api/provider-models") {
+ return { ok: true, json: async () => ({ models: {} }) };
+ }
+ if (url === "/api/combos") {
+ return { ok: true, json: async () => ({ combos: [] }) };
+ }
+ return { ok: true, json: async () => ({}) };
+ })
+ );
+});
+
+afterEach(() => {
+ while (containers.length > 0) {
+ containers.pop()?.remove();
+ }
+ document.body.innerHTML = "";
+ vi.unstubAllGlobals();
+});
+
+describe("ModelSelectModal zero-config providers", () => {
+ it("shows OpenCode Free models when explicitly included without an active connection", async () => {
+ const container = await renderModal({ alwaysIncludeProviders: ["opencode"] });
+
+ expect(container.textContent).toContain("OpenCode Free");
+ expect(container.textContent).toContain("Big Pickle");
+ });
+
+ it("does not show OpenCode Free by default without an active connection", async () => {
+ const container = await renderModal();
+
+ expect(container.textContent).not.toContain("OpenCode Free");
+ expect(container.textContent).not.toContain("Big Pickle");
+ });
+
+ it("treats null explicit provider lists as empty", async () => {
+ const container = await renderModal({ alwaysIncludeProviders: null });
+
+ expect(container.textContent).not.toContain("OpenCode Free");
+ expect(container.textContent).not.toContain("Big Pickle");
+ });
+});