Skip to content

Commit 1a196fa

Browse files
authored
Add provider availability settings and picker filtering (#419)
* Add provider availability settings and picker filtering - Move provider readiness checks to on-demand config loading - Add authentication settings for Codex, Claude, and OpenClaw - Filter thread/provider pickers to only show selectable providers * Improve provider status loading and settings availability - Scope provider health dependencies explicitly - Fetch server config earlier in chat view - Tidy provider selection and settings formatting
1 parent 95528d8 commit 1a196fa

8 files changed

Lines changed: 837 additions & 615 deletions

File tree

apps/server/src/provider/Layers/ProviderHealth.ts

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
/**
22
* ProviderHealthLive - Startup-time provider health checks.
33
*
4-
* Performs one-time provider readiness probes when the server starts and
5-
* keeps the resulting snapshot in memory for `server.getConfig`.
4+
* Performs provider readiness probes on demand for `server.getConfig`.
65
*
76
* Uses effect's ChildProcessSpawner to run CLI probes natively.
87
*
@@ -14,18 +13,7 @@ import type {
1413
ServerProviderStatus,
1514
ServerProviderStatusState,
1615
} from "@okcode/contracts";
17-
import {
18-
Array,
19-
Data,
20-
Effect,
21-
Fiber,
22-
FileSystem,
23-
Layer,
24-
Option,
25-
Path,
26-
Result,
27-
Stream,
28-
} from "effect";
16+
import { Array, Data, Effect, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
2917
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3018

3119
import {
@@ -686,15 +674,27 @@ const checkOpenClawProviderStatus: Effect.Effect<ServerProviderStatus, never, ne
686674
export const ProviderHealthLive = Layer.effect(
687675
ProviderHealth,
688676
Effect.gen(function* () {
689-
const statusesFiber = yield* Effect.all(
690-
[checkCodexProviderStatus, checkClaudeProviderStatus, checkOpenClawProviderStatus],
691-
{
692-
concurrency: "unbounded",
693-
},
694-
).pipe(Effect.forkScoped);
677+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
678+
const fileSystem = yield* FileSystem.FileSystem;
679+
const path = yield* Path.Path;
695680

696681
return {
697-
getStatuses: Fiber.join(statusesFiber),
682+
getStatuses: Effect.all(
683+
[
684+
checkCodexProviderStatus.pipe(
685+
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
686+
Effect.provideService(FileSystem.FileSystem, fileSystem),
687+
Effect.provideService(Path.Path, path),
688+
),
689+
checkClaudeProviderStatus.pipe(
690+
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
691+
),
692+
checkOpenClawProviderStatus,
693+
],
694+
{
695+
concurrency: "unbounded",
696+
},
697+
),
698698
} satisfies ProviderHealthShape;
699699
}),
700700
);

apps/web/src/components/ChatView.tsx

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4242
import { useDebouncedValue } from "@tanstack/react-pacer";
4343
import { useNavigate } from "@tanstack/react-router";
4444
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
45+
import {
46+
getSelectableThreadProviders,
47+
getThreadProviderLabel,
48+
resolveThreadProviderSelection,
49+
} from "~/lib/providerAvailability";
4550
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
4651
import {
4752
skillCatalogQueryOptions,
@@ -194,7 +199,7 @@ import { useDiffViewerStore } from "~/diffViewerStore";
194199
import { PreviewPanel } from "./PreviewPanel";
195200
import { ContextWindowMeter } from "./chat/ContextWindowMeter";
196201
import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
197-
import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
202+
import { ProviderModelPicker } from "./chat/ProviderModelPicker";
198203
import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
199204
import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
200205
import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";
@@ -856,8 +861,18 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
856861
markThreadVisited,
857862
]);
858863

864+
const serverConfigQuery = useQuery(serverConfigQueryOptions());
859865
const sessionProvider = activeThread?.session?.provider ?? null;
860866
const selectedProviderByThreadId = composerDraft.provider;
867+
const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES;
868+
const selectableProviders = useMemo(
869+
() =>
870+
getSelectableThreadProviders({
871+
statuses: providerStatuses,
872+
openclawGatewayUrl: settings.openclawGatewayUrl,
873+
}),
874+
[providerStatuses, settings.openclawGatewayUrl],
875+
);
861876
const hasThreadStarted = Boolean(
862877
activeThread &&
863878
(activeThread.latestTurn !== null ||
@@ -867,7 +882,12 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
867882
const lockedProvider: ProviderKind | null = hasThreadStarted
868883
? (sessionProvider ?? selectedProviderByThreadId ?? null)
869884
: null;
870-
const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex";
885+
const selectedProvider: ProviderKind =
886+
lockedProvider ??
887+
resolveThreadProviderSelection({
888+
preferredProvider: selectedProviderByThreadId,
889+
selectableProviders,
890+
});
871891
const baseThreadModel = resolveModelSlugForProvider(
872892
selectedProvider,
873893
activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),
@@ -907,20 +927,18 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
907927
}, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);
908928
const searchableModelOptions = useMemo(
909929
() =>
910-
AVAILABLE_PROVIDER_OPTIONS.filter(
911-
(option) => lockedProvider === null || option.value === lockedProvider,
912-
).flatMap((option) =>
913-
modelOptionsByProvider[option.value].map(({ slug, name }) => ({
914-
provider: option.value,
915-
providerLabel: option.label,
930+
(lockedProvider !== null ? [lockedProvider] : selectableProviders).flatMap((provider) =>
931+
modelOptionsByProvider[provider].map(({ slug, name }) => ({
932+
provider,
933+
providerLabel: getThreadProviderLabel(provider),
916934
slug,
917935
name,
918936
searchSlug: slug.toLowerCase(),
919937
searchName: name.toLowerCase(),
920-
searchProvider: option.label.toLowerCase(),
938+
searchProvider: getThreadProviderLabel(provider).toLowerCase(),
921939
})),
922940
),
923-
[lockedProvider, modelOptionsByProvider],
941+
[lockedProvider, modelOptionsByProvider, selectableProviders],
924942
);
925943
const phase = derivePhase(activeThread?.session ?? null);
926944
const isSendBusy = sendPhase !== "idle";
@@ -1313,7 +1331,6 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
13131331
);
13141332
const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
13151333
const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
1316-
const serverConfigQuery = useQuery(serverConfigQueryOptions());
13171334
const workspaceEntriesQuery = useQuery(
13181335
projectSearchEntriesQueryOptions({
13191336
cwd: gitCwd,
@@ -1535,7 +1552,6 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
15351552
};
15361553
}, []);
15371554
const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
1538-
const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES;
15391555
const activeProviderStatus = useMemo(
15401556
() => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,
15411557
[selectedProvider, providerStatuses],
@@ -5319,6 +5335,7 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
53195335
provider={selectedProvider}
53205336
model={selectedModelForPickerWithCustomFallback}
53215337
lockedProvider={lockedProvider}
5338+
availableProviders={selectableProviders}
53225339
modelOptionsByProvider={modelOptionsByProvider}
53235340
{...(composerProviderState.modelPickerIconClassName
53245341
? {

apps/web/src/components/chat/ProviderModelPicker.browser.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ async function mountPicker(props: {
2222
provider: ProviderKind;
2323
model: ModelSlug;
2424
lockedProvider: ProviderKind | null;
25+
availableProviders?: ReadonlyArray<ProviderKind>;
2526
}) {
2627
const host = document.createElement("div");
2728
document.body.append(host);
@@ -31,6 +32,7 @@ async function mountPicker(props: {
3132
provider={props.provider}
3233
model={props.model}
3334
lockedProvider={props.lockedProvider}
35+
availableProviders={props.availableProviders ?? ["codex", "claudeAgent", "openclaw"]}
3436
modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER}
3537
onProviderModelChange={onProviderModelChange}
3638
/>,
@@ -56,6 +58,7 @@ describe("ProviderModelPicker", () => {
5658
provider: "claudeAgent",
5759
model: "claude-opus-4-6",
5860
lockedProvider: null,
61+
availableProviders: ["codex", "claudeAgent"],
5962
});
6063

6164
try {
@@ -114,4 +117,26 @@ describe("ProviderModelPicker", () => {
114117
await mounted.cleanup();
115118
}
116119
});
120+
121+
it("only shows authenticated providers when switching is allowed", async () => {
122+
const mounted = await mountPicker({
123+
provider: "codex",
124+
model: "gpt-5-codex",
125+
lockedProvider: null,
126+
availableProviders: ["codex"],
127+
});
128+
129+
try {
130+
await page.getByRole("button").click();
131+
132+
await vi.waitFor(() => {
133+
const text = document.body.textContent ?? "";
134+
expect(text).toContain("Codex");
135+
expect(text).toContain("GPT-5.3 Codex");
136+
expect(text).not.toContain("Claude Code");
137+
});
138+
} finally {
139+
await mounted.cleanup();
140+
}
141+
});
117142
});

apps/web/src/components/chat/ProviderModelPicker.tsx

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,7 @@ import {
1717
} from "../ui/menu";
1818
import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenClawIcon, OpenCodeIcon } from "../Icons";
1919
import { cn } from "~/lib/utils";
20-
21-
function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
22-
value: ProviderKind;
23-
label: string;
24-
available: true;
25-
} {
26-
return option.available;
27-
}
20+
import { getThreadProviderLabel } from "~/lib/providerAvailability";
2821

2922
const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
3023
codex: OpenAI,
@@ -33,7 +26,6 @@ const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
3326
cursor: CursorIcon,
3427
};
3528

36-
export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption);
3729
const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available);
3830
const COMING_SOON_PROVIDER_OPTIONS = [
3931
{ id: "opencode", label: "OpenCode", icon: OpenCodeIcon },
@@ -50,13 +42,14 @@ function providerIconClassName(
5042
}
5143

5244
function getProviderLabel(provider: ProviderKind): string {
53-
return AVAILABLE_PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider;
45+
return getThreadProviderLabel(provider);
5446
}
5547

5648
export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5749
provider: ProviderKind;
5850
model: ModelSlug;
5951
lockedProvider: ProviderKind | null;
52+
availableProviders: ReadonlyArray<ProviderKind>;
6053
modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
6154
activeProviderIconClassName?: string;
6255
compact?: boolean;
@@ -65,6 +58,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
6558
}) {
6659
const [isMenuOpen, setIsMenuOpen] = useState(false);
6760
const activeProvider = props.lockedProvider ?? props.provider;
61+
const visibleProviders =
62+
props.lockedProvider !== null ? [props.lockedProvider] : props.availableProviders;
6863
const selectedProviderOptions = props.modelOptionsByProvider[activeProvider];
6964
const selectedModelLabel =
7065
selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model;
@@ -147,7 +142,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
147142
</MenuGroup>
148143
) : (
149144
<>
150-
{AVAILABLE_PROVIDER_OPTIONS.map((option, index) => {
145+
{visibleProviders.map((provider, index) => {
146+
const option = {
147+
value: provider,
148+
label: getThreadProviderLabel(provider),
149+
};
151150
const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
152151
return (
153152
<MenuGroup key={option.value}>

apps/web/src/components/home/home-utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export function getProviderLabel(provider: ServerProviderStatus["provider"]) {
1010
return "Claude";
1111
case "codex":
1212
return "Codex";
13+
case "openclaw":
14+
return "OpenClaw";
1315
}
1416
}
1517

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { ProviderKind, ServerProviderStatus } from "@okcode/contracts";
4+
import {
5+
getSelectableThreadProviders,
6+
isProviderReadyForThreadSelection,
7+
resolveThreadProviderSelection,
8+
} from "./providerAvailability";
9+
10+
function makeStatus(
11+
provider: ProviderKind,
12+
overrides: Partial<ServerProviderStatus> = {},
13+
): ServerProviderStatus {
14+
return {
15+
provider,
16+
status: "ready",
17+
available: true,
18+
authStatus: "authenticated",
19+
checkedAt: "2026-04-12T12:00:00.000Z",
20+
...overrides,
21+
};
22+
}
23+
24+
describe("providerAvailability", () => {
25+
it("allows ready authenticated CLI providers", () => {
26+
expect(
27+
isProviderReadyForThreadSelection({
28+
provider: "codex",
29+
statuses: [makeStatus("codex")],
30+
}),
31+
).toBe(true);
32+
});
33+
34+
it("allows ready providers with unknown auth when auth is handled externally", () => {
35+
expect(
36+
isProviderReadyForThreadSelection({
37+
provider: "codex",
38+
statuses: [makeStatus("codex", { authStatus: "unknown" })],
39+
}),
40+
).toBe(true);
41+
});
42+
43+
it("blocks providers that are explicitly unauthenticated", () => {
44+
expect(
45+
isProviderReadyForThreadSelection({
46+
provider: "claudeAgent",
47+
statuses: [makeStatus("claudeAgent", { status: "error", authStatus: "unauthenticated" })],
48+
}),
49+
).toBe(false);
50+
});
51+
52+
it("treats configured OpenClaw as selectable even when server auth state is unknown", () => {
53+
expect(
54+
isProviderReadyForThreadSelection({
55+
provider: "openclaw",
56+
statuses: [],
57+
openclawGatewayUrl: "ws://localhost:8080",
58+
}),
59+
).toBe(true);
60+
});
61+
62+
it("returns selectable providers in stable picker order", () => {
63+
expect(
64+
getSelectableThreadProviders({
65+
statuses: [
66+
makeStatus("openclaw", { authStatus: "unknown" }),
67+
makeStatus("codex"),
68+
makeStatus("claudeAgent", { status: "error", authStatus: "unauthenticated" }),
69+
],
70+
}),
71+
).toEqual(["codex", "openclaw"]);
72+
});
73+
74+
it("falls back to the first selectable provider when the preferred one is unavailable", () => {
75+
expect(
76+
resolveThreadProviderSelection({
77+
preferredProvider: "claudeAgent",
78+
selectableProviders: ["codex", "openclaw"],
79+
}),
80+
).toBe("codex");
81+
});
82+
});

0 commit comments

Comments
 (0)