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
2 changes: 2 additions & 0 deletions .github/workflows/ci-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ jobs:
node --test apps/desktop/test/fresh-install-engine.test.mjs
pnpm --filter openwork-server test:fresh-install-engine
bun test apps/server/test/engine-auto-upgrade-boot.test.ts apps/server/test/engine-auto-upgrade.test.ts apps/server/test/engine-auto-upgrade-safety.test.ts
- name: Test providers reaching OpenCode v2 and the organization's provider rule
run: bun test apps/server/test/engine-v2-provider-mapping.test.ts apps/server/test/opencode-v2-local-auth.test.ts
- name: Test the self-hosted web UI stays on its own origin
run: bun --conditions=development test apps/server/test/web-ui-origin.test.ts
- name: Test OneDrive and SharePoint pulls and saves
Expand Down
29 changes: 29 additions & 0 deletions apps/app/src/app/lib/openwork-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,38 @@ export interface EngineV2PreviewStatus {
v1HistoryAvailable?: boolean;
mirroredProviderIds: string[];
skippedProviderIds: string[];
/** Why each skipped provider is not on OpenCode v2. Older servers, and the rollout switched off, omit it. */
skippedProviders?: EngineV2SkippedProvider[];
catalogModelIds: string[];
lastMirroredAt?: string;
lastError?: string;
}

/**
* `policy`: the organization allows only its own providers. Every other
* reason means the provider is connected on this computer but OpenCode v2
* cannot use it yet (`oauth` sign-ins, `unsupported` adapters, `metadata` or
* `credentials` it could not read). A reason a newer server adds reads as
* `unsupported`, never as ready.
*/
export type EngineV2SkippedProviderReason = "policy" | "oauth" | "unsupported" | "metadata" | "credentials";

export interface EngineV2SkippedProvider {
id: string;
reason: EngineV2SkippedProviderReason;
}

const ENGINE_V2_SKIP_REASONS: readonly EngineV2SkippedProviderReason[] = ["policy", "oauth", "unsupported", "metadata", "credentials"];

function parseEngineV2SkippedProviders(value: unknown): EngineV2SkippedProvider[] | undefined {
if (!Array.isArray(value)) return undefined;
return value.flatMap((item): EngineV2SkippedProvider[] => {
if (!item || typeof item !== "object" || !("id" in item) || typeof item.id !== "string" || !item.id) return [];
const reason = "reason" in item ? ENGINE_V2_SKIP_REASONS.find((known) => known === item.reason) : undefined;
return [{ id: item.id, reason: reason ?? "unsupported" }];
});
}

const workspaceDefaultModelRefSchema = z.object({
providerID: z.string(),
modelID: z.string(),
Expand Down Expand Up @@ -215,6 +242,7 @@ function parseEngineV2PreviewStatus(value: unknown): EngineV2PreviewStatus {
) {
throw new Error("Invalid OpenCode v2 engine preview status response.");
}
const skippedProviders = parseEngineV2SkippedProviders("skippedProviders" in value ? value.skippedProviders : undefined);
return {
migration: parseEngineV2Migration("migration" in value ? value.migration : undefined),
...("v1HistoryAvailable" in value && typeof value.v1HistoryAvailable === "boolean"
Expand All @@ -235,6 +263,7 @@ function parseEngineV2PreviewStatus(value: unknown): EngineV2PreviewStatus {
binSource: "binSource" in value && typeof value.binSource === "string" ? value.binSource : undefined,
mirroredProviderIds: value.mirroredProviderIds,
skippedProviderIds: value.skippedProviderIds,
...(skippedProviders ? { skippedProviders } : {}),
catalogModelIds: value.catalogModelIds,
lastMirroredAt: "lastMirroredAt" in value && typeof value.lastMirroredAt === "string" ? value.lastMirroredAt : undefined,
lastError: "lastError" in value && typeof value.lastError === "string" ? value.lastError : undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { EngineV2PreviewStatus, OpenworkServerClient } from "@/app/lib/openwork-server";
import { isCloudManagedProviderKey } from "./cloud-provider-config";

/**
* How a provider on this device stands on the engine chat runs on, when it
* cannot be used there. A provider without one is ready.
* - `managed_by_organization`: the organization allows only its own providers.
* - `not_on_engine`: OpenCode v2 cannot use it yet (a sign-in, or no adapter).
*/
export type LocalProviderEngineState = "managed_by_organization" | "not_on_engine";

export type LocalProviderEngineStatus = Pick<EngineV2PreviewStatus, "enabled" | "chatRouting" | "skippedProviders">;

/**
* Read from the server's skipped list, never guessed: a provider OpenCode v2
* skipped is never "Ready to use". Only while chat runs on v2 and the server
* explains its skips (the `localProviderMirror` rollout); otherwise nothing
* changes. Organization providers keep their own rows and states.
*/
export function localProviderEngineStates(status: LocalProviderEngineStatus | null | undefined): ReadonlyMap<string, LocalProviderEngineState> {
if (!status?.enabled || !status.chatRouting || !status.skippedProviders) return new Map();
return new Map(status.skippedProviders.flatMap((provider): Array<[string, LocalProviderEngineState]> =>
isCloudManagedProviderKey(provider.id)
? []
: [[provider.id, provider.reason === "policy" ? "managed_by_organization" : "not_on_engine"]]));
}

/**
* The engine states for Settings › AI Providers. `refreshKey` changes when the
* connected providers do; the server finishes mirroring a saved key before
* the save returns, so the next read already explains it.
*/
export function useLocalProviderEngineStates(client: OpenworkServerClient | null, refreshKey: string): ReadonlyMap<string, LocalProviderEngineState> {
const status = useQuery({
queryKey: ["local-provider-engine-states", client?.baseUrl ?? "", client?.token ?? "", refreshKey],
queryFn: () => (client ? client.getEngineV2PreviewStatus() : null),
enabled: Boolean(client),
staleTime: 30_000,
retry: false,
});
return useMemo(() => localProviderEngineStates(status.data), [status.data]);
}
8 changes: 6 additions & 2 deletions apps/app/src/react-app/domains/settings/pages/ai-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { t } from "@/i18n";
import { gatewayConnectProviderKey, type GatewayConnectProvider, isCloudManagedProviderKey } from "../../connections/provider-auth/cloud-provider-config";
import { isKeylessZenProvider } from "../../connections/provider-auth/provider-policy";
import type { ProviderLoadState } from "../../connections/provider-auth/store";
import type { LocalProviderEngineState } from "../../connections/provider-auth/local-provider-engine-state";
import { AUTO_PROVIDER_ID, autoProviderSubtitle } from "@/react-app/domains/models/model-catalog";
import { SettingsNotice } from "../settings-section";
import { LayoutStack } from "../settings-layout";
import { SettingsPanel, SettingsPanelDescription, SettingsPanelHeading, SettingsPanelTitle } from "../shell/panel";
import { getSettingsTabLabel } from "../shell/settings-page";
import { ProviderList, ProviderMeta, ProviderRow, ProviderStatus, ProviderTile } from "./provider-rows";
import { LocalProviderStatus, ProviderList, ProviderMeta, ProviderRow, ProviderStatus, ProviderTile } from "./provider-rows";

export type ConnectedProvider = {
id: string;
Expand Down Expand Up @@ -59,6 +60,8 @@ export type AiSettingsViewProps = {
autoError?: string | null;
onSetAutoEnabled?: (enabled: boolean) => void | Promise<void>;
organizationProviderIds?: ReadonlySet<string>;
/** Providers on this device the engine chat runs on cannot use, from the server's skipped list. */
localProviderStates?: ReadonlyMap<string, LocalProviderEngineState>;
onOpenDen?: () => void;
};

Expand Down Expand Up @@ -153,10 +156,11 @@ export function AiSettingsView(props: AiSettingsViewProps) {
/> : null}
{local.map((provider) => <ProviderRow
key={provider.id}
testId={`local-provider-${provider.id}`}
scope="device"
tile={<ProviderTile providerId={provider.id} name={provider.name} />}
name={provider.name}
status={<ProviderStatus tone="ready">Ready to use</ProviderStatus>}
status={<LocalProviderStatus state={props.localProviderStates?.get(provider.id)} organizationName={props.organizationName} />}
meta={<ProviderMeta id={provider.id} parts={[deviceSourceLabel(provider.source), props.organizationProviderIds?.has(provider.id) ? `also available from ${props.organizationName || "your organization"}` : null]} />}
actions={<Button variant="ghost" disabled={rowsBusy || !ready || !props.canDisconnectProvider(provider)} onClick={() => void props.onDisconnectProvider(provider.id)}>{props.disconnectingProviderId === provider.id ? t("settings.disconnecting") : props.canDisconnectProvider(provider) ? t("settings.disconnect") : t("settings.managed_by_env")}</Button>}
/>)}
Expand Down
20 changes: 18 additions & 2 deletions apps/app/src/react-app/domains/settings/pages/provider-rows.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { ReactNode } from "react";
import { Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import type { LocalProviderEngineState } from "../../connections/provider-auth/local-provider-engine-state";
import { ProviderIcon } from "../../../design-system/provider-icon";
import { resolveExtensionIconSrc } from "@/react-app/design-system/extension-icon-src";

// Shared rows for Settings › AI Providers and the Connect a provider sheet,
// so both surfaces render a provider the same way (Paper boards 09–11).

export type ProviderStatusTone = "ready" | "attention" | "neutral" | "error";
export type ProviderStatusTone = "ready" | "attention" | "neutral" | "locked" | "error";

/** A provider mark in a rounded tile; OpenWork's own mark sits on a filled tile. */
export function ProviderTile({ providerId, name, size = "md" }: { providerId: string; name?: string; size?: "sm" | "md" }) {
Expand All @@ -24,11 +26,14 @@ export function ProviderTile({ providerId, name, size = "md" }: { providerId: st
);
}

/** "● Ready to use" style status; attention renders as a soft pill. */
/** "● Ready to use" style status; attention renders as a soft pill, locked as neutral ink with a lock (blocked is not an error). */
export function ProviderStatus({ tone, children }: { tone: ProviderStatusTone; children: ReactNode }) {
if (tone === "attention") {
return <span className="rounded-full bg-amber-3 px-2 py-0.5 text-xs font-medium text-amber-11">{children}</span>;
}
if (tone === "locked") {
return <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><Lock aria-hidden className="size-3" />{children}</span>;
}
return (
<span className={cn(
"inline-flex items-center gap-1.5 text-xs",
Expand All @@ -43,6 +48,17 @@ export function ProviderStatus({ tone, children }: { tone: ProviderStatusTone; c
);
}

/**
* A provider on this device as the engine chat runs on sees it: ready, held
* back by the organization (a lock in neutral ink), or not usable on the new
* engine yet. Never "Ready to use" for a provider the engine skipped.
*/
export function LocalProviderStatus({ state, organizationName }: { state?: LocalProviderEngineState; organizationName?: string }) {
if (state === "managed_by_organization") return <ProviderStatus tone="locked">{organizationName || "Your organization"} manages AI providers</ProviderStatus>;
if (state === "not_on_engine") return <ProviderStatus tone="neutral">Not available on the new engine yet</ProviderStatus>;
return <ProviderStatus tone="ready">Ready to use</ProviderStatus>;
}

/** One meta line: an optional monospace id, then plain parts joined by middle dots. */
export function ProviderMeta({ id, parts }: { id?: string; parts: ReadonlyArray<string | null | undefined | false> }) {
const rest = parts.filter((part): part is string => typeof part === "string" && part.trim().length > 0);
Expand Down
33 changes: 24 additions & 9 deletions apps/app/src/react-app/shell/settings-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import ProviderAuthModal, { PROVIDER_LABELS } from "@/react-app/domains/connecti
import { keylessProviderIds } from "@/react-app/domains/connections/provider-auth/provider-policy";
import ConnectionsModals from "@/react-app/domains/connections/modals";
import { AiSettingsView } from "@/react-app/domains/settings/pages/ai-view";
import { useLocalProviderEngineStates } from "@/react-app/domains/connections/provider-auth/local-provider-engine-state";
// Side-effect imports: register extension config components into the registry.
import { OllamaConfig } from "@/react-app/domains/settings/ollama-config";
import "@/react-app/domains/settings/browser-extension-config";
Expand Down Expand Up @@ -1804,16 +1805,29 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) {
const disabledProviderIdSet = new Set(
disabledProviders.map((id) => id.trim().toLowerCase()).filter(Boolean),
);
const connectedProviders = providers.flatMap((provider) =>
providerConnectedIdSet.has(provider.id) &&
!disabledProviderIdSet.has(provider.id.trim().toLowerCase())
? [{
id: provider.id,
name: provider.name ?? provider.id,
source: provider.source,
}]
: [],
const localProviderStates = useLocalProviderEngineStates(
selectedWorkspaceEndpoint?.client ?? openworkClient,
providerConnectedIds.join("\n"),
);
const connectedProviders = [
...providers.flatMap((provider) =>
providerConnectedIdSet.has(provider.id) &&
!disabledProviderIdSet.has(provider.id.trim().toLowerCase())
? [{
id: provider.id,
name: provider.name ?? provider.id,
source: provider.source,
}]
: [],
),
// v1 leaves a provider the organization does not allow out of its list.
// On v2 it stays visible with the reason instead of disappearing.
...[...localProviderStates.keys()].flatMap((id) =>
providerConnectedIdSet.has(id) || disabledProviderIdSet.has(id.trim().toLowerCase())
? []
: [{ id, name: providers.find((provider) => provider.id === id)?.name ?? PROVIDER_LABELS[id.toLowerCase()] ?? id }],
),
];
// Disconnect hides built-in/config providers through disabled_providers, and
// the engine then omits them everywhere. List them so they can come back.
const hiddenProviders = [...new Set(disabledProviders.map((id) => id.trim()).filter(Boolean))].flatMap((id) =>
Expand Down Expand Up @@ -2220,6 +2234,7 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) {
canAddProviders={!providerAuthStore.isProviderAddRestricted()}
signedIn={cloudSession.isSignedIn}
organizationName={cloudSession.activeOrgName}
localProviderStates={localProviderStates}
cloudProviderIds={new Set([
...Object.values(providerAuthSnapshot.importedCloudProviders ?? {}).map((p) => p.providerId),
...(openWorkModelsEntitled || openWorkModelsAvailable ? ["openwork"] : []),
Expand Down
72 changes: 72 additions & 0 deletions apps/app/tests/local-provider-engine-state.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/** @jsxImportSource react */
import { afterEach, expect, mock, spyOn, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { createOpenworkServerClient } from "../src/app/lib/openwork-server";
import { localProviderEngineStates } from "../src/react-app/domains/connections/provider-auth/local-provider-engine-state";
import { LocalProviderStatus } from "../src/react-app/domains/settings/pages/provider-rows";

const status = {
enabled: true,
chatRouting: true,
skippedProviders: [
{ id: "openrouter", reason: "policy" as const },
{ id: "openai", reason: "oauth" as const },
{ id: "google", reason: "unsupported" as const },
{ id: "lpr_team", reason: "credentials" as const },
],
};

afterEach(() => {
mock.restore();
});

test("a provider OpenCode v2 skipped is never ready, and the reason decides its state", () => {
const states = localProviderEngineStates(status);
expect([...states]).toEqual([
["openrouter", "managed_by_organization"],
["openai", "not_on_engine"],
["google", "not_on_engine"],
]);
expect(states.has("lpr_team")).toBe(false);
});

test("nothing changes on v1, while v2 is not chat's engine, or when the server does not explain its skips", () => {
expect(localProviderEngineStates({ ...status, chatRouting: false }).size).toBe(0);
expect(localProviderEngineStates({ ...status, enabled: false }).size).toBe(0);
expect(localProviderEngineStates({ enabled: true, chatRouting: true }).size).toBe(0);
expect(localProviderEngineStates(null).size).toBe(0);
});

test("rows read Ready to use, the organization's lock, or not available on the new engine", () => {
const ready = renderToStaticMarkup(<LocalProviderStatus />);
const managed = renderToStaticMarkup(<LocalProviderStatus state="managed_by_organization" organizationName="Acme" />);
const unnamed = renderToStaticMarkup(<LocalProviderStatus state="managed_by_organization" />);
const unavailable = renderToStaticMarkup(<LocalProviderStatus state="not_on_engine" />);

expect(ready).toContain("Ready to use");
expect(managed).toContain("Acme manages AI providers");
expect(managed).toContain("lucide-lock");
expect(managed).not.toContain("text-red");
expect(unnamed).toContain("Your organization manages AI providers");
expect(unavailable).toContain("Not available on the new engine yet");
expect(unavailable).not.toContain("Ready to use");
});

test("the status client reads skip reasons, keeps older servers working and never reads an unknown reason as ready", async () => {
const body = {
enabled: true, running: true, chatRouting: true,
mirroredProviderIds: ["openrouter"], skippedProviderIds: ["openai", "future"], catalogModelIds: [],
skippedProviders: [{ id: "openai", reason: "oauth" }, { id: "future", reason: "something-new" }, { reason: "policy" }],
};
const fetch = spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(body), { status: 200 }));
const client = createOpenworkServerClient({ baseUrl: "http://localhost:8787", token: "test" });

const current = await client.getEngineV2PreviewStatus();
expect(current.skippedProviders).toEqual([{ id: "openai", reason: "oauth" }, { id: "future", reason: "unsupported" }]);

const { skippedProviders: _omitted, ...older } = body;
fetch.mockResolvedValue(new Response(JSON.stringify(older), { status: 200 }));
const legacy = await client.getEngineV2PreviewStatus();
expect(legacy.skippedProviders).toBeUndefined();
expect(legacy.skippedProviderIds).toEqual(["openai", "future"]);
});
Loading
Loading