Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 21 additions & 21 deletions apps/server/src/provider/Layers/ProviderHealth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/**
* ProviderHealthLive - Startup-time provider health checks.
*
* Performs one-time provider readiness probes when the server starts and
* keeps the resulting snapshot in memory for `server.getConfig`.
* Performs provider readiness probes on demand for `server.getConfig`.
*
* Uses effect's ChildProcessSpawner to run CLI probes natively.
*
Expand All @@ -14,18 +13,7 @@ import type {
ServerProviderStatus,
ServerProviderStatusState,
} from "@okcode/contracts";
import {
Array,
Data,
Effect,
Fiber,
FileSystem,
Layer,
Option,
Path,
Result,
Stream,
} from "effect";
import { Array, Data, Effect, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import {
Expand Down Expand Up @@ -686,15 +674,27 @@ const checkOpenClawProviderStatus: Effect.Effect<ServerProviderStatus, never, ne
export const ProviderHealthLive = Layer.effect(
ProviderHealth,
Effect.gen(function* () {
const statusesFiber = yield* Effect.all(
[checkCodexProviderStatus, checkClaudeProviderStatus, checkOpenClawProviderStatus],
{
concurrency: "unbounded",
},
).pipe(Effect.forkScoped);
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

return {
getStatuses: Fiber.join(statusesFiber),
getStatuses: Effect.all(
[
checkCodexProviderStatus.pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
),
checkClaudeProviderStatus.pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
checkOpenClawProviderStatus,
],
{
concurrency: "unbounded",
},
),
} satisfies ProviderHealthShape;
}),
);
41 changes: 29 additions & 12 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer";
import { useNavigate } from "@tanstack/react-router";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
import {
getSelectableThreadProviders,
getThreadProviderLabel,
resolveThreadProviderSelection,
} from "~/lib/providerAvailability";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
import {
skillCatalogQueryOptions,
Expand Down Expand Up @@ -194,7 +199,7 @@ import { useDiffViewerStore } from "~/diffViewerStore";
import { PreviewPanel } from "./PreviewPanel";
import { ContextWindowMeter } from "./chat/ContextWindowMeter";
import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
import { ProviderModelPicker } from "./chat/ProviderModelPicker";
import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu";
import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions";
import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu";
Expand Down Expand Up @@ -856,8 +861,18 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
markThreadVisited,
]);

const serverConfigQuery = useQuery(serverConfigQueryOptions());
const sessionProvider = activeThread?.session?.provider ?? null;
const selectedProviderByThreadId = composerDraft.provider;
const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES;
const selectableProviders = useMemo(
() =>
getSelectableThreadProviders({
statuses: providerStatuses,
openclawGatewayUrl: settings.openclawGatewayUrl,
}),
[providerStatuses, settings.openclawGatewayUrl],
);
const hasThreadStarted = Boolean(
activeThread &&
(activeThread.latestTurn !== null ||
Expand All @@ -867,7 +882,12 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
const lockedProvider: ProviderKind | null = hasThreadStarted
? (sessionProvider ?? selectedProviderByThreadId ?? null)
: null;
const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex";
const selectedProvider: ProviderKind =
lockedProvider ??
resolveThreadProviderSelection({
preferredProvider: selectedProviderByThreadId,
selectableProviders,
});
const baseThreadModel = resolveModelSlugForProvider(
selectedProvider,
activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),
Expand Down Expand Up @@ -907,20 +927,18 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
}, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);
const searchableModelOptions = useMemo(
() =>
AVAILABLE_PROVIDER_OPTIONS.filter(
(option) => lockedProvider === null || option.value === lockedProvider,
).flatMap((option) =>
modelOptionsByProvider[option.value].map(({ slug, name }) => ({
provider: option.value,
providerLabel: option.label,
(lockedProvider !== null ? [lockedProvider] : selectableProviders).flatMap((provider) =>
modelOptionsByProvider[provider].map(({ slug, name }) => ({
provider,
providerLabel: getThreadProviderLabel(provider),
slug,
name,
searchSlug: slug.toLowerCase(),
searchName: name.toLowerCase(),
searchProvider: option.label.toLowerCase(),
searchProvider: getThreadProviderLabel(provider).toLowerCase(),
})),
),
[lockedProvider, modelOptionsByProvider],
[lockedProvider, modelOptionsByProvider, selectableProviders],
);
const phase = derivePhase(activeThread?.session ?? null);
const isSendBusy = sendPhase !== "idle";
Expand Down Expand Up @@ -1313,7 +1331,6 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
);
const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
const serverConfigQuery = useQuery(serverConfigQueryOptions());
const workspaceEntriesQuery = useQuery(
projectSearchEntriesQueryOptions({
cwd: gitCwd,
Expand Down Expand Up @@ -1535,7 +1552,6 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
};
}, []);
const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES;
const activeProviderStatus = useMemo(
() => providerStatuses.find((status) => status.provider === selectedProvider) ?? null,
[selectedProvider, providerStatuses],
Expand Down Expand Up @@ -5319,6 +5335,7 @@ export default function ChatView({ threadId, onMinimize }: ChatViewProps) {
provider={selectedProvider}
model={selectedModelForPickerWithCustomFallback}
lockedProvider={lockedProvider}
availableProviders={selectableProviders}
modelOptionsByProvider={modelOptionsByProvider}
{...(composerProviderState.modelPickerIconClassName
? {
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/chat/ProviderModelPicker.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ async function mountPicker(props: {
provider: ProviderKind;
model: ModelSlug;
lockedProvider: ProviderKind | null;
availableProviders?: ReadonlyArray<ProviderKind>;
}) {
const host = document.createElement("div");
document.body.append(host);
Expand All @@ -31,6 +32,7 @@ async function mountPicker(props: {
provider={props.provider}
model={props.model}
lockedProvider={props.lockedProvider}
availableProviders={props.availableProviders ?? ["codex", "claudeAgent", "openclaw"]}
modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER}
onProviderModelChange={onProviderModelChange}
/>,
Expand All @@ -56,6 +58,7 @@ describe("ProviderModelPicker", () => {
provider: "claudeAgent",
model: "claude-opus-4-6",
lockedProvider: null,
availableProviders: ["codex", "claudeAgent"],
});

try {
Expand Down Expand Up @@ -114,4 +117,26 @@ describe("ProviderModelPicker", () => {
await mounted.cleanup();
}
});

it("only shows authenticated providers when switching is allowed", async () => {
const mounted = await mountPicker({
provider: "codex",
model: "gpt-5-codex",
lockedProvider: null,
availableProviders: ["codex"],
});

try {
await page.getByRole("button").click();

await vi.waitFor(() => {
const text = document.body.textContent ?? "";
expect(text).toContain("Codex");
expect(text).toContain("GPT-5.3 Codex");
expect(text).not.toContain("Claude Code");
});
} finally {
await mounted.cleanup();
}
});
});
21 changes: 10 additions & 11 deletions apps/web/src/components/chat/ProviderModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,7 @@ import {
} from "../ui/menu";
import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenClawIcon, OpenCodeIcon } from "../Icons";
import { cn } from "~/lib/utils";

function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
value: ProviderKind;
label: string;
available: true;
} {
return option.available;
}
import { getThreadProviderLabel } from "~/lib/providerAvailability";

const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
codex: OpenAI,
Expand All @@ -33,7 +26,6 @@ const PROVIDER_ICON_BY_PROVIDER: Record<ProviderPickerKind, Icon> = {
cursor: CursorIcon,
};

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

function getProviderLabel(provider: ProviderKind): string {
return AVAILABLE_PROVIDER_OPTIONS.find((option) => option.value === provider)?.label ?? provider;
return getThreadProviderLabel(provider);
}

export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
provider: ProviderKind;
model: ModelSlug;
lockedProvider: ProviderKind | null;
availableProviders: ReadonlyArray<ProviderKind>;
modelOptionsByProvider: Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>>;
activeProviderIconClassName?: string;
compact?: boolean;
Expand All @@ -65,6 +58,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
}) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const activeProvider = props.lockedProvider ?? props.provider;
const visibleProviders =
props.lockedProvider !== null ? [props.lockedProvider] : props.availableProviders;
const selectedProviderOptions = props.modelOptionsByProvider[activeProvider];
const selectedModelLabel =
selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model;
Expand Down Expand Up @@ -147,7 +142,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
</MenuGroup>
) : (
<>
{AVAILABLE_PROVIDER_OPTIONS.map((option, index) => {
{visibleProviders.map((provider, index) => {
const option = {
value: provider,
label: getThreadProviderLabel(provider),
};
const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value];
return (
<MenuGroup key={option.value}>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/home/home-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export function getProviderLabel(provider: ServerProviderStatus["provider"]) {
return "Claude";
case "codex":
return "Codex";
case "openclaw":
return "OpenClaw";
}
}

Expand Down
82 changes: 82 additions & 0 deletions apps/web/src/lib/providerAvailability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";

import type { ProviderKind, ServerProviderStatus } from "@okcode/contracts";
import {
getSelectableThreadProviders,
isProviderReadyForThreadSelection,
resolveThreadProviderSelection,
} from "./providerAvailability";

function makeStatus(
provider: ProviderKind,
overrides: Partial<ServerProviderStatus> = {},
): ServerProviderStatus {
return {
provider,
status: "ready",
available: true,
authStatus: "authenticated",
checkedAt: "2026-04-12T12:00:00.000Z",
...overrides,
};
}

describe("providerAvailability", () => {
it("allows ready authenticated CLI providers", () => {
expect(
isProviderReadyForThreadSelection({
provider: "codex",
statuses: [makeStatus("codex")],
}),
).toBe(true);
});

it("allows ready providers with unknown auth when auth is handled externally", () => {
expect(
isProviderReadyForThreadSelection({
provider: "codex",
statuses: [makeStatus("codex", { authStatus: "unknown" })],
}),
).toBe(true);
});

it("blocks providers that are explicitly unauthenticated", () => {
expect(
isProviderReadyForThreadSelection({
provider: "claudeAgent",
statuses: [makeStatus("claudeAgent", { status: "error", authStatus: "unauthenticated" })],
}),
).toBe(false);
});

it("treats configured OpenClaw as selectable even when server auth state is unknown", () => {
expect(
isProviderReadyForThreadSelection({
provider: "openclaw",
statuses: [],
openclawGatewayUrl: "ws://localhost:8080",
}),
).toBe(true);
});

it("returns selectable providers in stable picker order", () => {
expect(
getSelectableThreadProviders({
statuses: [
makeStatus("openclaw", { authStatus: "unknown" }),
makeStatus("codex"),
makeStatus("claudeAgent", { status: "error", authStatus: "unauthenticated" }),
],
}),
).toEqual(["codex", "openclaw"]);
});

it("falls back to the first selectable provider when the preferred one is unavailable", () => {
expect(
resolveThreadProviderSelection({
preferredProvider: "claudeAgent",
selectableProviders: ["codex", "openclaw"],
}),
).toBe("codex");
});
});
Loading
Loading