diff --git a/apps/app/src/app/cloud/managed-provider-models.ts b/apps/app/src/app/cloud/managed-provider-models.ts new file mode 100644 index 0000000000..6758262118 --- /dev/null +++ b/apps/app/src/app/cloud/managed-provider-models.ts @@ -0,0 +1,63 @@ +import type { CloudImportedProvider } from "./import-state"; +import type { ModelOption, ProviderListItem } from "../types"; + +export function buildCloudManagedModelIdsByProvider( + importedCloudProviders: Record | null | undefined, +): Map> { + const next = new Map>(); + for (const imported of Object.values(importedCloudProviders ?? {})) { + const providerId = imported.providerId.trim(); + if (!providerId) continue; + const modelIds = imported.modelIds.map((id) => id.trim()).filter(Boolean); + if (!modelIds.length) continue; + const merged = next.get(providerId) ?? new Set(); + for (const modelId of modelIds) merged.add(modelId); + next.set(providerId, merged); + } + return next; +} + +export function isCloudManagedModelAllowed( + cloudManagedModelIdsByProvider: Map>, + providerId: string, + modelId: string, +) { + const allowedModelIds = cloudManagedModelIdsByProvider.get(providerId); + return !allowedModelIds || allowedModelIds.has(modelId); +} + +export function hasCloudManagedModelAllowlist( + cloudManagedModelIdsByProvider: Map>, + providerId: string, +) { + return cloudManagedModelIdsByProvider.has(providerId); +} + +export function buildCloudManagedModelOptions(input: { + providers: ProviderListItem[]; + cloudManagedModelIdsByProvider: Map>; + isRecommendedProvider?: (providerId: string) => boolean; +}): ModelOption[] { + const options: ModelOption[] = []; + for (const provider of input.providers) { + const isCloudManaged = hasCloudManagedModelAllowlist(input.cloudManagedModelIdsByProvider, provider.id); + for (const [modelId, model] of Object.entries(provider.models)) { + if (!isCloudManagedModelAllowed(input.cloudManagedModelIdsByProvider, provider.id, modelId)) continue; + options.push({ + providerID: provider.id, + modelID: modelId, + title: model.name || modelId, + description: provider.name, + behaviorTitle: "Reasoning", + behaviorLabel: "Default", + behaviorDescription: "", + behaviorValue: null, + isFree: false, + isConnected: true, + isRecommended: input.isRecommendedProvider?.(provider.id), + source: isCloudManaged || /^lpr_/i.test(provider.id) ? "cloud" : undefined, + }); + } + } + return options; +} diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index 7d07ab3c52..5eb23678f3 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -121,10 +121,13 @@ export type DenOrgLlmProviderModel = { export type DenOrgLlmProvider = { id: string; source: "models_dev" | "custom" | "openwork"; + credentialKind: "api_key" | "opencode_oauth"; providerId: string; name: string; providerConfig: Record; hasApiKey: boolean; + hasOpencodeAuth: boolean; + hasCredential: boolean; models: DenOrgLlmProviderModel[]; createdAt: string | null; updatedAt: string | null; @@ -132,6 +135,14 @@ export type DenOrgLlmProvider = { export type DenOrgLlmProviderConnection = DenOrgLlmProvider & { apiKey: string | null; + opencodeAuth: string | null; +}; + +export type DenManagedProviderSyncResult = { + status: "applied" | "failed"; + providerCount: number; + revision: string; + reason?: string; }; export type DenPluginConfigObjectType = "skill" | "agent" | "command" | "tool" | "mcp" | "hook" | "context" | "custom"; @@ -907,10 +918,13 @@ function parseDenOrgLlmProvider(value: unknown): DenOrgLlmProvider | null { return { id: value.id, source: value.source, + credentialKind: value.credentialKind === "opencode_oauth" ? "opencode_oauth" : "api_key", providerId: value.providerId, name: value.name, providerConfig: isRecord(value.providerConfig) ? value.providerConfig : {}, hasApiKey: value.hasApiKey === true, + hasOpencodeAuth: value.hasOpencodeAuth === true, + hasCredential: value.hasCredential === true || value.hasApiKey === true || value.hasOpencodeAuth === true, models: Array.isArray(value.models) ? value.models.flatMap((model) => { const parsed = parseDenOrgLlmProviderModel(model); @@ -946,6 +960,20 @@ function getDenOrgLlmProviderConnection(payload: unknown): DenOrgLlmProviderConn return { ...provider, apiKey: typeof payload.llmProvider.apiKey === "string" ? payload.llmProvider.apiKey : null, + opencodeAuth: typeof payload.llmProvider.opencodeAuth === "string" ? payload.llmProvider.opencodeAuth : null, + }; +} + +function getDenManagedProviderSyncResult(payload: unknown): DenManagedProviderSyncResult | null { + if (!isRecord(payload)) return null; + if (payload.status !== "applied" && payload.status !== "failed") return null; + if (typeof payload.providerCount !== "number" || !Number.isInteger(payload.providerCount) || payload.providerCount < 0) return null; + if (typeof payload.revision !== "string") return null; + return { + status: payload.status, + providerCount: payload.providerCount, + revision: payload.revision, + ...(typeof payload.reason === "string" ? { reason: payload.reason } : {}), }; } @@ -1771,6 +1799,27 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string return provider; }, + async syncWorkerManagedProviders(orgId: string, workerId: string): Promise { + const payload = await requestJson( + baseUrls, + `/v1/workers/${encodeURIComponent(workerId)}/managed-providers/sync`, + { + method: "POST", + token, + organizationId: orgId, + body: {}, + }, + ); + const result = getDenManagedProviderSyncResult(payload); + if (!result) { + throw new DenApiError(500, "invalid_managed_provider_sync_payload", "Managed provider sync response was invalid."); + } + if (result.status !== "applied") { + throw new DenApiError(502, "managed_provider_sync_failed", result.reason ?? "Managed provider sync failed."); + } + return result; + }, + async listOrgMarketplaces(orgId: string): Promise { const payload = await requestJson( baseUrls, diff --git a/apps/app/src/app/lib/desktop-types.ts b/apps/app/src/app/lib/desktop-types.ts index c5351a6768..f8031525b5 100644 --- a/apps/app/src/app/lib/desktop-types.ts +++ b/apps/app/src/app/lib/desktop-types.ts @@ -66,6 +66,8 @@ export type WorkspaceInfo = { openworkHostToken?: string | null; openworkWorkspaceId?: string | null; openworkWorkspaceName?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; sandboxBackend?: "docker" | "microsandbox" | null; sandboxRunId?: string | null; sandboxContainerName?: string | null; diff --git a/apps/app/src/components/model-select.tsx b/apps/app/src/components/model-select.tsx index 08077edcf9..dcf017c3e9 100644 --- a/apps/app/src/components/model-select.tsx +++ b/apps/app/src/components/model-select.tsx @@ -34,6 +34,7 @@ import { readHiddenModels } from "@/react-app/domains/session/modals/model-picke import { Settings2 } from "lucide-react"; import { openModelPickerEvent } from "@/react-app/shell/new-providers-toast"; import { newProvidersEvent } from "@/app/lib/provider-events"; +import { buildCloudManagedModelOptions } from "@/app/cloud/managed-provider-models"; function getProviderDisplayName(providerId: string) { return providerId @@ -44,7 +45,7 @@ function getProviderDisplayName(providerId: string) { } function useModelOptions(open: boolean) { - const { client, opencodeBaseUrl, selectedWorkspaceRoot } = useWorkspace(); + const { client, opencodeBaseUrl, selectedWorkspaceRoot, cloudManagedModelIdsByProvider } = useWorkspace(); const checkDesktopRestriction = useCheckDesktopRestriction(); const { data, refetch } = useProviderListQuery({ @@ -78,21 +79,10 @@ function useModelOptions(open: boolean) { restriction: "allowCustomProviders", }); - const options = getConnectedProviderItems(data) - .flatMap((provider) => - Object.entries(provider.models).map(([id, model]) => ({ - providerID: provider.id, - modelID: id, - title: model.name, - description: provider.name, - behaviorTitle: "Reasoning", - behaviorLabel: "Default", - behaviorDescription: "", - behaviorValue: null, - isFree: false, - isConnected: true, - })), - ); + const options = buildCloudManagedModelOptions({ + providers: getConnectedProviderItems(data), + cloudManagedModelIdsByProvider, + }); return options.filter((option) => { if ( @@ -110,7 +100,7 @@ function useModelOptions(open: boolean) { return true; }); - }, [checkDesktopRestriction, data]); + }, [checkDesktopRestriction, cloudManagedModelIdsByProvider, data]); } function groupByProvider(modelOptions: ModelOption[]) { diff --git a/apps/app/src/react-app/domains/connections/provider-auth/store.ts b/apps/app/src/react-app/domains/connections/provider-auth/store.ts index 5a26e07e72..2a0bc3bf4e 100644 --- a/apps/app/src/react-app/domains/connections/provider-auth/store.ts +++ b/apps/app/src/react-app/domains/connections/provider-auth/store.ts @@ -32,7 +32,7 @@ import { filterProviderList, } from "../../../../app/utils/providers"; import { getReactQueryClient } from "../../../infra/query-client"; -import { ensureProviderListQuery } from "../provider-list-query"; +import { ensureProviderListQuery, refreshProviderListQueries } from "../provider-list-query"; import type { OpenworkServerStore } from "../openwork-server-store"; import { denSessionUpdatedEvent, @@ -164,8 +164,12 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) a.length === b.length && a.every((value, index) => value === b[index]); const getCloudManagedProviderId = ( - provider: Pick, - ) => provider.source === "openwork" ? "openwork" : provider.id.trim(); + provider: Pick, + ) => { + if (provider.source === "openwork") return "openwork"; + if (provider.credentialKind === "opencode_oauth") return provider.providerId.trim(); + return provider.id.trim(); + }; const getProviderAuthWorkerType = (): "local" | "remote" => options.selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local"; @@ -1335,14 +1339,45 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const existingImported = state.importedCloudProviders[cloudProviderId] ?? null; const localProviderId = getCloudManagedProviderId(provider); const apiKey = provider.apiKey?.trim() ?? ""; + const opencodeAuth = provider.opencodeAuth?.trim() ?? ""; const env = getCloudProviderEnv(provider.providerConfig); - if (!apiKey && env.length > 0) { + if (provider.credentialKind === "opencode_oauth" && !opencodeAuth) { + throw new Error(`${provider.name} does not have a stored OpenCode OAuth credential yet.`); + } + if (provider.credentialKind === "api_key" && !apiKey && env.length > 0) { throw new Error(`${provider.name} does not have a stored organization credential yet.`); } await assertCloudProviderImportSafe(provider); - if (apiKey) { + if (provider.credentialKind === "opencode_oauth" && opencodeAuth) { + let parsedAuth: unknown; + try { + parsedAuth = JSON.parse(opencodeAuth); + } catch { + throw new Error(`${provider.name} has invalid OpenCode OAuth JSON.`); + } + if (!parsedAuth || typeof parsedAuth !== "object" || Array.isArray(parsedAuth)) { + throw new Error(`${provider.name} OpenCode OAuth auth must be a JSON object.`); + } + const authRecord = parsedAuth as Record; + if (authRecord.type !== "oauth") { + throw new Error(`${provider.name} OpenCode OAuth auth must include type "oauth".`); + } + if (typeof authRecord.access !== "string" || !authRecord.access.trim()) { + throw new Error(`${provider.name} OpenCode OAuth auth must include an access token.`); + } + if (typeof authRecord.refresh !== "string" || !authRecord.refresh.trim()) { + throw new Error(`${provider.name} OpenCode OAuth auth must include a refresh token.`); + } + if (typeof authRecord.expires !== "number" || !Number.isFinite(authRecord.expires) || authRecord.expires < 0) { + throw new Error(`${provider.name} OpenCode OAuth auth must include a non-negative numeric expires value.`); + } + await c.auth.set({ + providerID: localProviderId, + auth: parsedAuth as Parameters[0]["auth"], + }); + } else if (apiKey) { await c.auth.set({ providerID: localProviderId, auth: { type: "api", key: apiKey }, @@ -1388,6 +1423,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) .filter((id) => id !== localProviderId && id !== existingImported?.providerId); options.setDisabledProviders(nextDisabledProviders); options.markOpencodeConfigReloadRequired(); + await refreshProviders({ dispose: true }).catch(() => null); refreshSnapshot(); emitChange(); return `${t("status.connected")} ${provider.name}`; @@ -1401,6 +1437,23 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) } async function connectCloudProvider(cloudProviderId: string) { + const target = getRemoteManagedProviderSyncTarget(); + if (target) { + setStateField("providerAuthError", null); + try { + const liveProviders = await refreshCloudOrgProviders({ force: true }); + const provider = liveProviders.find((entry) => entry.id === cloudProviderId); + if (!provider) { + throw new Error("Organization provider is no longer available."); + } + await syncRemoteManagedProviders("settings_cloud_opened", liveProviders, state.importedCloudProviders); + return `${t("status.connected")} ${provider.name}`; + } catch (error) { + const message = describeProviderError(error, "Failed to sync organization provider to the remote worker."); + setStateField("providerAuthError", message); + throw error instanceof Error ? error : new Error(message); + } + } return await connectCloudProviderInternal(cloudProviderId); } @@ -1462,6 +1515,9 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) return message; }; + const shouldSurfaceCloudProviderSyncError = (reason: CloudProviderSyncReason) => + reason === "settings_cloud_opened"; + const getCloudProviderSyncContextKey = () => { const settings = readDenSettings(); return [ @@ -1498,6 +1554,76 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) (importedProvider.updatedAt ?? null) !== (provider.updatedAt ?? null) || !sameStringList(importedProvider.modelIds, sortStrings(provider.models.map((model) => model.id))); + const getRemoteManagedProviderSyncTarget = () => { + const workspace = options.selectedWorkspaceDisplay(); + if (workspace.workspaceType !== "remote") return null; + const workerId = workspace.openworkDenWorkerId?.trim() ?? ""; + if (!workerId) return null; + + const settings = readDenSettings(); + const orgId = settings.activeOrgId?.trim() ?? ""; + if (!settings.authToken?.trim() || !orgId) return null; + const workspaceOrgId = workspace.openworkDenOrgId?.trim() ?? ""; + if (workspaceOrgId && workspaceOrgId !== orgId) return null; + + return { settings, orgId, workerId }; + }; + + const rememberRemoteManagedProviderSync = async (providers: DenOrgLlmProvider[]) => { + const nextImportedProviders = Object.fromEntries( + providers.map((provider) => [ + provider.id, + { + cloudProviderId: provider.id, + providerId: getCloudManagedProviderId(provider), + sourceProviderId: provider.providerId, + name: provider.name, + source: provider.source, + updatedAt: provider.updatedAt ?? null, + modelIds: getProviderModelIds(provider), + importedAt: Date.now(), + }, + ]), + ); + await persistImportedCloudProviders(nextImportedProviders); + }; + + const syncRemoteManagedProviders = async ( + reason: CloudProviderSyncReason, + liveProviders: DenOrgLlmProvider[], + importedProviders: Record, + ) => { + const target = getRemoteManagedProviderSyncTarget(); + if (!target) return false; + + const den = createDenClient({ + baseUrl: target.settings.baseUrl, + apiBaseUrl: target.settings.apiBaseUrl, + token: target.settings.authToken, + }); + await den.syncWorkerManagedProviders(target.orgId, target.workerId); + await rememberRemoteManagedProviderSync(liveProviders); + await refreshProviders({ dispose: true }).catch(() => null); + await refreshProviderListQueries(getReactQueryClient()).catch(() => undefined); + const newlyImported = liveProviders.filter((provider) => !importedProviders[provider.id]); + if (newlyImported.length > 0) { + dispatchNewProviders({ + providers: newlyImported.map((provider) => { + const firstModel = provider.models[0] ?? null; + return { + id: provider.id, + name: provider.name, + providerId: provider.providerId, + firstModelId: firstModel?.id, + firstModelName: firstModel?.name ?? firstModel?.id, + }; + }), + source: reason === "sign_in" ? "sign_in" : "cloud_sync", + }); + } + return true; + }; + async function performCloudProviderSync(reason: CloudProviderSyncReason) { if (!hasCloudProviderSyncPrerequisites()) { return; @@ -1507,6 +1633,11 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) refreshImportedCloudProviders(), refreshCloudOrgProviders({ force: true }), ]); + + if (await syncRemoteManagedProviders(reason, liveProviders, importedProviders)) { + return; + } + const liveProviderMap = new Map(liveProviders.map((provider) => [provider.id, provider])); const failures: string[] = []; const processedLiveProviderIds = new Set(); @@ -1567,6 +1698,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) if (configChanged) { await refreshProviders({ dispose: true }).catch(() => null); + await refreshProviderListQueries(getReactQueryClient()).catch(() => undefined); } // Notify the UI about newly imported providers so the global toast @@ -1592,7 +1724,7 @@ export function createProviderAuthStore(options: CreateProviderAuthStoreOptions) const request = performCloudProviderSync(reason) .catch((error) => { const message = logCloudProviderSyncError(reason, error); - if (reason === "settings_cloud_opened") { + if (shouldSurfaceCloudProviderSyncError(reason)) { setStateField("providerAuthError", message); } }) diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 4662c71a99..dab58ff97a 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -125,6 +125,10 @@ import { useReactRenderWatchdog } from "./react-render-watchdog"; import { readDenSettings } from "../../app/lib/den"; import { denSessionUpdatedEvent } from "../../app/lib/den-session-events"; +import { + buildCloudManagedModelOptions, + buildCloudManagedModelIdsByProvider, +} from "../../app/cloud/managed-provider-models"; import { openModelPickerEvent, pendingModelPickerProviderIdsKey } from "./new-providers-toast"; import { getModelBehaviorSummary } from "../../app/lib/model-behavior"; @@ -1611,6 +1615,10 @@ export function SessionRoute() { // sync here so sign-in applies opencode.json changes before Settings opens. useCloudProviderAutoSync(sessionProviderAuthStore.runCloudProviderSync); const sessionProviderAuthSnapshot = useProviderAuthStoreSnapshot(sessionProviderAuthStore); + const cloudManagedModelIdsByProvider = useMemo( + () => buildCloudManagedModelIdsByProvider(sessionProviderAuthSnapshot.importedCloudProviders), + [sessionProviderAuthSnapshot.importedCloudProviders], + ); const permissionQueryKey = useMemo( () => selectedWorkspaceId && selectedSessionId @@ -1891,28 +1899,11 @@ export function SessionRoute() { } catch { seenIds = new Set(); } - const options: ModelOption[] = []; - for (const provider of getConnectedProviderItems(data)) { - const modelIds = Object.keys(provider.models); - const isNew = !seenIds.has(provider.id) || recentProviderIds.has(provider.id); - for (const id of modelIds) { - const model = provider.models[id]; - options.push({ - providerID: provider.id, - modelID: id, - title: model.name || id, - description: provider.name, - behaviorTitle: "Reasoning", - behaviorLabel: "Default", - behaviorDescription: "", - behaviorValue: null, - isFree: false, - isConnected: true, - isRecommended: isNew, - source: /^lpr_/i.test(provider.id) ? "cloud" as const : undefined, - }); - } - } + const options = buildCloudManagedModelOptions({ + providers: getConnectedProviderItems(data), + cloudManagedModelIdsByProvider, + isRecommendedProvider: (providerId) => !seenIds.has(providerId) || recentProviderIds.has(providerId), + }); setModelOptions(options); } catch { // Silent: the picker surfaces an empty list rather than blocking the UI. @@ -1921,7 +1912,7 @@ export function SessionRoute() { return () => { cancelled = true; }; - }, [modelPickerOpen, opencodeBaseUrl, opencodeClient, recentProviderIds, selectedWorkspaceRoot]); + }, [cloudManagedModelIdsByProvider, modelPickerOpen, opencodeBaseUrl, opencodeClient, recentProviderIds, selectedWorkspaceRoot]); // Apply org-level restrictions (dev #1505) on top of the raw model list // so the picker never surfaces blocked options: @@ -2663,6 +2654,7 @@ export function SessionRoute() { client={opencodeClient} opencodeBaseUrl={opencodeBaseUrl} selectedWorkspaceRoot={selectedWorkspaceRoot} + cloudManagedModelIdsByProvider={cloudManagedModelIdsByProvider} > {opencodeClient && selectedWorkspaceEndpoint && opencodeBaseUrl && selectedWorkspaceServerToken ? ( buildCloudManagedModelIdsByProvider(providerAuthSnapshot.importedCloudProviders), + [providerAuthSnapshot.importedCloudProviders], + ); const showOpenWorkModelsSubscribe = !cloudSession.isSignedIn || !hasOpenWorkCloudProvider; const subscribeToOpenWorkModels = useCallback(() => { diff --git a/apps/app/src/react-app/shell/workspace-provider.ts b/apps/app/src/react-app/shell/workspace-provider.ts index fc3af36762..78f8970205 100644 --- a/apps/app/src/react-app/shell/workspace-provider.ts +++ b/apps/app/src/react-app/shell/workspace-provider.ts @@ -6,6 +6,7 @@ type WorkspaceContextValue = { client: Client | null; opencodeBaseUrl: string; selectedWorkspaceRoot: string; + cloudManagedModelIdsByProvider: Map>; }; const WorkspaceContext = React.createContext(null); @@ -14,6 +15,7 @@ type WorkspaceProviderProps = { client: Client | null; opencodeBaseUrl?: string; selectedWorkspaceRoot: string; + cloudManagedModelIdsByProvider?: Map>; children: React.ReactNode; }; @@ -21,11 +23,17 @@ export function WorkspaceProvider({ client, opencodeBaseUrl = "", selectedWorkspaceRoot, + cloudManagedModelIdsByProvider, children, }: WorkspaceProviderProps) { const value = React.useMemo( - () => ({ client, opencodeBaseUrl, selectedWorkspaceRoot }), - [client, opencodeBaseUrl, selectedWorkspaceRoot], + () => ({ + client, + opencodeBaseUrl, + selectedWorkspaceRoot, + cloudManagedModelIdsByProvider: cloudManagedModelIdsByProvider ?? new Map>(), + }), + [client, cloudManagedModelIdsByProvider, opencodeBaseUrl, selectedWorkspaceRoot], ); return React.createElement(WorkspaceContext.Provider, { value }, children); diff --git a/apps/app/tests/den-managed-provider-sync.test.ts b/apps/app/tests/den-managed-provider-sync.test.ts new file mode 100644 index 0000000000..a580e4b615 --- /dev/null +++ b/apps/app/tests/den-managed-provider-sync.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { createDenClient, DenApiError } from "../src/app/lib/den"; + +const originalFetch = globalThis.fetch; + +describe("Den managed provider worker sync client", () => { + afterEach(() => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: originalFetch, + }); + }); + + test("posts to the org-scoped worker sync endpoint", async () => { + const calls: Array<{ url: string; method: string; org: string | null; authorized: boolean; body: string | null }> = []; + const fetchMock: typeof fetch = async (input, init) => { + const headers = new Headers(init?.headers); + calls.push({ + url: String(input), + method: init?.method ?? "GET", + org: headers.get("x-openwork-legacy-org-id"), + authorized: headers.get("authorization") === "Bearer user-token", + body: typeof init?.body === "string" ? init.body : null, + }); + return new Response(JSON.stringify({ + status: "applied", + providerCount: 1, + revision: "safe-revision", + }), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); + }; + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: fetchMock, + }); + + const client = createDenClient({ baseUrl: "http://den.local", token: "user-token" }); + const result = await client.syncWorkerManagedProviders("org_test", "wrk_test"); + + expect(result).toEqual({ status: "applied", providerCount: 1, revision: "safe-revision" }); + expect(calls).toEqual([{ + url: "http://den.local/v1/workers/wrk_test/managed-providers/sync", + method: "POST", + org: "org_test", + authorized: true, + body: "{}", + }]); + }); + + test("surfaces sanitized worker sync failures", async () => { + const secret = "sk-secret-value"; + const fetchMock: typeof fetch = async () => new Response(JSON.stringify({ + error: "managed_provider_sync_failed", + message: "Worker provider sync failed.", + details: { redacted: true }, + secret, + }), { + headers: { "Content-Type": "application/json" }, + status: 502, + }); + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: fetchMock, + }); + + const client = createDenClient({ baseUrl: "http://den.local", token: "user-token" }); + + await expect(client.syncWorkerManagedProviders("org_test", "wrk_test")).rejects.toThrow("Worker provider sync failed."); + try { + await client.syncWorkerManagedProviders("org_test", "wrk_test"); + } catch (error) { + expect(error).toBeInstanceOf(DenApiError); + expect(error instanceof Error ? error.message.includes(secret) : true).toBe(false); + } + }); +}); diff --git a/apps/app/tests/managed-provider-models.test.ts b/apps/app/tests/managed-provider-models.test.ts new file mode 100644 index 0000000000..38384d0a86 --- /dev/null +++ b/apps/app/tests/managed-provider-models.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildCloudManagedModelOptions, + buildCloudManagedModelIdsByProvider, + hasCloudManagedModelAllowlist, + isCloudManagedModelAllowed, +} from "../src/app/cloud/managed-provider-models"; +import type { CloudImportedProvider } from "../src/app/cloud/import-state"; +import type { ProviderListItem } from "../src/app/types"; + +function importedProvider(input: Pick): CloudImportedProvider { + return { + ...input, + source: "models_dev", + updatedAt: null, + importedAt: 1, + }; +} + +function visibleModelIds(providerId: string, modelIds: string[], allowlist: Map>) { + return modelIds.filter((modelId) => isCloudManagedModelAllowed(allowlist, providerId, modelId)); +} + +function provider(id: string, name: string, modelIds: string[]): ProviderListItem { + return { + id, + name, + source: "config", + models: Object.fromEntries(modelIds.map((modelId) => [modelId, { id: modelId, name: modelId }])), + }; +} + +function staleOpenAiModelIds(): string[] { + const explicit = [ + "gpt-5.4", + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.5-fast", + "text-embedding-3-large", + "gpt-4o", + "gpt-image-1-mini", + "gpt-5.4-fast", + "o4-mini", + ]; + const generated = Array.from({ length: 45 }, (_, index) => `stale-openai-catalog-${index + 1}`); + return [...explicit, ...generated]; +} + +describe("managed cloud provider model allowlists", () => { + test("session modal and compact select option builder filters 54 stale OpenAI models to selected IDs", () => { + const allowlist = buildCloudManagedModelIdsByProvider({ + lpr_openai: importedProvider({ + cloudProviderId: "lpr_openai", + providerId: "openai", + sourceProviderId: "openai", + name: "openAI_server", + modelIds: ["gpt-5.4", "gpt-5.5"], + }), + }); + + const rawOpenAiProviderListIds = staleOpenAiModelIds(); + + expect(rawOpenAiProviderListIds).toHaveLength(54); + expect(hasCloudManagedModelAllowlist(allowlist, "openai")).toBe(true); + expect(visibleModelIds("openai", rawOpenAiProviderListIds, allowlist)).toEqual(["gpt-5.4", "gpt-5.5"]); + expect(buildCloudManagedModelOptions({ + providers: [provider("openai", "openAI_server", rawOpenAiProviderListIds)], + cloudManagedModelIdsByProvider: allowlist, + isRecommendedProvider: (providerId) => providerId === "openai", + }).map((option) => ({ + providerID: option.providerID, + modelID: option.modelID, + source: option.source, + isRecommended: option.isRecommended, + }))).toEqual([ + { providerID: "openai", modelID: "gpt-5.4", source: "cloud", isRecommended: true }, + { providerID: "openai", modelID: "gpt-5.5", source: "cloud", isRecommended: true }, + ]); + }); + + test("keeps API-key NVIDIA managed provider selected IDs intact", () => { + const allowlist = buildCloudManagedModelIdsByProvider({ + lpr_nvidia: importedProvider({ + cloudProviderId: "lpr_nvidia", + providerId: "lpr_nvidia", + sourceProviderId: "nvidia", + name: "nvidia", + modelIds: ["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"], + }), + }); + + expect(visibleModelIds("lpr_nvidia", ["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"], allowlist)).toEqual([ + "deepseek-ai/deepseek-v4-flash", + "google/gemma-4-31b-it", + ]); + expect(buildCloudManagedModelOptions({ + providers: [provider("lpr_nvidia", "nvidia", ["deepseek-ai/deepseek-v4-flash", "google/gemma-4-31b-it"])], + cloudManagedModelIdsByProvider: allowlist, + }).map((option) => option.modelID)).toEqual([ + "deepseek-ai/deepseek-v4-flash", + "google/gemma-4-31b-it", + ]); + }); + + test("does not filter non-managed providers without imported model IDs", () => { + const allowlist = buildCloudManagedModelIdsByProvider({}); + + expect(visibleModelIds("anthropic", ["claude-sonnet", "claude-opus"], allowlist)).toEqual([ + "claude-sonnet", + "claude-opus", + ]); + }); + + test("merges duplicate imported provider model allowlists by provider ID", () => { + const allowlist = buildCloudManagedModelIdsByProvider({ + llmProvider_openai_one: importedProvider({ + cloudProviderId: "llmProvider_openai_one", + providerId: "openai", + sourceProviderId: "openai", + name: "OpenAI one", + modelIds: ["gpt-5.4"], + }), + llmProvider_openai_two: importedProvider({ + cloudProviderId: "llmProvider_openai_two", + providerId: "openai", + sourceProviderId: "openai", + name: "OpenAI two", + modelIds: ["gpt-5.5"], + }), + }); + + expect(visibleModelIds("openai", ["gpt-5.4", "gpt-5.5", "gpt-4o"], allowlist)).toEqual(["gpt-5.4", "gpt-5.5"]); + }); +}); diff --git a/ee/apps/den-api/src/routes/org/llm-providers.ts b/ee/apps/den-api/src/routes/org/llm-providers.ts index 5cc66da166..102e66d22a 100644 --- a/ee/apps/den-api/src/routes/org/llm-providers.ts +++ b/ee/apps/den-api/src/routes/org/llm-providers.ts @@ -1,7 +1,6 @@ import { and, desc, eq, inArray, isNotNull, isNull, or } from "@openwork-ee/den-db/drizzle" import { AuthUserTable, - InvitationTable, LlmProviderAccessTable, LlmProviderModelTable, LlmProviderTable, @@ -33,6 +32,10 @@ type LlmProviderAccessId = typeof LlmProviderAccessTable.$inferSelect.id type MemberId = typeof MemberTable.$inferSelect.id type TeamId = typeof TeamTable.$inferSelect.id type LlmProviderRow = typeof LlmProviderTable.$inferSelect +const OPENAI_AUTH_ISSUER = "https://auth.openai.com" +const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +const OPENAI_DEVICE_REDIRECT_URI = `${OPENAI_AUTH_ISSUER}/deviceauth/callback` +const OPENAI_DEVICE_POLLING_SAFETY_MARGIN_MS = 3000 type RouteFailure = { status: number @@ -40,11 +43,6 @@ type RouteFailure = { message?: string } -function getInvitedMemberName(email: string) { - const [localPart, domain = "invited"] = email.split("@") - return `${localPart} ${domain.split(".")[0] ?? "invited"}`.trim() -} - const providerCatalogParamsSchema = z.object({ providerId: z.string().trim().min(1).max(255), }) @@ -73,10 +71,12 @@ const customProviderSchema = z.object({ const llmProviderWriteSchema = z.object({ name: z.string().trim().min(1).max(255), source: z.enum(["models_dev", "custom"]), + credentialKind: z.enum(["api_key", "opencode_oauth"]).optional().default("api_key"), providerId: z.string().trim().min(1).max(255).optional(), modelIds: z.array(z.string().trim().min(1).max(255)).min(1).optional(), customConfigText: z.string().trim().min(1).optional(), apiKey: z.string().trim().max(65535).optional(), + opencodeAuth: z.string().trim().max(65535).optional(), memberIds: z.array(denTypeIdSchema("member")).max(500).optional().default([]), teamIds: z.array(denTypeIdSchema("team")).max(500).optional().default([]), }).superRefine((value, ctx) => { @@ -105,6 +105,16 @@ const llmProviderWriteSchema = z.object({ message: "Paste a custom provider config.", }) } + + if (value.credentialKind === "opencode_oauth") { + if (value.source !== "models_dev" || !isOpencodeOauthProviderAllowed(value.providerId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["credentialKind"], + message: "OpenCode OAuth credentials can only be used with the OpenAI catalog provider.", + }) + } + } }) const providerCatalogListResponseSchema = z.object({ @@ -133,6 +143,24 @@ const conflictSchema = z.object({ message: z.string().optional(), }).meta({ ref: "ConflictError" }) +const openAiOauthStartResponseSchema = z.object({ + verificationUrl: z.string(), + userCode: z.string(), + deviceAuthId: z.string(), + intervalMs: z.number(), +}).meta({ ref: "OpenAiOauthStartResponse" }) + +const openAiOauthCompleteSchema = z.object({ + deviceAuthId: z.string().trim().min(1), + userCode: z.string().trim().min(1), +}) + +const openAiOauthCompleteResponseSchema = z.object({ + opencodeAuth: z.string(), + accountId: z.string().nullable(), + expires: z.number(), +}).meta({ ref: "OpenAiOauthCompleteResponse" }) + function createFailure(status: number, error: string, message?: string): RouteFailure { return { status, error, message } } @@ -141,10 +169,130 @@ function isRouteFailure(value: unknown): value is RouteFailure { return typeof value === "object" && value !== null && "status" in value && "error" in value } +function parseJwtClaims(token: string): Record | null { + const parts = token.split(".") + if (parts.length !== 3 || !parts[1]) return null + try { + return JSON.parse(Buffer.from(parts[1], "base64url").toString()) as Record + } catch { + return null + } +} + +function extractOpenAiAccountId(tokens: { id_token?: string; access_token?: string }) { + const claims = tokens.id_token ? parseJwtClaims(tokens.id_token) : tokens.access_token ? parseJwtClaims(tokens.access_token) : null + if (!claims) return null + const apiAuth = claims["https://api.openai.com/auth"] + if (typeof claims.chatgpt_account_id === "string") return claims.chatgpt_account_id + if (apiAuth && typeof apiAuth === "object" && !Array.isArray(apiAuth) && typeof (apiAuth as Record).chatgpt_account_id === "string") { + return (apiAuth as Record).chatgpt_account_id + } + const organizations = claims.organizations + if (Array.isArray(organizations)) { + const first = organizations.find((entry): entry is Record => typeof entry === "object" && entry !== null && !Array.isArray(entry)) + if (typeof first?.id === "string") return first.id + } + return null +} + +export async function startOpenAiDeviceAuth() { + const response = await fetch(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/usercode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "opencode/den", + }, + body: JSON.stringify({ client_id: OPENAI_CODEX_CLIENT_ID }), + }) + if (!response.ok) { + throw createFailure(502, "openai_oauth_start_failed", `OpenAI device authorization failed with ${response.status}.`) + } + const data = await response.json() as { device_auth_id?: unknown; user_code?: unknown; interval?: unknown } + if (typeof data.device_auth_id !== "string" || typeof data.user_code !== "string") { + throw createFailure(502, "openai_oauth_start_failed", "OpenAI device authorization response was incomplete.") + } + const interval = Math.max(Number.parseInt(String(data.interval ?? "5"), 10) || 5, 1) * 1000 + return { + verificationUrl: `${OPENAI_AUTH_ISSUER}/codex/device`, + userCode: data.user_code, + deviceAuthId: data.device_auth_id, + intervalMs: interval + OPENAI_DEVICE_POLLING_SAFETY_MARGIN_MS, + } +} + +export async function completeOpenAiDeviceAuth(input: { deviceAuthId: string; userCode: string }) { + const deviceResponse = await fetch(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "opencode/den", + }, + body: JSON.stringify({ + device_auth_id: input.deviceAuthId, + user_code: input.userCode, + }), + }) + + if (deviceResponse.status === 403 || deviceResponse.status === 404) { + throw createFailure(409, "openai_oauth_pending", "OpenAI authorization is not complete yet.") + } + if (!deviceResponse.ok) { + throw createFailure(502, "openai_oauth_complete_failed", `OpenAI device authorization failed with ${deviceResponse.status}.`) + } + const deviceData = await deviceResponse.json() as { authorization_code?: unknown; code_verifier?: unknown } + if (typeof deviceData.authorization_code !== "string" || typeof deviceData.code_verifier !== "string") { + throw createFailure(502, "openai_oauth_complete_failed", "OpenAI device token response was incomplete.") + } + + const tokenResponse = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: deviceData.authorization_code, + redirect_uri: OPENAI_DEVICE_REDIRECT_URI, + client_id: OPENAI_CODEX_CLIENT_ID, + code_verifier: deviceData.code_verifier, + }).toString(), + }) + if (!tokenResponse.ok) { + throw createFailure(502, "openai_oauth_complete_failed", `OpenAI token exchange failed with ${tokenResponse.status}.`) + } + const tokens = await tokenResponse.json() as { id_token?: string; access_token?: string; refresh_token?: string; expires_in?: number } + if (!tokens.access_token || !tokens.refresh_token) { + throw createFailure(502, "openai_oauth_complete_failed", "OpenAI token response did not include OAuth tokens.") + } + const expires = Date.now() + (tokens.expires_in ?? 3600) * 1000 + const accountId = extractOpenAiAccountId(tokens) + return { + opencodeAuth: JSON.stringify({ + type: "oauth", + refresh: tokens.refresh_token, + access: tokens.access_token, + expires, + ...(accountId ? { accountId } : {}), + }), + accountId, + expires, + } +} + function isOrganizationAdmin(payload: { currentMember: { isOwner: boolean; role: string } }) { return payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin") } +export function isOpencodeOauthProviderAllowed(providerId: string | undefined | null) { + return providerId?.trim().toLowerCase() === "openai" +} + +export function canUseOpenAiOAuthCredentialFlow(payload: { currentMember: { isOwner: boolean; role: string } }) { + return isOrganizationAdmin(payload) +} + +export function canImportLlmProviderCredential(payload: { currentMember: { isOwner: boolean; role: string } }) { + return isOrganizationAdmin(payload) +} + function canManageLlmProvider( payload: { currentMember: { id: MemberId; isOwner: boolean; role: string } }, provider: LlmProviderRow, @@ -175,6 +323,66 @@ function parseLlmProviderAccessId(value: string) { return normalizeDenTypeId("llmProviderAccess", value) } +export function getCredentialFlags(provider: Pick) { + const hasApiKey = Boolean(provider.apiKey && provider.apiKey.trim().length > 0) + const hasOpencodeAuth = Boolean(provider.opencodeAuth && provider.opencodeAuth.trim().length > 0) + return { + hasApiKey, + hasOpencodeAuth, + hasCredential: provider.credentialKind === "opencode_oauth" ? hasOpencodeAuth : hasApiKey, + } +} + +export function redactLlmProviderCredentials(provider: T): Omit & { apiKey: undefined; opencodeAuth: undefined } { + return { + ...provider, + apiKey: undefined, + opencodeAuth: undefined, + } +} + +function buildLlmProviderCredentialPayload(provider: LlmProviderRow) { + return { + ...redactLlmProviderCredentials(provider), + ...getCredentialFlags(provider), + apiKey: provider.credentialKind === "api_key" ? provider.apiKey : undefined, + opencodeAuth: provider.credentialKind === "opencode_oauth" ? provider.opencodeAuth : undefined, + } +} + +function normalizeOpencodeAuth(value: string | undefined) { + const trimmed = value?.trim() + if (!trimmed) return null + + try { + const parsed = JSON.parse(trimmed) as unknown + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("OpenCode OAuth auth must be a JSON object.") + } + const auth = parsed as Record + if (auth.type !== "oauth") { + throw new Error('OpenCode OAuth auth must include "type": "oauth".') + } + if (typeof auth.access !== "string" || !auth.access.trim()) { + throw new Error("OpenCode OAuth auth must include an access token.") + } + if (typeof auth.refresh !== "string" || !auth.refresh.trim()) { + throw new Error("OpenCode OAuth auth must include a refresh token.") + } + if (typeof auth.expires !== "number" || !Number.isFinite(auth.expires) || auth.expires < 0) { + throw new Error("OpenCode OAuth auth must include a non-negative numeric expires value.") + } + } catch (error) { + throw createFailure( + 400, + "invalid_opencode_auth", + error instanceof Error ? error.message : "OpenCode OAuth auth must be valid JSON.", + ) + } + + return trimmed +} + function parseMemberId(value: string) { return normalizeDenTypeId("member", value) } @@ -274,6 +482,10 @@ async function resolveTeamIds(input: { } async function normalizeLlmProviderInput(input: z.infer) { + const credentialKind = input.credentialKind + const apiKey = credentialKind === "api_key" ? input.apiKey?.trim() || null : null + const opencodeAuth = credentialKind === "opencode_oauth" ? normalizeOpencodeAuth(input.opencodeAuth) : null + if (input.source === "models_dev") { const provider = await getModelsDevProvider(input.providerId ?? "") if (!provider) { @@ -290,10 +502,9 @@ async function normalizeLlmProviderInput(input: z.infer ({ ...provider, - hasApiKey: Boolean(provider.apiKey && provider.apiKey.trim().length > 0), + ...getCredentialFlags(provider), models: (modelsByProviderId.get(provider.id) ?? []) .map((model) => ({ id: model.modelId, @@ -474,21 +687,13 @@ async function loadLlmProviders(input: { })) .sort((left, right) => left.name.localeCompare(right.name)), access: { - members: (memberAccessByProviderId.get(provider.id) ?? []).map((row) => { - const email = row.user?.email ?? row.invitation?.email ?? "invited@example.com" - return { - id: row.access.id, - orgMembershipId: row.member.id, - role: row.member.role, - user: { - id: row.user?.id ?? row.member.id, - name: row.user?.name ?? getInvitedMemberName(email), - email, - image: row.user?.image ?? null, - }, - createdAt: row.access.createdAt, - } - }), + members: (memberAccessByProviderId.get(provider.id) ?? []).map((row) => ({ + id: row.access.id, + orgMembershipId: row.member.id, + role: row.member.role, + user: row.user, + createdAt: row.access.createdAt, + })), teams: (teamAccessByProviderId.get(provider.id) ?? []).map((row) => ({ id: row.access.id, teamId: row.team.id, @@ -502,6 +707,69 @@ async function loadLlmProviders(input: { } export function registerOrgLlmProviderRoutes }>(app: Hono) { + app.post( + "/v1/llm-providers/openai-oauth/start", + describeRoute({ + tags: ["LLM Providers"], + summary: "Start OpenAI OAuth device flow", + description: "Starts the same OpenAI/ChatGPT device auth flow used by OpenCode and returns the user code.", + responses: { + 200: jsonResponse("OpenAI OAuth device flow started successfully.", openAiOauthStartResponseSchema), + 401: jsonResponse("The caller must be signed in to connect OpenAI.", unauthorizedSchema), + 403: jsonResponse("Only organization admins can connect OpenAI OAuth credentials.", forbiddenSchema), + 502: jsonResponse("OpenAI OAuth could not be started.", conflictSchema), + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + async (c) => { + const payload = c.get("organizationContext") + if (!canUseOpenAiOAuthCredentialFlow(payload)) { + return c.json({ error: "forbidden", message: "Only organization admins can connect OpenAI OAuth credentials." }, 403) + } + + try { + return c.json(await startOpenAiDeviceAuth()) + } catch (error) { + if (isRouteFailure(error)) return c.json({ error: error.error, message: error.message }, { status: error.status as 409 | 502 }) + throw error + } + }, + ) + + app.post( + "/v1/llm-providers/openai-oauth/complete", + describeRoute({ + tags: ["LLM Providers"], + summary: "Complete OpenAI OAuth device flow", + description: "Completes OpenAI device auth and returns an OpenCode-native OAuth auth object serialized as JSON.", + responses: { + 200: jsonResponse("OpenAI OAuth completed successfully.", openAiOauthCompleteResponseSchema), + 401: jsonResponse("The caller must be signed in to complete OpenAI auth.", unauthorizedSchema), + 403: jsonResponse("Only organization admins can connect OpenAI OAuth credentials.", forbiddenSchema), + 409: jsonResponse("OpenAI authorization is still pending.", conflictSchema), + 502: jsonResponse("OpenAI OAuth could not be completed.", conflictSchema), + }, + }), + requireUserMiddleware, + resolveOrganizationContextMiddleware, + jsonValidator(openAiOauthCompleteSchema), + async (c) => { + const payload = c.get("organizationContext") + if (!canUseOpenAiOAuthCredentialFlow(payload)) { + return c.json({ error: "forbidden", message: "Only organization admins can connect OpenAI OAuth credentials." }, 403) + } + + const input = c.req.valid("json") + try { + return c.json(await completeOpenAiDeviceAuth(input)) + } catch (error) { + if (isRouteFailure(error)) return c.json({ error: error.error, message: error.message }, { status: error.status as 409 | 502 }) + throw error + } + }, + ) + app.get( "/v1/llm-provider-catalog", describeRoute({ @@ -607,8 +875,7 @@ export function registerOrgLlmProviderRoutes ({ - ...provider, - apiKey: undefined, + ...redactLlmProviderCredentials(provider), canManage: canManageLlmProvider(payload, provider), })), }) @@ -677,7 +944,72 @@ export function registerOrgLlmProviderRoutes ({ + id: model.modelId, + name: model.name, + config: model.modelConfig, + createdAt: model.createdAt, + })) + .sort((left, right) => left.name.localeCompare(right.name)), + }, + }) + }, + ) + + app.get( + "/v1/llm-providers/:llmProviderId/import-credential", + describeRoute({ + tags: ["LLM Providers"], + summary: "Get LLM provider import credential", + description: "Returns a stored organization LLM provider credential for explicit import into a managed OpenCode client.", + responses: { + 200: jsonResponse("Provider import credential returned successfully.", llmProviderResponseSchema), + 400: jsonResponse("The provider import path parameters were invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to import an organization LLM provider credential.", unauthorizedSchema), + 403: jsonResponse("Only members who can manage this provider can import its credential.", forbiddenSchema), + 404: jsonResponse("The provider could not be found.", notFoundSchema), + }, + }), + requireUserMiddleware, + paramValidator(orgLlmProviderParamsSchema), + resolveOrganizationContextMiddleware, + async (c) => { + const payload = c.get("organizationContext") + const params = c.req.valid("param") + + let llmProviderId: LlmProviderId + try { + llmProviderId = parseLlmProviderId(params.llmProviderId) + } catch { + return c.json({ error: "llm_provider_not_found" }, 404) + } + + const providerRows = await db + .select() + .from(LlmProviderTable) + .where(and(eq(LlmProviderTable.id, llmProviderId), eq(LlmProviderTable.organizationId, payload.organization.id))) + .limit(1) + + const provider = providerRows[0] + if (!provider) { + return c.json({ error: "llm_provider_not_found" }, 404) + } + + if (!canImportLlmProviderCredential(payload)) { + return c.json({ error: "forbidden", message: "Only organization admins can import provider credentials." }, 403) + } + + const models = await db + .select() + .from(LlmProviderModelTable) + .where(eq(LlmProviderModelTable.llmProviderId, llmProviderId)) + + return c.json({ + llmProvider: { + ...buildLlmProviderCredentialPayload(provider), models: models .map((model) => ({ id: model.modelId, @@ -732,10 +1064,12 @@ export function registerOrgLlmProviderRoutes { + seedRequiredEnv() + llmProviderModule = await import("../src/routes/org/llm-providers.js") +}) + +test("generic provider payload redaction removes API key and OAuth auth material", () => { + const redacted = llmProviderModule.redactLlmProviderCredentials({ + id: "llmProvider_secret_123", + apiKey: "plain-secret", + opencodeAuth: JSON.stringify({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + }) + + expect(redacted).toEqual({ + id: "llmProvider_secret_123", + apiKey: undefined, + opencodeAuth: undefined, + }) +}) + +test("credential flags expose presence only, never credential values", () => { + expect(llmProviderModule.getCredentialFlags({ + credentialKind: "opencode_oauth", + apiKey: "plain-secret", + opencodeAuth: JSON.stringify({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + })).toEqual({ hasApiKey: true, hasOpencodeAuth: true, hasCredential: true }) +}) + +test("credential import permission gate requires organization admin role", () => { + const owner = { currentMember: { isOwner: true, role: "member" } } + const admin = { currentMember: { isOwner: false, role: "admin" } } + const creatorOnly = { currentMember: { isOwner: false, role: "member" } } + + expect(llmProviderModule.canImportLlmProviderCredential(owner)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(admin)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(creatorOnly)).toBe(false) +}) + +test("purpose-specific import endpoint requires authentication", async () => { + const app = new Hono() + llmProviderModule.registerOrgLlmProviderRoutes(app) + + const response = await app.request("http://den.local/v1/llm-providers/llmProvider_secret_123/import-credential", { + method: "GET", + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) +}) diff --git a/ee/apps/den-api/test/llm-providers-oauth.test.ts b/ee/apps/den-api/test/llm-providers-oauth.test.ts new file mode 100644 index 0000000000..92a9e789e4 --- /dev/null +++ b/ee/apps/den-api/test/llm-providers-oauth.test.ts @@ -0,0 +1,198 @@ +import { beforeAll, expect, test } from "bun:test" +import { readFile } from "node:fs/promises" +import { Hono } from "hono" + +function seedRequiredEnv() { + process.env.DATABASE_URL = process.env.DATABASE_URL ?? "mysql://root:password@127.0.0.1:3306/openwork_test" + process.env.DEN_DB_ENCRYPTION_KEY = process.env.DEN_DB_ENCRYPTION_KEY ?? "x".repeat(32) + process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "y".repeat(32) + process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://127.0.0.1:8790" + process.env.CORS_ORIGINS = process.env.CORS_ORIGINS ?? "http://127.0.0.1:8790" +} + +let llmProviderModule: typeof import("../src/routes/org/llm-providers.js") + +beforeAll(async () => { + seedRequiredEnv() + llmProviderModule = await import("../src/routes/org/llm-providers.js") +}) + +function createRouteApp() { + const app = new Hono() + llmProviderModule.registerOrgLlmProviderRoutes(app) + return app +} + +function jwtWithClaims(claims: Record) { + return [ + Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"), + Buffer.from(JSON.stringify(claims)).toString("base64url"), + "signature", + ].join(".") +} + +test("generic provider payload redaction removes API key and OAuth auth material", () => { + const redacted = llmProviderModule.redactLlmProviderCredentials({ + id: "llmProvider_secret_123", + apiKey: "sk-secret", + opencodeAuth: JSON.stringify({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + }) + + expect(redacted).toEqual({ + id: "llmProvider_secret_123", + apiKey: undefined, + opencodeAuth: undefined, + }) +}) + +test("credential flags expose presence only, never credential values", () => { + expect(llmProviderModule.getCredentialFlags({ + credentialKind: "opencode_oauth", + apiKey: "sk-secret", + opencodeAuth: JSON.stringify({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }), + })).toEqual({ hasApiKey: true, hasOpencodeAuth: true, hasCredential: true }) +}) + +test("OpenCode OAuth credential type rejects non-OpenAI providers", () => { + expect(llmProviderModule.isOpencodeOauthProviderAllowed("openai")).toBe(true) + expect(llmProviderModule.isOpencodeOauthProviderAllowed(" OpenAI ")).toBe(true) + expect(llmProviderModule.isOpencodeOauthProviderAllowed("anthropic")).toBe(false) + expect(llmProviderModule.isOpencodeOauthProviderAllowed(undefined)).toBe(false) +}) + +test("OAuth credential and import permission gates require organization admin role", () => { + const owner = { currentMember: { isOwner: true, role: "member" } } + const admin = { currentMember: { isOwner: false, role: "admin" } } + const creatorOnly = { currentMember: { isOwner: false, role: "member" } } + + expect(llmProviderModule.canUseOpenAiOAuthCredentialFlow(owner)).toBe(true) + expect(llmProviderModule.canUseOpenAiOAuthCredentialFlow(admin)).toBe(true) + expect(llmProviderModule.canUseOpenAiOAuthCredentialFlow(creatorOnly)).toBe(false) + expect(llmProviderModule.canImportLlmProviderCredential(owner)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(admin)).toBe(true) + expect(llmProviderModule.canImportLlmProviderCredential(creatorOnly)).toBe(false) +}) + +test("OpenAI OAuth routes require an authenticated caller before returning credential material", async () => { + const app = createRouteApp() + + const startResponse = await app.request("http://den.local/v1/llm-providers/openai-oauth/start", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }) + expect(startResponse.status).toBe(401) + await expect(startResponse.json()).resolves.toEqual({ error: "unauthorized" }) + + const completeResponse = await app.request("http://den.local/v1/llm-providers/openai-oauth/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceAuthId: "dev", userCode: "code" }), + }) + expect(completeResponse.status).toBe(401) + await expect(completeResponse.json()).resolves.toEqual({ error: "unauthorized" }) +}) + +test("purpose-specific import endpoint requires authentication", async () => { + const app = createRouteApp() + const response = await app.request("http://den.local/v1/llm-providers/llmProvider_secret_123/import-credential", { + method: "GET", + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) +}) + +test("OpenAI OAuth completion reports pending authorization without tokens", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => new Response(JSON.stringify({}), { status: 403 })) as typeof fetch + try { + await expect(llmProviderModule.completeOpenAiDeviceAuth({ + deviceAuthId: "device-pending", + userCode: "CODE", + })).rejects.toMatchObject({ error: "openai_oauth_pending", status: 409 }) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("OpenAI OAuth start reports upstream failure without credential material", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => new Response(JSON.stringify({ error: "upstream" }), { status: 500 })) as typeof fetch + try { + await expect(llmProviderModule.startOpenAiDeviceAuth()).rejects.toMatchObject({ + error: "openai_oauth_start_failed", + status: 502, + }) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("OpenAI OAuth completion reports token exchange failure without credential material", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith("/api/accounts/deviceauth/token")) { + return Response.json({ authorization_code: "authorization-code", code_verifier: "verifier" }) + } + if (url.endsWith("/oauth/token")) { + return new Response(JSON.stringify({ error: "exchange_failed" }), { status: 500 }) + } + return new Response("not found", { status: 404 }) + }) as typeof fetch + + try { + await expect(llmProviderModule.completeOpenAiDeviceAuth({ + deviceAuthId: "device-failure", + userCode: "CODE", + })).rejects.toMatchObject({ error: "openai_oauth_complete_failed", status: 502 }) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("OpenAI OAuth completion returns importable OpenCode OAuth auth on success", async () => { + const originalFetch = globalThis.fetch + const calls: string[] = [] + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input) + calls.push(url) + if (url.endsWith("/api/accounts/deviceauth/token")) { + return Response.json({ authorization_code: "authorization-code", code_verifier: "verifier" }) + } + if (url.endsWith("/oauth/token")) { + return Response.json({ + access_token: jwtWithClaims({ chatgpt_account_id: "acct_123" }), + refresh_token: "refresh-token", + expires_in: 60, + }) + } + return new Response("not found", { status: 404 }) + }) as typeof fetch + + try { + const completed = await llmProviderModule.completeOpenAiDeviceAuth({ + deviceAuthId: "device-complete", + userCode: "CODE", + }) + const auth = JSON.parse(completed.opencodeAuth) as Record + + expect(calls).toHaveLength(2) + expect(auth.type).toBe("oauth") + expect(typeof auth.access).toBe("string") + expect(auth.refresh).toBe("refresh-token") + expect(auth.accountId).toBe("acct_123") + expect(completed.accountId).toBe("acct_123") + expect(typeof auth.expires).toBe("number") + } finally { + globalThis.fetch = originalFetch + } +}) + +test("LLM provider migration journal remains valid JSON", async () => { + const journal = await readFile(new URL("../../../packages/den-db/drizzle/meta/_journal.json", import.meta.url), "utf8") + const parsed = JSON.parse(journal) as { entries?: Array<{ tag?: string }> } + + expect(parsed.entries?.some((entry) => entry.tag === "0020_llm_provider_opencode_oauth")).toBe(true) +}) diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx index 751b911462..53c89d7e0e 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-data.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { getErrorMessage, requestJson } from "../../_lib/den-flow"; export type DenLlmProviderSource = "models_dev" | "custom" | "openwork"; +export type DenLlmProviderCredentialKind = "api_key" | "opencode_oauth"; export type DenLlmProviderModel = { id: string; @@ -41,7 +42,10 @@ export type DenLlmProvider = { providerId: string; name: string; providerConfig: Record; + credentialKind: DenLlmProviderCredentialKind; hasApiKey: boolean; + hasOpencodeAuth: boolean; + hasCredential: boolean; createdAt: string | null; updatedAt: string | null; canManage: boolean; @@ -178,6 +182,7 @@ function asLlmProvider(value: unknown): DenLlmProvider | null { value.source === "models_dev" || value.source === "custom" || value.source === "openwork" ? value.source : null; + const credentialKind = value.credentialKind === "opencode_oauth" ? "opencode_oauth" : "api_key"; if (!id || !organizationId || !createdByOrgMembershipId || !providerId || !name || !source) { return null; } @@ -190,7 +195,10 @@ function asLlmProvider(value: unknown): DenLlmProvider | null { providerId, name, providerConfig: asJsonRecord(value.providerConfig), + credentialKind, hasApiKey: value.hasApiKey === true, + hasOpencodeAuth: value.hasOpencodeAuth === true, + hasCredential: value.hasCredential === true || value.hasApiKey === true || value.hasOpencodeAuth === true, createdAt: asIsoString(value.createdAt), updatedAt: asIsoString(value.updatedAt), canManage: value.canManage === true, diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx index c208bfdaa9..4b50aac65c 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-detail-screen.tsx @@ -185,10 +185,10 @@ export function LlmProviderDetailScreen({
- {provider.hasApiKey + {provider.hasCredential ? "Credential saved" : "Credential missing"}
@@ -221,10 +221,10 @@ export function LlmProviderDetailScreen({

- Updated + Credential

- {formatProviderTimestamp(provider.updatedAt)} + {provider.credentialKind === "opencode_oauth" ? "OpenCode OAuth" : "API key"}

diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx index fec4fc356b..69e48aa91b 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/llm-provider-editor-screen.tsx @@ -34,6 +34,7 @@ import { requestLlmProviderCatalogDetail, useOrgLlmProviders, type DenLlmProvider, + type DenLlmProviderCredentialKind, type DenModelsDevProviderDetail, type DenModelsDevProviderSummary, } from "./llm-provider-data"; @@ -88,7 +89,18 @@ export function LlmProviderEditorScreen({ const [customConfigText, setCustomConfigText] = useState( buildCustomProviderTemplate(), ); + const [credentialKind, setCredentialKind] = + useState("api_key"); const [apiKey, setApiKey] = useState(""); + const [opencodeAuth, setOpencodeAuth] = useState(""); + const [openAiOauthBusy, setOpenAiOauthBusy] = useState(false); + const [openAiOauthError, setOpenAiOauthError] = useState(null); + const [openAiOauthSession, setOpenAiOauthSession] = useState<{ + verificationUrl: string; + userCode: string; + deviceAuthId: string; + intervalMs: number; + } | null>(null); const [selectedMemberIds, setSelectedMemberIds] = useState([]); const [selectedTeamIds, setSelectedTeamIds] = useState([]); const [saveBusy, setSaveBusy] = useState(false); @@ -146,7 +158,11 @@ export function LlmProviderEditorScreen({ ? buildEditableCustomProviderText(provider) : buildCustomProviderTemplate(), ); + setCredentialKind(provider.credentialKind); setApiKey(""); + setOpencodeAuth(""); + setOpenAiOauthError(null); + setOpenAiOauthSession(null); return; } @@ -159,9 +175,99 @@ export function LlmProviderEditorScreen({ ); setSelectedTeamIds([]); setCustomConfigText(buildCustomProviderTemplate()); + setCredentialKind("api_key"); setApiKey(""); + setOpencodeAuth(""); + setOpenAiOauthError(null); + setOpenAiOauthSession(null); }, [orgContext?.currentMember.id, provider]); + useEffect(() => { + setOpenAiOauthError(null); + setOpenAiOauthSession(null); + }, [credentialKind, selectedProviderId, source]); + + const canUseOpenCodeOAuth = + source === "models_dev" && selectedProviderId.trim().toLowerCase() === "openai"; + + useEffect(() => { + if (credentialKind === "opencode_oauth" && !canUseOpenCodeOAuth) { + setCredentialKind("api_key"); + } + }, [canUseOpenCodeOAuth, credentialKind]); + + async function startOpenAiOauth() { + setOpenAiOauthBusy(true); + setOpenAiOauthError(null); + try { + const { response, payload } = await requestJson( + "/v1/llm-providers/openai-oauth/start", + { method: "POST", body: JSON.stringify({}) }, + 20000, + ); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to start OpenAI OAuth (${response.status}).`)); + } + if (!payload || typeof payload !== "object") { + throw new Error("OpenAI OAuth response was empty."); + } + const data = payload as Record; + if ( + typeof data.verificationUrl !== "string" || + typeof data.userCode !== "string" || + typeof data.deviceAuthId !== "string" || + typeof data.intervalMs !== "number" + ) { + throw new Error("OpenAI OAuth response was incomplete."); + } + setOpenAiOauthSession({ + verificationUrl: data.verificationUrl, + userCode: data.userCode, + deviceAuthId: data.deviceAuthId, + intervalMs: data.intervalMs, + }); + window.open(data.verificationUrl, "_blank", "noopener,noreferrer"); + } catch (error) { + setOpenAiOauthError(error instanceof Error ? error.message : "Could not start OpenAI OAuth."); + } finally { + setOpenAiOauthBusy(false); + } + } + + async function completeOpenAiOauth() { + if (!openAiOauthSession) { + setOpenAiOauthError("Start OpenAI OAuth first."); + return; + } + setOpenAiOauthBusy(true); + setOpenAiOauthError(null); + try { + const { response, payload } = await requestJson( + "/v1/llm-providers/openai-oauth/complete", + { + method: "POST", + body: JSON.stringify({ + deviceAuthId: openAiOauthSession.deviceAuthId, + userCode: openAiOauthSession.userCode, + }), + }, + 20000, + ); + if (!response.ok) { + throw new Error(getErrorMessage(payload, response.status === 409 ? "OpenAI authorization is not complete yet." : `Failed to complete OpenAI OAuth (${response.status}).`)); + } + if (!payload || typeof payload !== "object" || typeof (payload as Record).opencodeAuth !== "string") { + throw new Error("OpenAI OAuth completion response was incomplete."); + } + setOpencodeAuth((payload as { opencodeAuth: string }).opencodeAuth); + setOpenAiOauthSession(null); + } catch (error) { + setOpenAiOauthError(error instanceof Error ? error.message : "Could not complete OpenAI OAuth."); + } finally { + setOpenAiOauthBusy(false); + } + } + useEffect(() => { if (source !== "models_dev" || !orgId || !selectedProviderId) { setCatalogDetail(null); @@ -286,6 +392,11 @@ export function LlmProviderEditorScreen({ } } + if (credentialKind === "opencode_oauth" && !canUseOpenCodeOAuth) { + setSaveError("OpenCode OAuth credentials are only available for the OpenAI catalog provider."); + return; + } + if (source === "custom" && !customConfigText.trim()) { setSaveError("Paste a custom provider config."); return; @@ -297,6 +408,7 @@ export function LlmProviderEditorScreen({ const body: Record = { name: providerName.trim(), source, + credentialKind, memberIds: [...new Set(selectedMemberIds)], teamIds: [...new Set(selectedTeamIds)], }; @@ -308,10 +420,14 @@ export function LlmProviderEditorScreen({ body.customConfigText = customConfigText; } - if (apiKey.trim() || !provider) { + if (credentialKind === "api_key" && (apiKey.trim() || !provider || provider.credentialKind !== "api_key")) { body.apiKey = apiKey.trim(); } + if (credentialKind === "opencode_oauth" && (opencodeAuth.trim() || !provider || provider.credentialKind !== "opencode_oauth")) { + body.opencodeAuth = opencodeAuth.trim(); + } + const path = provider ? `/v1/llm-providers/${encodeURIComponent(provider.id)}` : `/v1/llm-providers`; @@ -607,28 +723,118 @@ export function LlmProviderEditorScreen({ Credential - {provider?.hasApiKey ? ( + {provider?.hasCredential ? ( Existing credential saved ) : null} -