diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index ad5f84a440..b0d83e613a 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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 diff --git a/apps/app/src/app/lib/openwork-server.ts b/apps/app/src/app/lib/openwork-server.ts index 2712e84d7b..68292ccf3c 100644 --- a/apps/app/src/app/lib/openwork-server.ts +++ b/apps/app/src/app/lib/openwork-server.ts @@ -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(), @@ -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" @@ -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, diff --git a/apps/app/src/react-app/domains/connections/provider-auth/local-provider-engine-state.ts b/apps/app/src/react-app/domains/connections/provider-auth/local-provider-engine-state.ts new file mode 100644 index 0000000000..4db0855ef1 --- /dev/null +++ b/apps/app/src/react-app/domains/connections/provider-auth/local-provider-engine-state.ts @@ -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; + +/** + * 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 { + 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 { + 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]); +} diff --git a/apps/app/src/react-app/domains/settings/pages/ai-view.tsx b/apps/app/src/react-app/domains/settings/pages/ai-view.tsx index 3f0d50bba9..39113c845d 100644 --- a/apps/app/src/react-app/domains/settings/pages/ai-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/ai-view.tsx @@ -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; @@ -59,6 +60,8 @@ export type AiSettingsViewProps = { autoError?: string | null; onSetAutoEnabled?: (enabled: boolean) => void | Promise; organizationProviderIds?: ReadonlySet; + /** Providers on this device the engine chat runs on cannot use, from the server's skipped list. */ + localProviderStates?: ReadonlyMap; onOpenDen?: () => void; }; @@ -153,10 +156,11 @@ export function AiSettingsView(props: AiSettingsViewProps) { /> : null} {local.map((provider) => } name={provider.name} - status={Ready to use} + status={} meta={} actions={} />)} diff --git a/apps/app/src/react-app/domains/settings/pages/provider-rows.tsx b/apps/app/src/react-app/domains/settings/pages/provider-rows.tsx index 85d4cbb22a..59c70bdae4 100644 --- a/apps/app/src/react-app/domains/settings/pages/provider-rows.tsx +++ b/apps/app/src/react-app/domains/settings/pages/provider-rows.tsx @@ -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" }) { @@ -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 {children}; } + if (tone === "locked") { + return {children}; + } return ( {organizationName || "Your organization"} manages AI providers; + if (state === "not_on_engine") return Not available on the new engine yet; + return Ready to use; +} + /** One meta line: an optional monospace id, then plain parts joined by middle dots. */ export function ProviderMeta({ id, parts }: { id?: string; parts: ReadonlyArray }) { const rest = parts.filter((part): part is string => typeof part === "string" && part.trim().length > 0); diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 95f9ceda0b..159571c7e9 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -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"; @@ -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) => @@ -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"] : []), diff --git a/apps/app/tests/local-provider-engine-state.test.tsx b/apps/app/tests/local-provider-engine-state.test.tsx new file mode 100644 index 0000000000..e2fa068207 --- /dev/null +++ b/apps/app/tests/local-provider-engine-state.test.tsx @@ -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(); + const managed = renderToStaticMarkup(); + const unnamed = renderToStaticMarkup(); + const unavailable = renderToStaticMarkup(); + + 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"]); +}); diff --git a/apps/server/src/engine-v2-preview.ts b/apps/server/src/engine-v2-preview.ts index 0e7b69f80e..5980313759 100644 --- a/apps/server/src/engine-v2-preview.ts +++ b/apps/server/src/engine-v2-preview.ts @@ -56,7 +56,10 @@ export function engineInstanceId(env: NodeJS.ProcessEnv = process.env): string | } import { findManagedEngineWorkspace, hasExternalOpencode } from "./workspaces.js"; import { globalSkillDirectories } from "./skills.js"; -import { localProviderDefinitions, readLocalProviderApiKeys } from "./opencode-v2-local-auth.js"; +import { localProviderDefinitions, readLocalProviderAuth, type EngineV2SkippedProvider, type EngineV2SkippedProviderReason } from "./opencode-v2-local-auth.js"; +import { organizationProviderGate } from "./organization-provider-gate.js"; + +export type { EngineV2SkippedProvider, EngineV2SkippedProviderReason } from "./opencode-v2-local-auth.js"; const OPENCODE_V2_VERSION = constants.opencodeV2Version; const PREVIEW_STATE_FILE = "engine-v2-preview.json"; @@ -104,6 +107,12 @@ export interface EngineV2PreviewStatus { binSource?: "env" | "path" | "cache"; mirroredProviderIds: string[]; skippedProviderIds: string[]; + /** + * Why each provider in `skippedProviderIds` is not on this engine, including + * providers connected on this computer that never reached the mirror. Only + * present while the `localProviderMirror` rollout is on. + */ + skippedProviders?: EngineV2SkippedProvider[]; catalogModelIds: string[]; lastMirroredAt?: string; lastError?: string; @@ -137,6 +146,8 @@ export interface EngineV2Preview { connection(): { url: string; username: string; password: string } | undefined; ensureWorkspaceReady(directory: string): Promise; refreshProviders(): Promise; + /** Mirror again when the `localProviderMirror` rollout changed since the last mirror, so turning it off applies without a restart. */ + recheckLocalProviderMirror(): Promise; setLiveProviderSync(enabled: boolean): Promise; /** * Reconcile a folder's MCP connections with the engine: register new or @@ -351,13 +362,14 @@ export function mapRuntimeProvidersToV2Specs( storedCredentials: ReadonlyMap = new Map(), localApiKeys: ReadonlyMap = new Map(), configuredProviderIds: ReadonlySet = new Set(), -): { specs: OpencodeV2ProviderSpec[]; skippedProviderIds: string[] } { +): { specs: OpencodeV2ProviderSpec[]; skippedProviderIds: string[]; skippedProviders: EngineV2SkippedProvider[] } { const specs: OpencodeV2ProviderSpec[] = []; - const skippedProviderIds: string[] = []; + const skippedProviders: EngineV2SkippedProvider[] = []; + const skip = (id: string, reason: EngineV2SkippedProviderReason) => { skippedProviders.push({ id, reason }); }; for (const [id, value] of Object.entries(providerMap)) { if (!isRecord(value)) { - skippedProviderIds.push(id); + skip(id, "metadata"); continue; } // Only built-in adapters: do not turn organization configuration into a @@ -385,7 +397,7 @@ export function mapRuntimeProvidersToV2Specs( } catch { return false; } }; if (!safeBaseUrl(baseUrl)) { - skippedProviderIds.push(id); + skip(id, "unsupported"); continue; } if (!explicitBaseUrl && value.api !== undefined) { @@ -400,12 +412,12 @@ export function mapRuntimeProvidersToV2Specs( trusted = typeof value.npm === "string" && Object.hasOwn(nativeOrigins, value.npm) && url.origin === nativeOrigins[value.npm]; } catch { /* Invalid endpoint metadata is never mirrored. */ } - if (!trusted && !isConfigured) { skippedProviderIds.push(id); continue; } + if (!trusted && !isConfigured) { skip(id, "unsupported"); continue; } } const packageName = typeof value.npm === "string" && Object.hasOwn(packages, value.npm) ? packages[value.npm] : undefined; if ((value.npm !== undefined && !packageName) || (!baseUrl && (!packageName || value.npm === "@ai-sdk/openai-compatible"))) { - skippedProviderIds.push(id); + skip(id, "unsupported"); continue; } const { apiKey, baseURL, headers, ...settings } = options; @@ -428,7 +440,7 @@ export function mapRuntimeProvidersToV2Specs( const explicitKey = typeof apiKey === "string" && apiKey.trim() !== "" && !apiKey.includes("{env:") ? apiKey : undefined; const resolvedKey = explicitKey ?? storedKey ?? localApiKeys.get(id); if (envNames.length > 0 && !resolvedKey) { - skippedProviderIds.push(id); + skip(id, "credentials"); continue; } const models = isRecord(value.models) @@ -455,8 +467,15 @@ export function mapRuntimeProvidersToV2Specs( } specs.sort((left, right) => left.id.localeCompare(right.id)); - skippedProviderIds.sort((left, right) => left.localeCompare(right)); - return { specs, skippedProviderIds }; + skippedProviders.sort((left, right) => left.id.localeCompare(right.id)); + return { specs, skippedProviderIds: skippedProviders.map(provider => provider.id), skippedProviders }; +} + +/** One entry per provider, sorted by id; the first reason given for a provider wins. */ +export function mergeSkippedProviders(...groups: ReadonlyArray): EngineV2SkippedProvider[] { + const merged = new Map(); + for (const provider of groups.flat()) if (!merged.has(provider.id)) merged.set(provider.id, provider); + return [...merged.values()].sort((left, right) => left.id.localeCompare(right.id)); } export function shouldSyncLiveProviderOwnerTransition(previous: boolean, next: boolean, rollout: boolean): boolean { @@ -584,6 +603,12 @@ export async function resolveLiveProviderSyncForEngineStart(resolve?: () => Prom catch { return false; } } +/** A rollout that cannot be read is off: the mirror keeps its older behavior. */ +export async function resolveLocalProviderMirror(resolve?: () => Promise): Promise { + try { return await resolve?.() === true; } + catch { return false; } +} + export function createEngineV2Preview(options: { config: ServerConfig; env?: Pick; @@ -592,6 +617,8 @@ export function createEngineV2Preview(options: { providerSession?: () => CloudProviderDenSession | null; /** Fresh Den resolution on every child start, independent of the cloud sync timer. */ resolveLiveProviderSync?: () => Promise; + /** The `localProviderMirror` rollout, read on every mirror. Missing, unreachable or signed out means off. */ + resolveLocalProviderMirror?: () => Promise; waits?: Partial; /** Same running-task probe used by the interactive migration route; unknown is not idle. */ autoUpgradeIdle?: () => Promise; @@ -649,6 +676,9 @@ export function createEngineV2Preview(options: { let binSource: EngineV2PreviewStatus["binSource"]; let mirroredProviderIds: string[] = []; let skippedProviderIds: string[] = []; + let skippedProviders: EngineV2SkippedProvider[] | undefined; + // The `localProviderMirror` value the last mirror ran with. + let appliedLocalProviderMirror: boolean | undefined; let currentCatalogModelIds: string[] = []; let lastMirroredAt: string | undefined; let lastError: string | undefined; @@ -839,6 +869,7 @@ export function createEngineV2Preview(options: { ...(binSource === undefined ? {} : { binSource }), mirroredProviderIds: [...mirroredProviderIds], skippedProviderIds: [...skippedProviderIds], + ...(skippedProviders === undefined ? {} : { skippedProviders: skippedProviders.map(provider => ({ ...provider })) }), catalogModelIds: [...currentCatalogModelIds], ...(lastMirroredAt === undefined ? {} : { lastMirroredAt }), ...(lastError === undefined ? {} : { lastError }), @@ -866,14 +897,25 @@ export function createEngineV2Preview(options: { } async function pushAndConfirmProviders(active: ManagedOpencodeV2Server, pushed: () => void, discovered: () => void): Promise { + const mirrorLocal = await resolveLocalProviderMirror(options.resolveLocalProviderMirror); + appliedLocalProviderMirror = mirrorLocal; const globalRuntime = await readGlobalRuntimeOpencodeConfig(config); const configured = runtimeProviderMap(globalRuntime); // Same list v1 receives as `disabled_providers` (e.g. a disconnected // OpenCode Zen). Re-enabling removes the entry and this mirror restores it. const disabledProviderIds = runtimeDisabledProviderList(globalRuntime); const disabled = new Set(disabledProviderIds); - const localKeys = await readLocalProviderApiKeys(); - const combinedProviders = { ...await localProviderDefinitions(config, localKeys, configured), ...configured }; + // "Only models you provide": the providers v1 omits from `enabled_providers` + // never reach v2 either, whether configured or connected on this computer. + const allowsProvider = organizationProviderGate(globalRuntime.managedPolicy); + const localAuth = await readLocalProviderAuth(); + const localKeys = allowsProvider + ? new Map([...localAuth.apiKeys].filter(([id]) => allowsProvider(id))) + : localAuth.apiKeys; + const local = await localProviderDefinitions(config, localAuth, configured, { normalize: mirrorLocal, allows: allowsProvider }); + const blockedConfigured = Object.keys(configured).filter(id => allowsProvider && !allowsProvider(id)); + const combinedProviders = { ...local.providers, ...Object.fromEntries(Object.entries(configured) + .filter(([id]) => !allowsProvider || allowsProvider(id))) }; // Native plugin is the sole Gateway owner. Local and legacy providers still // use the old mirror, including their normal readiness/catalog checks. const providerMap = Object.fromEntries(Object.entries(combinedProviders) @@ -907,7 +949,14 @@ export function createEngineV2Preview(options: { mirroredSpecs = specs.filter(spec => !ownsGateway() || !/^ipr_/i.test(spec.id)); workspaceReadiness.clear(); mirroredProviderIds = nextMirroredProviderIds.filter(id => !ownsGateway() || !/^ipr_/i.test(id)); - skippedProviderIds = [...new Set([...mapped.skippedProviderIds, ...templates.errors.map(error => error.providerId)])].sort(); + const skipped = mergeSkippedProviders( + local.skipped, + blockedConfigured.map((id): EngineV2SkippedProvider => ({ id, reason: "policy" })), + templates.errors.map((error): EngineV2SkippedProvider => ({ id: error.providerId, reason: "metadata" })), + mapped.skippedProviders, + ); + skippedProviderIds = skipped.map(provider => provider.id); + skippedProviders = mirrorLocal ? skipped : undefined; lastMirroredAt = new Date().toISOString(); pushed(); // Re-check folders already in use now, so their next prompt pays nothing. @@ -946,6 +995,11 @@ export function createEngineV2Preview(options: { if (mirrorError) throw new Error(mirrorError); } + async function recheckLocalProviderMirror(): Promise { + if (!running || !sidecar || appliedLocalProviderMirror === undefined) return; + if (await resolveLocalProviderMirror(options.resolveLocalProviderMirror) !== appliedLocalProviderMirror) scheduleMirror(); + } + async function setLiveProviderSync(next: boolean): Promise { // Signed-in cold boot delivers identity after the child starts. Activation // is one-way for this lifetime: one watched-config write removes the old @@ -1432,5 +1486,5 @@ export function createEngineV2Preview(options: { } else if (enabled && !automaticHistoryCopy && engineOrigin !== "reverted" && !(autoUpgradeEnabled && !chatRouting && !chosenV1Manually())) void start().catch(recordStartError); } if (!options.deferStart) startWhenReady(); - return { start: startWhenReady, setForcePolicy, migrateHistory, status, setEnabled, setChatRouting, requireV1HistoryCopied, describeUncopiedChat, connection, ensureWorkspaceReady, refreshProviders, setLiveProviderSync, syncWorkspaceMcp, settleWorkspaceMcp, warmWorkspace, settleWorkspaceSkills, stop }; + return { start: startWhenReady, setForcePolicy, migrateHistory, status, setEnabled, setChatRouting, requireV1HistoryCopied, describeUncopiedChat, connection, ensureWorkspaceReady, refreshProviders, recheckLocalProviderMirror, setLiveProviderSync, syncWorkspaceMcp, settleWorkspaceMcp, warmWorkspace, settleWorkspaceSkills, stop }; } diff --git a/apps/server/src/managed-desktop-policy.ts b/apps/server/src/managed-desktop-policy.ts index 1b3aaaf762..4721d15e1a 100644 --- a/apps/server/src/managed-desktop-policy.ts +++ b/apps/server/src/managed-desktop-policy.ts @@ -342,6 +342,10 @@ class ManagedDesktopPolicy { : agentPermissionRulesOf((await readGlobalRuntimeOpencodeConfig(this.config)).managedPolicy); return rules.filter((rule) => rule.action === "webfetch"); } + /** The desktop config verified for this sign-in, without waiting on Den. Null signed out or before Den first answered. */ + known(): DesktopConfig | null { + return this.knownPolicy(); + } /** The policy to enforce now: verified for this sign-in, else the last one verified for it; never waits on Den. */ private knownPolicy(): DesktopConfig | null { const session = this.session; diff --git a/apps/server/src/opencode-v2-local-auth.ts b/apps/server/src/opencode-v2-local-auth.ts index 58e28c69dc..61e99a71a4 100644 --- a/apps/server/src/opencode-v2-local-auth.ts +++ b/apps/server/src/opencode-v2-local-auth.ts @@ -1,36 +1,199 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { opencodeDataDirs } from "@openwork/paths"; +import { ORGANIZATION_PROVIDER_ID } from "./organization-provider-gate.js"; import { loopbackFetch } from "./server-fetch.js"; import type { ServerConfig } from "./types.js"; +/** Where the running OpenCode v1 engine answers, with its Basic credentials. */ +type EngineV1Connection = Pick; + const record = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); -/** Only the active v1 profile, never another installation's fallback directory. - * OAuth credentials need their native authorization flow and are not API keys. */ -export async function readLocalProviderApiKeys(path = join(opencodeDataDirs()[0], "auth.json")): Promise> { +/** + * Why a provider connected on this computer is not on OpenCode v2: + * - `policy`: the organization allows only its own providers. + * - `oauth`: a sign-in (such as ChatGPT) that needs the engine's own sign-in flow. + * - `unsupported`: no built-in v2 adapter, or an endpoint the mirror does not trust. + * - `metadata`: OpenCode v1 did not describe it in a shape the mirror can translate. + * - `credentials`: its declared credential is missing. + */ +export type EngineV2SkippedProviderReason = "policy" | "oauth" | "unsupported" | "metadata" | "credentials"; + +export interface EngineV2SkippedProvider { + id: string; + reason: EngineV2SkippedProviderReason; +} + +export interface LocalProviderAuth { + /** API keys by provider id. */ + apiKeys: Map; + /** Providers connected with OAuth or another sign-in instead of an API key. */ + signIns: Set; +} + +/** Only the active v1 profile, never another installation's fallback directory. */ +export async function readLocalProviderAuth(path = join(opencodeDataDirs()[0], "auth.json")): Promise { let value: unknown; try { value = JSON.parse(await readFile(path, "utf8")); } catch (error) { - if (record(error) && error.code === "ENOENT") return new Map(); + if (record(error) && error.code === "ENOENT") return { apiKeys: new Map(), signIns: new Set() }; throw new Error("Could not read local provider credentials"); } - return new Map(record(value) ? Object.entries(value).flatMap(([id, auth]) => - record(auth) && auth.type === "api" && typeof auth.key === "string" && auth.key.trim() - ? [[id, auth.key]] : []) : []); + const apiKeys = new Map(); + const signIns = new Set(); + for (const [id, auth] of record(value) ? Object.entries(value) : []) { + if (!record(auth) || typeof auth.type !== "string") continue; + if (auth.type === "api") { + if (typeof auth.key === "string" && auth.key.trim()) apiKeys.set(id, auth.key); + } else { + signIns.add(id); + } + } + return { apiKeys, signIns }; } -/** Obtain trusted built-in metadata for locally connected providers that have - * no custom runtime definition. Credentials never come from catalog responses. */ -export async function localProviderDefinitions(config: ServerConfig, keys: ReadonlyMap, configured: Record): Promise> { - const missing = [...keys.keys()].filter(id => !(id in configured) && !/^(?:lpr_|ipr_|openwork$)/.test(id)); - if (!missing.length || !config.opencodeBaseUrl) return {}; +/** OAuth credentials need their native authorization flow and are not API keys. */ +export async function readLocalProviderApiKeys(path?: string): Promise> { + return (await readLocalProviderAuth(path)).apiKeys; +} + +type Normalized = { definition: Record } | { reason: "metadata" }; + +function enabledKinds(value: unknown): string[] | undefined { + return record(value) ? Object.entries(value).flatMap(([kind, enabled]) => enabled === true ? [kind] : []) : undefined; +} + +/** A v1 Provider.Info model in the models.dev field names the v2 config renderer reads. */ +function modelsDevModel(id: string, model: Record, api: Record): Record { + const capabilities = record(model.capabilities) ? model.capabilities : {}; + const input = enabledKinds(capabilities.input); + const output = enabledKinds(capabilities.output); + return { + id: typeof api.id === "string" && api.id.trim() ? api.id : id, + name: typeof model.name === "string" && model.name.trim() ? model.name : id, + ...(typeof model.family === "string" ? { family: model.family } : {}), + ...(typeof capabilities.toolcall === "boolean" ? { tool_call: capabilities.toolcall } : {}), + ...(typeof capabilities.reasoning === "boolean" ? { reasoning: capabilities.reasoning } : {}), + ...(typeof capabilities.attachment === "boolean" ? { attachment: capabilities.attachment } : {}), + ...(typeof capabilities.temperature === "boolean" ? { temperature: capabilities.temperature } : {}), + ...(input?.length || output?.length ? { modalities: { input: input?.length ? input : ["text"], output: output?.length ? output : ["text"] } } : {}), + ...(record(model.limit) ? { limit: model.limit } : {}), + ...(typeof model.status === "string" ? { status: model.status } : {}), + ...(record(model.variants) ? { variants: model.variants } : {}), + ...(record(model.options) && Object.keys(model.options).length ? { options: model.options } : {}), + ...(record(model.headers) && Object.keys(model.headers).length ? { headers: model.headers } : {}), + }; +} + +/** + * Translate a GET /provider entry into the models.dev shape the v2 mirror + * maps. OpenCode v1 1.18+ answers with Provider.Info: no top-level `npm` or + * `api`; each model carries `api: { id, url, npm }` and `capabilities`. Older + * engines answer in the models.dev shape, which is kept. + * + * Catalog responses are never a source of credentials or destinations: the + * provider's `key`, `options` (which can hold `apiKey`, `baseURL`, a sign-in + * placeholder or engine-specific headers) are dropped. The key comes from + * auth.json and the endpoint from `api`, which the mapper accepts only on the + * adapter's own origin. + */ +export function normalizeLocalProviderDefinition(value: unknown): Normalized { + if (!record(value) || !record(value.models)) return { reason: "metadata" }; + const base = { + ...(typeof value.id === "string" ? { id: value.id } : {}), + ...(typeof value.name === "string" ? { name: value.name } : {}), + env: Array.isArray(value.env) ? value.env.filter((name): name is string => typeof name === "string") : [], + }; + if (typeof value.npm === "string") { + return { definition: { ...base, npm: value.npm, ...(typeof value.api === "string" ? { api: value.api } : {}), models: value.models } }; + } + // Every model must name the same package and endpoint, otherwise one + // provider entry cannot describe it (for example a gateway mixing adapters). + let shared: { npm: string; url: string | undefined } | undefined; + const models: Record = {}; + for (const [id, model] of Object.entries(value.models)) { + if (!record(model) || !record(model.api) || typeof model.api.npm !== "string" || !model.api.npm.trim()) return { reason: "metadata" }; + const url = typeof model.api.url === "string" && model.api.url.trim() ? model.api.url.trim() : undefined; + if (shared && (shared.npm !== model.api.npm || shared.url !== url)) return { reason: "metadata" }; + shared ??= { npm: model.api.npm, url }; + models[id] = modelsDevModel(id, model, model.api); + } + if (!shared) return { reason: "metadata" }; + return { definition: { ...base, npm: shared.npm, ...(shared.url ? { api: shared.url } : {}), models } }; +} + +/** + * One provider connected on this computer as the v2 mirror sees it: ready to + * mirror with its definition and key, or skipped with a reason. Row-level + * actions on a provider added on this computer start from the same answer. + */ +export type LocalProvider = + | { id: string; status: "ready"; definition: Record; apiKey: string } + | { id: string; status: "skipped"; reason: EngineV2SkippedProviderReason }; + +export interface LocalProviderOptions { + /** Translate v1 Provider.Info and report every provider that is skipped. Off keeps the older pass-through. */ + normalize: boolean; + /** The organization's provider rule; providers it refuses are never read from the engine or mirrored. */ + allows?: ((providerId: string) => boolean) | null; +} + +/** + * Providers connected in OpenCode v1 (auth.json) that have no OpenWork + * runtime definition, resolved with trusted built-in metadata from the + * running v1 engine. Organization providers are mirrored from their own + * definitions and are not part of this list. + */ +export async function resolveLocalProviders( + config: EngineV1Connection, + auth: LocalProviderAuth, + configured: Record, + options: LocalProviderOptions, +): Promise { + const local = (id: string) => !(id in configured) && !ORGANIZATION_PROVIDER_ID.test(id); + const ids = [...new Set([...auth.apiKeys.keys(), ...auth.signIns])].filter(local).sort((left, right) => left.localeCompare(right)); + const resolved: LocalProvider[] = []; + const wanted: string[] = []; + for (const id of ids) { + if (options.allows && !options.allows(id)) resolved.push({ id, status: "skipped", reason: "policy" }); + else if (!auth.apiKeys.has(id)) { + if (options.normalize) resolved.push({ id, status: "skipped", reason: "oauth" }); + } else wanted.push(id); + } + if (!wanted.length || !config.opencodeBaseUrl) return resolved; const url = new URL("/provider", config.opencodeBaseUrl); const response = await loopbackFetch(url.toString(), { headers: { Authorization: `Basic ${Buffer.from(`${config.opencodeUsername}:${config.opencodePassword}`).toString("base64")}`, }, signal: AbortSignal.timeout(10_000) }); if (!response.ok) throw new Error("Could not read locally connected provider metadata"); const value: unknown = await response.json(); - const providers = record(value) && Array.isArray(value.all) ? value.all : []; - return Object.fromEntries(providers.flatMap(provider => record(provider) && typeof provider.id === "string" && missing.includes(provider.id) - ? [[provider.id, provider]] : [])); + const catalog = new Map((record(value) && Array.isArray(value.all) ? value.all : []).flatMap(provider => + record(provider) && typeof provider.id === "string" ? [[provider.id, provider] as const] : [])); + for (const id of wanted) { + const apiKey = auth.apiKeys.get(id) ?? ""; + const entry = catalog.get(id); + if (!options.normalize) { + if (entry) resolved.push({ id, status: "ready", definition: entry, apiKey }); + continue; + } + const normalized = normalizeLocalProviderDefinition(entry); + resolved.push("definition" in normalized + ? { id, status: "ready", definition: normalized.definition, apiKey } + : { id, status: "skipped", reason: normalized.reason }); + } + return resolved; +} + +/** The mirror's view: definitions to map, and what will not reach the engine. */ +export async function localProviderDefinitions( + config: EngineV1Connection, + auth: LocalProviderAuth, + configured: Record, + options: LocalProviderOptions, +): Promise<{ providers: Record; skipped: EngineV2SkippedProvider[] }> { + const resolved = await resolveLocalProviders(config, auth, configured, options); + return { + providers: Object.fromEntries(resolved.flatMap(provider => provider.status === "ready" ? [[provider.id, provider.definition]] : [])), + skipped: resolved.flatMap(provider => provider.status === "skipped" ? [{ id: provider.id, reason: provider.reason }] : []), + }; } diff --git a/apps/server/src/openwork-runtime-config.ts b/apps/server/src/openwork-runtime-config.ts index 9501f5ad43..ad6909f9fa 100644 --- a/apps/server/src/openwork-runtime-config.ts +++ b/apps/server/src/openwork-runtime-config.ts @@ -2,6 +2,7 @@ import { legacyExecutionPermissions } from "./managed-policy-rules.js"; import { DESKTOP_POLICY_ENFORCEMENT_ENABLED, desktopCapabilityConfig } from "@openwork/types/den/desktop-policies-runtime"; import { materializeLegacyFastProviders } from "@openwork/types/cloud-model-fast"; import { isManagedPolicyPlugin } from "./managed-policy-plugin.js"; +import { ORGANIZATION_PROVIDER_ID, organizationProviderGate } from "./organization-provider-gate.js"; /** * Runtime OpenCode configuration injected via a server-managed config file * passed to the engine as OPENCODE_CONFIG. @@ -72,11 +73,12 @@ export function buildOpenworkRuntimeConfigObjectFromSnapshot( const permissions = legacyExecutionPermissions(runtimeConfig.managedPolicy?.execution); const { managedPolicy: _managedPolicy, managedPrompt, ...engineConfig } = runtimeConfig; const provider = materializeLegacyFastProviders(runtimeProviderMap(runtimeConfig)); + const allowsProvider = organizationProviderGate(runtimeConfig.managedPolicy); return { ...engineConfig, - ...(runtimeConfig.managedPolicy?.allowCustomProviders === false ? { enabled_providers: [ - ...Object.keys(provider).filter((id) => /^(?:lpr_|ipr_|openwork$)/i.test(id)), - ...(runtimeConfig.managedPolicy.allowZenModel !== false ? ["opencode"] : []), + ...(allowsProvider ? { enabled_providers: [ + ...Object.keys(provider).filter((id) => ORGANIZATION_PROVIDER_ID.test(id)), + ...(allowsProvider("opencode") ? ["opencode"] : []), ] } : {}), permission: { ...engineConfig.permission, ...permissions }, default_agent: runtimeConfig.default_agent ?? "openwork", diff --git a/apps/server/src/organization-provider-gate.ts b/apps/server/src/organization-provider-gate.ts new file mode 100644 index 0000000000..36240a9ca0 --- /dev/null +++ b/apps/server/src/organization-provider-gate.ts @@ -0,0 +1,17 @@ +import { desktopCapabilityConfig, type DesktopConfig } from "@openwork/types/den/desktop-policies-runtime"; + +/** Providers an organization owns: Den-imported (lpr_), Gateway (ipr_) and hosted OpenWork Models. */ +export const ORGANIZATION_PROVIDER_ID = /^(?:lpr_|ipr_|openwork$)/i; + +/** + * When the organization allows only its own providers ("Only models you + * provide", `allowCustomProviders: false`), whether one provider may run. + * Null when personal providers are allowed. Both engines use this rule: v1 + * receives it as `enabled_providers`, the v2 provider mirror skips the rest. + */ +export function organizationProviderGate(policy: DesktopConfig | undefined): ((providerId: string) => boolean) | null { + const effective = policy ? desktopCapabilityConfig(policy) : undefined; + if (effective?.allowCustomProviders !== false) return null; + const zen = effective.allowZenModel !== false; + return (providerId) => ORGANIZATION_PROVIDER_ID.test(providerId) || (zen && providerId === "opencode"); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 26895de856..1c3a230c88 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -778,6 +778,7 @@ function admitSessionCommand( // Backoff for re-sending managed provider credentials the engine did not take // during startup. After the last attempt, the next launch tries again. const STARTUP_PROVIDER_AUTH_RETRY_DELAYS_MS = [2_000, 10_000, 30_000, 120_000]; +const LOCAL_PROVIDER_MIRROR_POLICY_WAIT_MS = 1_500; export async function startServer( config: ServerConfig, @@ -836,6 +837,18 @@ export async function startServer( }; const engineV2Preview: EngineV2Preview = createEngineV2Preview({ config, env, deferStart: true, resolveLiveProviderSync, + // The signed-in member's rollout from Den; signed out it stays off. A slow + // or unreachable Den never holds the mirror: the answer last verified for + // this sign-in stands in. + resolveLocalProviderMirror: async () => { + const policy = managedDesktopPolicy(config); + let timer: NodeJS.Timeout | undefined; + const fresh = await Promise.race([ + policy.current().catch(() => policy.known()), + new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), LOCAL_PROVIDER_MIRROR_POLICY_WAIT_MS); }), + ]).finally(() => clearTimeout(timer)); + return (fresh === undefined ? policy.known() : fresh)?.localProviderMirror === true; + }, autoUpgradeIdle: async () => (await describeV1EngineActivity(config)).verdict === "idle", autoUpgradeError: (error) => logger.log("warn", "Automatic engine upgrade failed; keeping v1", { error }), providerSession: () => cloudProviderSync.currentSession(), @@ -857,6 +870,7 @@ export async function startServer( // The same signed-in desktop-config hydration as organization defaults. A // missing field/older Den is off; never infer this from org metadata. await engineV2Preview.setLiveProviderSync(await resolveLiveProviderSync()); + void engineV2Preview.recheckLocalProviderMirror().catch(() => undefined); return engineV2Preview.status().liveProviderSync; }, reloadEngine: async () => { @@ -983,11 +997,22 @@ export async function startServer( const response = await sendWithOwnershipProof(ownership, () => taskRecovery ? taskRecovery.forward(workspace, "v1", mount.restPath, request, send) : send()); if (response.ok && engineV2Preview.status().chatRouting - && ["PUT", "DELETE"].includes(request.method) - && /^\/opencode\/auth\/[^/]+$/.test(mount.restPath)) { - // The ordinary local-provider form writes v1's credential store. - // Join v2 catalog reconciliation before reporting that save complete. + && ((["PUT", "DELETE"].includes(request.method) && /^\/opencode\/auth\/[^/]+$/.test(mount.restPath)) + || (request.method === "POST" && /^\/opencode\/provider\/[^/]+\/oauth\/callback$/.test(mount.restPath)))) { + // The ordinary local-provider form and provider sign-in write v1's + // credential store. Join v2 catalog reconciliation before reporting + // that save complete, so the engine's status already explains it. await engineV2Preview.refreshProviders(); + // With the localProviderMirror rollout (the mirror then explains + // its skips), also rebuild v1's provider state for this folder: + // AI Providers lists providers from v1, which otherwise keeps the + // old list until a restart. Never while v1 is running work. + if (engineV2Preview.status().skippedProviders && !await engineHasActiveSessions(config, workspace)) { + await reloadOpencodeEngine(config, workspace, engineMcpServerState, { reason: "local_provider_auth", awaitPostRefreshSync: false }) + .catch((error) => logger.log("warn", "Could not refresh OpenCode v1 providers after a provider change.", { + error: error instanceof Error ? error.message : String(error), + })); + } } return finalizeProxyResponse(response); } catch (error) { diff --git a/apps/server/test/fixtures/opencode-v1.18.30-provider.json b/apps/server/test/fixtures/opencode-v1.18.30-provider.json new file mode 100644 index 0000000000..51c68e7721 --- /dev/null +++ b/apps/server/test/fixtures/opencode-v1.18.30-provider.json @@ -0,0 +1,739 @@ +{ + "all": [ + { + "id": "openrouter", + "name": "OpenRouter", + "source": "api", + "env": [ + "OPENROUTER_API_KEY" + ], + "key": "sk-or-catalog-copy-must-not-be-used", + "options": { + "headers": { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode" + } + }, + "models": { + "arcee-ai/trinity-large-thinking": { + "id": "arcee-ai/trinity-large-thinking", + "providerID": "openrouter", + "api": { + "id": "arcee-ai/trinity-large-thinking", + "url": "https://openrouter.ai/api/v1", + "npm": "@openrouter/ai-sdk-provider" + }, + "name": "Trinity Large Thinking", + "family": "trinity", + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "cost": { + "input": 0.25, + "output": 0.8, + "cache": { + "read": 0.06, + "write": 0 + } + }, + "limit": { + "context": 262144, + "output": 80000 + }, + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2026-04-01", + "variants": {} + }, + "deepseek/deepseek-v4.1-flash": { + "id": "deepseek/deepseek-v4.1-flash", + "providerID": "openrouter", + "api": { + "id": "deepseek/deepseek-v4.1-flash", + "url": "https://openrouter.ai/api/v1", + "npm": "@openrouter/ai-sdk-provider" + }, + "name": "DeepSeek V4.1 Flash", + "family": "deepseek-flash", + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "cost": { + "input": 0.02, + "output": 1, + "cache": { + "read": 0.015, + "write": 0 + } + }, + "limit": { + "context": 1048576, + "output": 943718 + }, + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2026-09-10", + "variants": { + "none": { + "reasoning": { + "effort": "none" + } + }, + "low": { + "reasoning": { + "effort": "low" + } + }, + "high": { + "reasoning": { + "effort": "high" + } + }, + "max": { + "reasoning": { + "effort": "max" + } + } + } + }, + "scaledown/summarize": { + "id": "scaledown/summarize", + "providerID": "openrouter", + "api": { + "id": "scaledown/summarize", + "url": "https://openrouter.ai/api/v1", + "npm": "@openrouter/ai-sdk-provider" + }, + "name": "Summarize", + "capabilities": { + "temperature": false, + "reasoning": false, + "attachment": true, + "toolcall": false, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "cost": { + "input": 0.04, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 4000 + }, + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2026-10-13", + "variants": {} + }, + "bytedance-seed/seed-1.6": { + "id": "bytedance-seed/seed-1.6", + "providerID": "openrouter", + "api": { + "id": "bytedance-seed/seed-1.6", + "url": "https://openrouter.ai/api/v1", + "npm": "@openrouter/ai-sdk-provider" + }, + "name": "Seed 1.6", + "family": "seed", + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": true, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "cost": { + "input": 0.25, + "output": 2, + "cache": { + "read": 0, + "write": 0 + }, + "tiers": [ + { + "input": 0.5, + "output": 4, + "cache": { + "read": 0, + "write": 0 + }, + "tier": { + "type": "context", + "size": 128000 + } + } + ] + }, + "limit": { + "context": 262144, + "output": 32768 + }, + "status": "deprecated", + "options": {}, + "headers": {}, + "release_date": "2025-10-15", + "variants": { + "low": { + "reasoning": { + "effort": "low" + } + }, + "medium": { + "reasoning": { + "effort": "medium" + } + }, + "high": { + "reasoning": { + "effort": "high" + } + } + } + } + } + }, + { + "id": "openai", + "name": "OpenAI", + "source": "custom", + "env": [ + "OPENAI_API_KEY" + ], + "options": { + "apiKey": "opencode-oauth-dummy-key", + "headerTimeout": 300000 + }, + "models": { + "gpt-5.6-terra": { + "id": "gpt-5.6-terra", + "providerID": "openai", + "api": { + "id": "gpt-5.6-terra", + "url": "", + "npm": "@ai-sdk/openai" + }, + "name": "GPT-5.6 Terra", + "family": "gpt-terra", + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2026-07-09", + "variants": { + "none": { + "reasoningEffort": "none", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "xhigh": { + "reasoningEffort": "xhigh", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "max": { + "reasoningEffort": "max", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + } + } + }, + { + "id": "anthropic", + "name": "Anthropic", + "source": "custom", + "env": [ + "ANTHROPIC_API_KEY" + ], + "options": {}, + "models": { + "claude-haiku-4-5-20251001": { + "id": "claude-haiku-4-5-20251001", + "providerID": "anthropic", + "api": { + "id": "claude-haiku-4-5-20251001", + "url": "", + "npm": "@ai-sdk/anthropic" + }, + "name": "Claude Haiku 4.5", + "family": "claude-haiku", + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "cost": { + "input": 1, + "output": 5, + "cache": { + "read": 0.1, + "write": 1.25 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2025-10-15", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + } + } + }, + { + "id": "google", + "name": "Google", + "source": "custom", + "env": [ + "GOOGLE_API_KEY", + "GOOGLE_GENERATIVE_AI_API_KEY", + "GEMINI_API_KEY" + ], + "options": {}, + "models": { + "gemini-omni-flash-preview": { + "id": "gemini-omni-flash-preview", + "providerID": "google", + "api": { + "id": "gemini-omni-flash-preview", + "url": "", + "npm": "@ai-sdk/google" + }, + "name": "Gemini Omni Flash Preview", + "family": "gemini", + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": false, + "input": { + "text": true, + "audio": false, + "image": true, + "video": true, + "pdf": false + }, + "output": { + "text": false, + "audio": false, + "image": false, + "video": true, + "pdf": false + }, + "interleaved": false + }, + "cost": { + "input": 1.5, + "output": 17.5, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 131072, + "output": 65536 + }, + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2026-06-30", + "variants": {} + } + } + }, + { + "id": "github-copilot", + "name": "GitHub Copilot", + "source": "custom", + "env": [ + "GITHUB_TOKEN" + ], + "options": {}, + "models": { + "claude-haiku-5.5": { + "id": "claude-haiku-5.5", + "providerID": "github-copilot", + "api": { + "id": "claude-haiku-5.5", + "url": "https://api.githubcopilot.com/v1", + "npm": "@ai-sdk/anthropic" + }, + "name": "Claude Haiku 5.5", + "family": "claude-haiku", + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "cost": { + "input": 0.1, + "output": 0.5, + "cache": { + "read": 0.01, + "write": 0.125 + }, + "tiers": [ + { + "input": 0.5, + "output": 2.5, + "cache": { + "read": 0.05, + "write": 0.625 + }, + "tier": { + "type": "context", + "size": 100000 + } + } + ] + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2026-10-07", + "variants": { + "low": { + "thinking": { + "type": "adaptive", + "display": "summarized" + }, + "effort": "low" + }, + "medium": { + "thinking": { + "type": "adaptive", + "display": "summarized" + }, + "effort": "medium" + }, + "high": { + "thinking": { + "type": "adaptive", + "display": "summarized" + }, + "effort": "high" + }, + "xhigh": { + "thinking": { + "type": "adaptive", + "display": "summarized" + }, + "effort": "xhigh" + }, + "max": { + "thinking": { + "type": "adaptive", + "display": "summarized" + }, + "effort": "max" + } + } + }, + "gpt-5.6-terra": { + "id": "gpt-5.6-terra", + "providerID": "github-copilot", + "api": { + "id": "gpt-5.6-terra", + "url": "https://api.githubcopilot.com", + "npm": "@ai-sdk/openai" + }, + "name": "GPT-5.6 Terra", + "family": "gpt-terra", + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "cost": { + "input": 2, + "output": 12, + "cache": { + "read": 0.2, + "write": 2.5 + }, + "tiers": [ + { + "input": 4, + "output": 18, + "cache": { + "read": 0.4, + "write": 5 + }, + "tier": { + "type": "context", + "size": 272000 + } + } + ], + "experimentalOver200K": { + "input": 4, + "output": 18, + "cache": { + "read": 0.4, + "write": 5 + } + } + }, + "limit": { + "context": 1050000, + "input": 922000, + "output": 128000 + }, + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2026-07-09", + "variants": { + "none": { + "reasoningEffort": "none", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "xhigh": { + "reasoningEffort": "xhigh", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "max": { + "reasoningEffort": "max", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + } + } + } + ], + "connected": [ + "openai", + "openrouter" + ], + "default": {} +} diff --git a/apps/server/test/opencode-v2-local-auth.test.ts b/apps/server/test/opencode-v2-local-auth.test.ts new file mode 100644 index 0000000000..f55f6da976 --- /dev/null +++ b/apps/server/test/opencode-v2-local-auth.test.ts @@ -0,0 +1,238 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, test } from "node:test"; +import { mapRuntimeProvidersToV2Specs } from "../src/engine-v2-preview.js"; +import { renderOpencodeV2Config } from "../src/managed-opencode-v2.js"; +import { buildOpenworkRuntimeConfigObjectFromSnapshot } from "../src/openwork-runtime-config.js"; +import { + localProviderDefinitions, + normalizeLocalProviderDefinition, + readLocalProviderAuth, + type LocalProviderAuth, +} from "../src/opencode-v2-local-auth.js"; +import { organizationProviderGate } from "../src/organization-provider-gate.js"; + +// Trimmed GET /provider answer from the bundled OpenCode v1.18.30 engine, +// recorded with an OpenRouter API key and a ChatGPT (OAuth) sign-in in its +// auth.json. Every value is the engine's own except the catalog's copy of the +// OpenRouter key, replaced with a marker that must never be used. +const fixturePath = new URL("./fixtures/opencode-v1.18.30-provider.json", import.meta.url); +const CATALOG_KEY = "sk-or-catalog-copy-must-not-be-used"; +const KEY = "sk-or-local-test-key"; + +let engine: Server; +let engineUrl = ""; +let providerReads = 0; +const connection = () => ({ opencodeBaseUrl: engineUrl, opencodeUsername: "opencode", opencodePassword: "fixture-password" }); + +before(async () => { + const body = await readFile(fixturePath, "utf8"); + engine = createServer((request, response) => { + const expected = `Basic ${Buffer.from("opencode:fixture-password").toString("base64")}`; + if (request.url !== "/provider" || request.headers.authorization !== expected) { + response.writeHead(404).end(); + return; + } + providerReads += 1; + response.writeHead(200, { "content-type": "application/json" }).end(body); + }); + await new Promise((resolve) => engine.listen(0, "127.0.0.1", resolve)); + const address = engine.address(); + if (!address || typeof address === "string") throw new Error("fixture engine did not bind"); + engineUrl = `http://127.0.0.1:${address.port}`; +}); + +after(async () => { + await new Promise((resolve) => engine.close(() => resolve())); +}); + +/** A nested value of a generated config, or undefined. */ +function at(value: unknown, ...path: string[]): unknown { + let current = value; + for (const key of path) { + if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; + current = Object.entries(current).find(([name]) => name === key)?.[1]; + } + return current; +} + +const auth = (apiKeys: Record, signIns: string[] = []): LocalProviderAuth => ({ + apiKeys: new Map(Object.entries(apiKeys)), + signIns: new Set(signIns), +}); + +test("before: the real v1.18.30 OpenRouter entry passed through unchanged maps to nothing", async () => { + const local = await localProviderDefinitions(connection(), auth({ openrouter: KEY }), {}, { normalize: false }); + const mapped = mapRuntimeProvidersToV2Specs(local.providers, new Map(), new Map([["openrouter", KEY]])); + + assert.deepEqual(mapped.specs, []); + assert.deepEqual(mapped.skippedProviderIds, ["openrouter"]); +}); + +test("after: the real v1.18.30 OpenRouter entry becomes one v2 provider with its models", async () => { + const local = await localProviderDefinitions(connection(), auth({ openrouter: KEY }), {}, { normalize: true }); + const mapped = mapRuntimeProvidersToV2Specs(local.providers, new Map(), new Map([["openrouter", KEY]])); + + assert.deepEqual(local.skipped, []); + assert.deepEqual(mapped.skippedProviders, []); + assert.equal(mapped.specs.length, 1); + const spec = mapped.specs[0]; + assert.equal(spec?.id, "openrouter"); + assert.equal(spec?.name, "OpenRouter"); + assert.equal(spec?.package, "@opencode/ai/providers/openrouter"); + assert.equal(spec?.baseUrl, "https://openrouter.ai/api/v1"); + assert.equal(spec?.apiKey, KEY, "the key comes from auth.json"); + assert.ok(!JSON.stringify(mapped).includes(CATALOG_KEY), "the catalog's copy of the key is never used"); + assert.equal(spec?.headers, undefined, "engine-specific catalog headers do not carry over"); + assert.equal(spec?.settings, undefined); + assert.deepEqual(spec?.models.map(model => model.id), [ + "arcee-ai/trinity-large-thinking", + "bytedance-seed/seed-1.6", + "deepseek/deepseek-v4.1-flash", + "scaledown/summarize", + ]); + + const rendered = renderOpencodeV2Config({ providers: mapped.specs, skills: [] }); + const openrouter = (...path: string[]) => at(rendered, "providers", "openrouter", ...path); + const model = (id: string, ...path: string[]) => openrouter("models", id, ...path); + assert.equal(openrouter("package"), "@opencode/ai/providers/openrouter"); + assert.equal(openrouter("settings", "baseURL"), "https://openrouter.ai/api/v1"); + assert.equal(openrouter("settings", "apiKey"), KEY); + assert.deepEqual(model("deepseek/deepseek-v4.1-flash", "capabilities"), { tools: true, input: ["text", "image"], output: ["text", "reasoning"] }); + assert.deepEqual(model("deepseek/deepseek-v4.1-flash", "limit"), { context: 1048576, output: 943718 }); + assert.deepEqual(model("deepseek/deepseek-v4.1-flash", "variants"), [ + { id: "none", settings: { reasoning: { effort: "none" } } }, + { id: "low", settings: { reasoning: { effort: "low" } } }, + { id: "high", settings: { reasoning: { effort: "high" } } }, + { id: "max", settings: { reasoning: { effort: "max" } } }, + ]); + assert.equal(model("deepseek/deepseek-v4.1-flash", "modelID"), "deepseek/deepseek-v4.1-flash"); + assert.deepEqual(model("scaledown/summarize", "capabilities"), { tools: false, input: ["text", "image", "pdf"], output: ["text"] }); + assert.equal(model("bytedance-seed/seed-1.6", "disabled"), true, "a deprecated model stays out of the picker, as in v1"); +}); + +test("an organization that allows only its own providers mirrors none of the keys added on this computer", async () => { + const allows = organizationProviderGate({ allowCustomProviders: false }); + const reads = providerReads; + const local = await localProviderDefinitions(connection(), auth({ openrouter: KEY, anthropic: "sk-ant-local" }, ["openai"]), {}, { normalize: true, allows }); + + assert.deepEqual(local.providers, {}); + assert.deepEqual(local.skipped, [ + { id: "anthropic", reason: "policy" }, + { id: "openai", reason: "policy" }, + { id: "openrouter", reason: "policy" }, + ]); + assert.equal(providerReads, reads, "blocked providers are not even looked up"); +}); + +test("the organization rule matches the providers v1 keeps in enabled_providers", () => { + const provider = Object.fromEntries(["lpr_team", "ipr_gateway", "openwork", "personal-byok", "openrouter"] + .map(id => [id, { npm: "@ai-sdk/openai-compatible", models: {} }])); + for (const managedPolicy of [{ allowCustomProviders: false }, { allowCustomProviders: false, allowZenModel: false }]) { + const v1 = buildOpenworkRuntimeConfigObjectFromSnapshot({ provider, managedPolicy }).enabled_providers; + assert.ok(Array.isArray(v1)); + const allows = organizationProviderGate(managedPolicy); + assert.ok(allows); + for (const id of [...Object.keys(provider), "opencode", "anthropic"]) { + assert.equal(allows(id), v1.includes(id), `${id} with ${JSON.stringify(managedPolicy)}`); + } + } + assert.equal(organizationProviderGate({ allowCustomProviders: true }), null); + assert.equal(organizationProviderGate(undefined), null); +}); + +test("a ChatGPT-style sign-in is reported as skipped instead of disappearing", async () => { + const local = await localProviderDefinitions(connection(), auth({}, ["openai"]), {}, { normalize: true }); + + assert.deepEqual(local.providers, {}); + assert.deepEqual(local.skipped, [{ id: "openai", reason: "oauth" }]); + assert.ok(!JSON.stringify(local).includes("opencode-oauth-dummy-key")); +}); + +test("with the rollout off a sign-in keeps today's behavior, but the organization rule still applies", async () => { + const off = await localProviderDefinitions(connection(), auth({}, ["openai"]), {}, { normalize: false }); + assert.deepEqual(off, { providers: {}, skipped: [] }); + + const allows = organizationProviderGate({ allowCustomProviders: false }); + const blocked = await localProviderDefinitions(connection(), auth({ openrouter: KEY }), {}, { normalize: false, allows }); + assert.deepEqual(blocked, { providers: {}, skipped: [{ id: "openrouter", reason: "policy" }] }); +}); + +test("providers without a usable v2 description are skipped with a reason", async () => { + const keys = { google: "google-local", "github-copilot": "copilot-local", "not-in-catalog": "missing-local", anthropic: "sk-ant-local" }; + const local = await localProviderDefinitions(connection(), auth(keys), {}, { normalize: true }); + const mapped = mapRuntimeProvidersToV2Specs(local.providers, new Map(), new Map(Object.entries(keys))); + + // Mixed adapters in one provider, and a provider v1 does not describe. + assert.deepEqual(local.skipped, [ + { id: "github-copilot", reason: "metadata" }, + { id: "not-in-catalog", reason: "metadata" }, + ]); + // No built-in v2 adapter in the mirror for Google; Anthropic maps to its native one. + assert.deepEqual(mapped.skippedProviders, [{ id: "google", reason: "unsupported" }]); + assert.deepEqual(mapped.specs.map(spec => [spec.id, spec.package, spec.baseUrl ?? null]), [["anthropic", "@opencode/ai/providers/anthropic", null]]); +}); + +test("organization providers and configured providers are never read from the v1 catalog", async () => { + const reads = providerReads; + const local = await localProviderDefinitions(connection(), auth({ lpr_team: "managed", openwork: "managed", openrouter: KEY }), { openrouter: {} }, { normalize: true }); + + assert.deepEqual(local, { providers: {}, skipped: [] }); + assert.equal(providerReads, reads); +}); + +test("the models.dev shape older engines answer with still maps", () => { + const normalized = normalizeLocalProviderDefinition({ + id: "openrouter", + name: "OpenRouter", + npm: "@openrouter/ai-sdk-provider", + api: "https://openrouter.ai/api/v1", + env: ["OPENROUTER_API_KEY"], + key: CATALOG_KEY, + options: { apiKey: CATALOG_KEY, baseURL: "https://attacker.example.test/v1" }, + models: { "openai/gpt-4.1": { id: "openai/gpt-4.1", name: "GPT-4.1", tool_call: true, modalities: { input: ["text"], output: ["text"] } } }, + }); + assert.ok("definition" in normalized); + const mapped = mapRuntimeProvidersToV2Specs({ openrouter: normalized.definition }, new Map(), new Map([["openrouter", KEY]])); + + assert.deepEqual(mapped.skippedProviderIds, []); + assert.equal(mapped.specs[0]?.package, "@opencode/ai/providers/openrouter"); + assert.equal(mapped.specs[0]?.baseUrl, "https://openrouter.ai/api/v1", "a catalog baseURL never overrides the trusted endpoint"); + assert.equal(mapped.specs[0]?.apiKey, KEY); + assert.deepEqual(mapped.specs[0]?.models.map(model => model.id), ["openai/gpt-4.1"]); +}); + +test("a catalog endpoint off the adapter's own origin is not trusted", () => { + const normalized = normalizeLocalProviderDefinition({ + id: "openrouter", + env: ["OPENROUTER_API_KEY"], + models: { model: { id: "model", api: { id: "model", npm: "@openrouter/ai-sdk-provider", url: "https://relay.example.test/api/v1" } } }, + }); + assert.ok("definition" in normalized); + const mapped = mapRuntimeProvidersToV2Specs({ openrouter: normalized.definition }, new Map(), new Map([["openrouter", KEY]])); + + assert.deepEqual(mapped.skippedProviders, [{ id: "openrouter", reason: "unsupported" }]); +}); + +test("auth.json API keys and sign-ins are told apart", async () => { + const directory = await mkdtemp(join(tmpdir(), "openwork-local-auth-")); + try { + const path = join(directory, "auth.json"); + await writeFile(path, JSON.stringify({ + openrouter: { type: "api", key: KEY }, + anthropic: { type: "api", key: " " }, + openai: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, + enterprise: { type: "wellknown", key: "token", token: "token" }, + broken: "not an entry", + })); + const read = await readLocalProviderAuth(path); + assert.deepEqual([...read.apiKeys], [["openrouter", KEY]]); + assert.deepEqual([...read.signIns].sort(), ["enterprise", "openai"]); + assert.deepEqual(await readLocalProviderAuth(join(directory, "missing.json")), { apiKeys: new Map(), signIns: new Set() }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/docs/features/local-provider-mirror/README.md b/docs/features/local-provider-mirror/README.md new file mode 100644 index 0000000000..62a98b43dc --- /dev/null +++ b/docs/features/local-provider-mirror/README.md @@ -0,0 +1,76 @@ +# Providers added on this computer, on OpenCode v2 + +Feature key `localProviderMirror` · cloud and self-hosted · default off · +author `benjaminshafii` · follow-up: ENG-888. + +## What changes + +People add their own API keys in Settings › AI Providers (OpenRouter, OpenAI, +Anthropic…). OpenWork saves them in OpenCode v1's credential store and mirrors +them into OpenCode v2, the engine chat runs on. The bundled v1 engine now +describes providers as `Provider.Info` (package and endpoint on each model, +`capabilities` instead of `tool_call`/`modalities`), which the mirror could not +read, so every personal key was dropped and never reached the chat's model +picker. + +With the feature on, for the signed-in member: + +- The mirror translates v1's `Provider.Info` (and still accepts the older + models.dev shape). The key comes only from `auth.json`; the catalog's copy of + a key, its `baseURL` and its headers are ignored, and a catalog endpoint is + used only on the adapter's own origin (`https://openrouter.ai` for + OpenRouter, for example). +- Nothing is dropped silently. `GET /experimental/engine-v2-preview/status` + adds `skippedProviders: [{ id, reason }]`, with `reason` one of `policy`, + `oauth`, `unsupported`, `metadata` or `credentials`. +- After a key is saved or a provider sign-in finishes, the server mirrors it + into v2 and rebuilds v1's provider list for that folder (never while v1 runs + work), so AI Providers and the picker agree right away. +- AI Providers reads the skipped list: a sign-in v2 cannot use yet (ChatGPT, + for example) reads "Not available on the new engine yet", and a provider the + organization does not allow keeps its row with a lock and "Acme manages AI + providers" (the organization's name). A skipped provider never reads "Ready + to use". + +Independently of the feature, "Only models you provide" +(`allowCustomProviders: false`) now applies to the v2 mirror exactly as v1's +`enabled_providers` does: only organization providers (`lpr_`, `ipr_`, +`openwork`, and OpenCode Zen unless removed) reach v2. Both engines use one rule +(`apps/server/src/organization-provider-gate.ts`). + +| Off (today) | On | +| --- | --- | +| ![The picker says OpenRouter isn't connected after its key was saved](picker-off.png) | ![OpenRouter's models in the picker](picker-on.png) | + +![AI Providers lists the OpenRouter key as Ready to use](ai-providers-ready.png) + +![A ChatGPT sign-in reads Not available on the new engine yet](ai-providers-sign-in.png) + +![With Only models you provide, the rows stay with a lock and Acme manages AI providers](ai-providers-locked.png) + +## How to try it + +Turn it on for one organization in `/admin`, sign in on a desktop that runs +chat on OpenCode v2, add an OpenRouter key in Settings › AI Providers, then +open the model picker in a chat and search "OpenRouter". The proof +`evals/proofs/v2-local-provider-mirror.proof.test.ts` drives the same path. + +Signed out there is no organization, so the feature stays off until it ships +on by default. + +## Turning it off + +The next mirror (within a cloud provider sync, at most a few minutes, or the +next key change or restart) goes back to the older pass-through: personal keys +leave the v2 picker again and AI Providers stops reading skip reasons. Saved +keys, chats and the organization rule are untouched. + +## What to watch + +- `skippedProviders` in the engine status: a growing `metadata` count means v1 + changed its catalog shape again; `unsupported` lists providers without a v2 + adapter in the mirror (Google, Groq, custom gateways). +- Server log warning "Could not refresh OpenCode v1 providers after a provider + change." means AI Providers may show a stale list until a restart. +- Out of scope here (ENG-888): sharing a personal provider with the team, + asking an admin, and signing in again with OAuth on v2. diff --git a/docs/features/local-provider-mirror/ai-providers-locked.png b/docs/features/local-provider-mirror/ai-providers-locked.png new file mode 100644 index 0000000000..2f8ef654bd Binary files /dev/null and b/docs/features/local-provider-mirror/ai-providers-locked.png differ diff --git a/docs/features/local-provider-mirror/ai-providers-ready.png b/docs/features/local-provider-mirror/ai-providers-ready.png new file mode 100644 index 0000000000..159f0db16a Binary files /dev/null and b/docs/features/local-provider-mirror/ai-providers-ready.png differ diff --git a/docs/features/local-provider-mirror/ai-providers-sign-in.png b/docs/features/local-provider-mirror/ai-providers-sign-in.png new file mode 100644 index 0000000000..d181fdf9c2 Binary files /dev/null and b/docs/features/local-provider-mirror/ai-providers-sign-in.png differ diff --git a/docs/features/local-provider-mirror/picker-off.png b/docs/features/local-provider-mirror/picker-off.png new file mode 100644 index 0000000000..a770926afd Binary files /dev/null and b/docs/features/local-provider-mirror/picker-off.png differ diff --git a/docs/features/local-provider-mirror/picker-on.png b/docs/features/local-provider-mirror/picker-on.png new file mode 100644 index 0000000000..eaf161bac9 Binary files /dev/null and b/docs/features/local-provider-mirror/picker-on.png differ diff --git a/ee/apps/den-api/src/routes/me/index.ts b/ee/apps/den-api/src/routes/me/index.ts index 1870ab6d4f..dee2dc2bd9 100644 --- a/ee/apps/den-api/src/routes/me/index.ts +++ b/ee/apps/den-api/src/routes/me/index.ts @@ -481,6 +481,7 @@ export function registerMeRoutes { + const openRouterModels = async () => (await probe.dom('[data-testid="composer-model-picker"] [data-model-key^="openrouter:"]')).elements.length; + const row = async (providerId: string) => (await probe.dom(`[data-testid="local-provider-${providerId}"]`)).elements.map(element => element.text).join(" "); + + /** Open the chat's model picker and search it; opening it also asks the desktop to refresh providers. */ + async function searchPicker(query: string) { + await user.navigate(world.sessionUrl); + await user.see("composer", { editable: true, timeoutMs: WITHIN }); + await user.click("Change model"); + await user.see({ testId: "composer-model-picker" }); + await user.type({ label: "Search all models" }, query); + } + + /** Reopen the picker until it agrees with `until`, as a person would after a change. */ + async function pickerUntil(label: string, until: (count: number) => boolean) { + return probe.eventually(async () => { + await searchPicker("OpenRouter"); + const count = await openRouterModels(); + if (!until(count)) await user.press("Escape"); + return count; + }, { within: 120_000, label, until }); + } + + await user.resizeViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); + + async function connectOpenRouter() { + await user.navigate(world.settingsUrl); + await user.see({ role: "button", label: "Connect a provider" }, { timeoutMs: WITHIN }); + await user.click({ role: "button", label: "Connect a provider" }); + await user.type({ label: "Search providers" }, "OpenRouter"); + await user.click({ role: "button", label: /^OpenRouter/ }); + await user.type({ label: "API key" }, world.openRouterKey); + await user.click({ role: "button", label: "Save key" }); + await user.notSee({ role: "button", label: "Save key" }, { timeoutMs: WITHIN }); + } + const engineStatus = async () => { + const status = await probe.desktopApi("/experimental/engine-v2-preview/status"); + const body = typeof status.body === "object" && status.body !== null ? status.body : {}; + return { + mirrored: "mirroredProviderIds" in body ? JSON.stringify(body.mirroredProviderIds) : "[]", + skipped: "skippedProviderIds" in body ? JSON.stringify(body.skippedProviderIds) : "[]", + reasons: "skippedProviders" in body ? JSON.stringify(body.skippedProviders) : "none", + }; + }; + + await step("before: with the rollout off, a saved OpenRouter key never reaches the new engine's model picker", async () => { + await connectOpenRouter(); + const status = await engineStatus(); + expect(status.skipped).toContain('"openrouter"'); + await searchPicker("OpenRouter"); + await user.see({ text: "OpenRouter isn't connected in this workspace." }, { timeoutMs: WITHIN }); + const count = await openRouterModels(); + expect(count).toBe(0); + evidence.recordAssertionEvidence("The key is saved, the engine drops it, and the picker says OpenRouter isn't connected", `${count} OpenRouter models listed; engine skipped ${status.skipped}, mirrored ${status.mirrored}`, count === 0 && status.skipped.includes('"openrouter"')); + await user.screenshot(); + await user.press("Escape"); + }); + + await step("after: with the rollout on, connecting OpenRouter reads Ready to use in AI Providers", async () => { + await world.setMirror(true); + await connectOpenRouter(); + const text = await probe.eventually(() => row("openrouter"), { within: WITHIN, label: "the OpenRouter row", until: value => value.includes("Ready to use") }); + const status = await engineStatus(); + expect(status.mirrored).toContain('"openrouter"'); + evidence.recordAssertionEvidence("AI Providers and the engine agree the key is ready", `${text}; engine mirrored ${status.mirrored}`, text.includes("Ready to use") && status.mirrored.includes('"openrouter"')); + await user.screenshot(); + }); + + await step("after: OpenRouter's models are in the new engine's model picker", async () => { + const count = await pickerUntil("OpenRouter models in the picker", value => value > 0); + expect(count).toBeGreaterThan(0); + evidence.recordAssertionEvidence("The same key now lists OpenRouter's models on the new engine", `${count} OpenRouter models match "OpenRouter"`, count > 0); + await user.screenshot(); + await user.press("Escape"); + }); + + await step("after: a ChatGPT sign-in the new engine cannot use yet says so instead of Ready to use", async () => { + await world.signInWithChatGpt(); + await user.navigate(world.settingsUrl); + await user.see({ testId: "local-provider-openai" }, { timeoutMs: WITHIN }); + const text = await probe.eventually(() => row("openai"), { within: WITHIN, label: "the OpenAI row's state", until: value => value.includes("Not available on the new engine yet") }); + expect(text).not.toContain("Ready to use"); + const openRouter = await probe.eventually(() => row("openrouter"), { within: WITHIN, label: "the OpenRouter row's state", until: value => value.includes("Ready to use") }); + const { reasons } = await engineStatus(); + expect(reasons).toContain('{"id":"openai","reason":"oauth"}'); + evidence.recordAssertionEvidence("The sign-in is neutral and not ready; the OpenRouter key stays ready", `OpenAI: ${text} | OpenRouter: ${openRouter} | engine skipped: ${reasons}`, !text.includes("Ready to use") && openRouter.includes("Ready to use")); + await user.screenshot(); + }); + + await step("after: when Acme allows only its own providers, OpenRouter leaves the picker", async () => { + await world.allowPersonalProviders(false); + await user.reload(); + const count = await pickerUntil("OpenRouter gone from the picker", value => value === 0); + expect(count).toBe(0); + evidence.recordAssertionEvidence("The personal key no longer reaches the new engine", `${count} OpenRouter models listed`, count === 0); + await user.screenshot(); + await user.press("Escape"); + }); + + await step("after: AI Providers keeps the OpenRouter row with a lock that names who manages providers", async () => { + await user.navigate(world.settingsUrl); + const text = await probe.eventually(() => row("openrouter"), { + within: WITHIN, label: "the locked OpenRouter row", until: value => value.includes(`${world.organizationName} manages AI providers`), + }); + await user.see({ text: "Provider connections are managed by your organization administrator." }); + const locked = (await probe.dom('[data-testid="local-provider-openrouter"] svg.lucide-lock')).elements.length === 1; + const connectLocked = (await probe.dom('button[disabled]:has(svg.lucide-lock)')).elements.some(element => element.text.includes("Connect a provider")); + const { reasons } = await engineStatus(); + expect(text).not.toContain("Ready to use"); + expect(locked).toBe(true); + expect(connectLocked).toBe(true); + expect(reasons).toContain('{"id":"openrouter","reason":"policy"}'); + evidence.recordAssertionEvidence("Blocked, not missing: the row stays with a lock and the owner's name, and Connect a provider is locked", `${text}; lock=${locked}; Connect a provider locked=${connectLocked}; engine skipped ${reasons}`, locked && connectLocked); + await user.screenshot(); + }); +}); diff --git a/evals/worlds/v2-local-provider-mirror.ts b/evals/worlds/v2-local-provider-mirror.ts new file mode 100644 index 0000000000..22bacc6d5c --- /dev/null +++ b/evals/worlds/v2-local-provider-mirror.ts @@ -0,0 +1,91 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, realpath } from "node:fs/promises"; +import { browserScript } from "@openwork/cdp"; +import type { Seed } from "@openwork/env"; +import { readDefaultDesktopPolicy } from "./desktop-policies.ts"; +import { isRecord } from "./library.ts"; +import { seededOrganizationId } from "./organization-capabilities.ts"; +import { denForAppWeb, signedInAppWeb } from "./signed-in-app-web.ts"; + +/** + * The owner of "Acme", signed in to a real Den in the real app (in a browser) + * on the OpenCode v2 engine, with the bundled OpenCode v1 catalog behind it. + * The `localProviderMirror` rollout starts off and personal providers are + * allowed. Nothing in this computer's own OpenCode profile is read. + */ +export async function v2LocalProviderMirror(seed: Seed) { + const folder = seed.tmpPath("acme"); + await mkdir(folder, { recursive: true }); + const workspacePath = await realpath(folder); + const { den, webPort } = await denForAppWeb(seed, { org: { name: "Acme", admin: { name: "Acme Admin" } } }); + const token = randomUUID(); + const hostToken = randomUUID(); + const { app, workspace } = await signedInAppWeb(seed, { + den, webPort, name: "v2-local-provider-mirror", workspacePath, engine: "v2", headless: true, + env: { OPENWORK_TOKEN: token, OPENWORK_HOST_TOKEN: hostToken }, + }); + const organizationId = await seededOrganizationId(seed, den.admin); + if (!organizationId) throw new Error("Could not resolve the seeded organization"); + + /** The app's own local OpenWork server, called from inside its page as the app does. */ + async function request(path: string, init: { method?: string; body?: unknown } = {}): Promise<{ status: number; body: unknown }> { + const result = await seed.evalIn(app, browserScript(async (path, method, bodyJson, token, hostToken) => { + const port = localStorage.getItem("openwork.server.port"); + if (!port) return { status: 0, text: "missing local server port" }; + const response = await fetch("http://127.0.0.1:" + port + path, { + method, + headers: { Authorization: "Bearer " + token, "x-openwork-host-token": hostToken, "Content-Type": "application/json" }, + ...(bodyJson ? { body: bodyJson } : {}), + }); + return { status: response.status, text: await response.text() }; + }, [path, init.method ?? "GET", init.body === undefined ? "" : JSON.stringify(init.body), token, hostToken]), { awaitPromise: true, timeoutMs: 120_000 }); + let body: unknown = result.text; + try { body = result.text ? JSON.parse(result.text) : null; } catch { /* diagnostic text */ } + return { status: result.status, body }; + } + + // Same owned-runtime handoff as other app-in-a-browser worlds: renderer + // sign-in alone does not give the local server the Den session the desktop + // shell pushes (PUT /den-session), which organization policy and rollouts + // are read with. + const linked = await request("/den-session", { method: "PUT", body: { baseUrl: den.ref.apiUrl, token: den.admin.token, orgId: organizationId } }); + if (linked.status >= 300) throw new Error(`Den session handoff failed: HTTP ${linked.status}`); + + const setMirror = async (enabled: boolean) => { + const result = await seed.api(den.admin, `/v1/admin/organizations/${organizationId}/capabilities`, { + method: "PUT", body: JSON.stringify({ capabilities: { localProviderMirror: enabled } }), + }); + if (!result.response.ok) throw new Error(`Turning localProviderMirror ${enabled ? "on" : "off"} failed: HTTP ${result.response.status}`); + }; + + /** Saved the way the AI Gateway's "Who can use models" saves "Only models you provide". */ + const allowPersonalProviders = async (allowed: boolean) => { + const stored = await readDefaultDesktopPolicy(seed, den.admin); + const policy = { ...(isRecord(stored.policy) ? stored.policy : {}), allowCustomProviders: allowed }; + const updated = await seed.api(den.admin, `/v1/desktop-policies/${String(stored.id)}`, { + method: "PATCH", body: JSON.stringify({ policyName: stored.policyName, policy }), signal: AbortSignal.timeout(30_000), + }); + if (!updated.response.ok) throw new Error(`Saving model access failed: HTTP ${updated.response.status}`); + }; + + /** + * A ChatGPT sign-in as OpenCode v1 stores it: an OAuth entry written through + * the same engine credential route a provider sign-in ends with. The tokens + * are synthetic and never sent anywhere. + */ + const signInWithChatGpt = async () => { + const saved = await request(`/workspace/${encodeURIComponent(workspace.workspaceId)}/opencode/auth/openai`, { + method: "PUT", body: { type: "oauth", refresh: "synthetic-refresh", access: "synthetic-access", expires: Date.now() + 86_400_000 }, + }); + if (saved.status >= 300) throw new Error(`Seeding the ChatGPT sign-in failed: HTTP ${saved.status}`); + }; + + const session = await seed.session(app, { title: "Pick a model" }); + return { + app, den, workspace, session, setMirror, allowPersonalProviders, signInWithChatGpt, + organizationName: "Acme", + openRouterKey: "sk-or-v1-synthetic-not-a-real-key", + settingsUrl: `${app.webUrl}/workspace/${encodeURIComponent(workspace.workspaceId)}/settings/ai`, + sessionUrl: `${app.webUrl}/workspace/${encodeURIComponent(workspace.workspaceId)}/session/${encodeURIComponent(session.sessionId)}`, + }; +} diff --git a/packages/features/src/registry.ts b/packages/features/src/registry.ts index d3c9ac59fb..e41332fbf9 100644 --- a/packages/features/src/registry.ts +++ b/packages/features/src/registry.ts @@ -123,6 +123,15 @@ export const FEATURES = defineFeatures({ deployments: everywhere, default: false, }, + localProviderMirror: { + label: "OpenCode v2: providers added on this computer", + description: "API keys people add on their computer, such as OpenRouter, OpenAI or Anthropic, show in the chat model picker on the new engine. AI Providers says when the organization manages providers or a sign-in is not available on the new engine yet.", + since: "2026-10", + author: "benjaminshafii", + docs: "docs/features/local-provider-mirror/README.md", + deployments: everywhere, + default: false, + }, liveProviderSync: { label: "Live provider sync", description: "Keep desktop OpenCode v2 models up to date with the organization's AI Gateway in real time.", diff --git a/packages/types/src/den/desktop-policies.ts b/packages/types/src/den/desktop-policies.ts index 94edd163f9..d14212c9c4 100644 --- a/packages/types/src/den/desktop-policies.ts +++ b/packages/types/src/den/desktop-policies.ts @@ -428,6 +428,8 @@ export const desktopConfigSchema = desktopPolicyValueSchema connectEnabled: z.boolean().optional(), /** Whether the server may start the live OpenCode provider engine. Missing means off. */ liveProviderSync: z.boolean().optional(), + /** Whether the server mirrors API keys added on this computer into OpenCode v2. Missing means off. */ + localProviderMirror: z.boolean().optional(), /** OpenCode Zen models (Big Pickle and other free `opencode` models) are removed. Missing means off. */ removeOpencodeZen: z.boolean().optional(), onboardingPrompts: onboardingPromptsSchema.optional(), @@ -845,6 +847,7 @@ export function normalizeDesktopConfig(value: unknown): DesktopConfig { ...(dashboardEnabled !== undefined ? { dashboardEnabled } : {}), ...(connectEnabled !== undefined ? { connectEnabled } : {}), ...(typeof raw?.liveProviderSync === "boolean" ? { liveProviderSync: raw.liveProviderSync } : {}), + ...(typeof raw?.localProviderMirror === "boolean" ? { localProviderMirror: raw.localProviderMirror } : {}), ...(typeof raw?.removeOpencodeZen === "boolean" ? { removeOpencodeZen: raw.removeOpencodeZen } : {}), ...(onboardingPromptConfig !== undefined ? onboardingPromptConfig : {}), ...(agentPermissions.success ? { agentPermissions: agentPermissions.data } : {}), diff --git a/packaging/helm/openwork-ee/values.schema.json b/packaging/helm/openwork-ee/values.schema.json index e167e670ea..1c7dd5089c 100644 --- a/packaging/helm/openwork-ee/values.schema.json +++ b/packaging/helm/openwork-ee/values.schema.json @@ -121,6 +121,16 @@ ], "description": "Connect: an MCP server for each connection: Members can add one connected service, such as Gmail or Linear, to an agent like Claude Code as its own MCP server that lists only that service's tools. Renders DEN_FEATURE_CONNECTION_MCP_SERVERS." }, + "localProviderMirror": { + "enum": [ + "", + "true", + "false", + true, + false + ], + "description": "OpenCode v2: providers added on this computer: API keys people add on their computer, such as OpenRouter, OpenAI or Anthropic, show in the chat model picker on the new engine. AI Providers says when the organization manages providers or a sign-in is not available on the new engine yet. Renders DEN_FEATURE_LOCAL_PROVIDER_MIRROR." + }, "liveProviderSync": { "enum": [ "", diff --git a/packaging/helm/openwork-ee/values.yaml b/packaging/helm/openwork-ee/values.yaml index 0b307433a9..150e615528 100644 --- a/packaging/helm/openwork-ee/values.yaml +++ b/packaging/helm/openwork-ee/values.yaml @@ -32,6 +32,7 @@ config: sandboxOrgs: "" # default off (cloud only) · Platform admins create try-it-out organizations from /admin with fictional workplace connectors, included models and a shareable join link that expires. organizationAutomationSettings: "" # default off · Organization admins turn Desktop Automations and Cloud Automations on or off for their organization. Cloud Automations need the deployment's headless runner. connectionMcpServers: "" # default off · Members can add one connected service, such as Gmail or Linear, to an agent like Claude Code as its own MCP server that lists only that service's tools. + localProviderMirror: "" # default off · API keys people add on their computer, such as OpenRouter, OpenAI or Anthropic, show in the chat model picker on the new engine. AI Providers says when the organization manages providers or a sign-in is not available on the new engine yet. liveProviderSync: "" # default off · Keep desktop OpenCode v2 models up to date with the organization's AI Gateway in real time. agentConnectionRemoval: "" # default off · People allowed to remove a connection can ask their agent to prepare removing it, then confirm the removal in OpenWork. managedDeployments: "" # default off (cloud only) · Organization admins install OpenWork in their own AWS account or Google Cloud project and see its health and updates in OpenWork, and agents connected to OpenWork can create and follow those deployments.