diff --git a/apps/app/src/app/app.tsx b/apps/app/src/app/app.tsx index c8817a72..f8877b8f 100644 --- a/apps/app/src/app/app.tsx +++ b/apps/app/src/app/app.tsx @@ -900,12 +900,14 @@ export default function App() { providerAuthBusy, providerAuthError, providerAuthMethods, + providerAuthProviders, providerAuthPreferredProviderId, providerAuthWorkerType, startProviderAuth, refreshProviders, completeProviderAuthOAuth, submitProviderApiKey, + connectCloudProvider, disconnectProvider, openProviderAuthModal, closeProviderAuthModal, @@ -2046,6 +2048,7 @@ export default function App() { providerAuthModalOpen: providerAuthModalOpen(), providerAuthError: providerAuthError(), providerAuthMethods: providerAuthMethods(), + providerAuthProviders: providerAuthProviders(), providerAuthPreferredProviderId: providerAuthPreferredProviderId(), providerAuthWorkerType: providerAuthWorkerType(), openProviderAuthModal, @@ -2055,6 +2058,7 @@ export default function App() { completeProviderAuthOAuth, refreshProviders, submitProviderApiKey, + connectCloudProvider, setView, toggleSettings: () => toggleSettingsView("general"), startupPreference: startupPreference(), @@ -2270,12 +2274,14 @@ export default function App() { completeProviderAuthOAuth: completeProviderAuthOAuth, refreshProviders: refreshProviders, submitProviderApiKey: submitProviderApiKey, + connectCloudProvider: connectCloudProvider, openProviderAuthModal: openProviderAuthModal, closeProviderAuthModal: closeProviderAuthModal, providerAuthModalOpen: providerAuthModalOpen(), providerAuthBusy: providerAuthBusy(), providerAuthError: providerAuthError(), providerAuthMethods: providerAuthMethods(), + providerAuthProviders: providerAuthProviders(), providerAuthPreferredProviderId: providerAuthPreferredProviderId(), providers: providers(), providerConnectedIds: providerConnectedIds(), diff --git a/apps/app/src/app/context/providers/index.ts b/apps/app/src/app/context/providers/index.ts index b6dd2c06..c5e52092 100644 --- a/apps/app/src/app/context/providers/index.ts +++ b/apps/app/src/app/context/providers/index.ts @@ -1,3 +1,3 @@ export { createProvidersStore } from "./store"; -export type { ProviderAuthMethod, ProviderOAuthStartResult } from "./store"; +export type { ProviderAuthMethod, ProviderAuthProvider, ProviderOAuthStartResult } from "./store"; export { default as ProviderAuthModal } from "./provider-auth-modal"; diff --git a/apps/app/src/app/context/providers/provider-auth-modal.tsx b/apps/app/src/app/context/providers/provider-auth-modal.tsx index 3b6248cf..61dbc45d 100644 --- a/apps/app/src/app/context/providers/provider-auth-modal.tsx +++ b/apps/app/src/app/context/providers/provider-auth-modal.tsx @@ -1,13 +1,12 @@ import { CheckCircle2, Loader2, X, Search, ChevronRight } from "lucide-solid"; import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"; -import type { ProviderListItem } from "../../types"; import { isTauriRuntime } from "../../utils"; import { compareProviders } from "../../utils/providers"; import Button from "../../components/button"; import ProviderIcon from "../../components/provider-icon"; import TextInput from "../../components/text-input"; -import type { ProviderAuthMethod, ProviderOAuthStartResult } from "./store"; +import type { ProviderAuthMethod, ProviderAuthProvider, ProviderOAuthStartResult } from "./store"; type ProviderAuthEntry = { id: string; @@ -37,11 +36,12 @@ export type ProviderAuthModalProps = { error: string | null; preferredProviderId?: string | null; workerType?: "local" | "remote"; - providers: ProviderListItem[]; + providers: ProviderAuthProvider[]; connectedProviderIds: string[]; authMethods: Record; onSelect: (providerId: string, methodIndex?: number) => Promise; onSubmitApiKey: (providerId: string, apiKey: string) => Promise; + onConnectCloudProvider: (cloudProviderId: string) => Promise; onSubmitOAuth: ( providerId: string, methodIndex: number, @@ -121,8 +121,9 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { const actionDisabled = () => props.loading || props.submitting; - const [view, setView] = createSignal<"list" | "method" | "api" | "oauth-code" | "oauth-auto">("list"); + const [view, setView] = createSignal<"list" | "method" | "api" | "cloud" | "oauth-code" | "oauth-auto">("list"); const [selectedProviderId, setSelectedProviderId] = createSignal(null); + const [selectedCloudMethod, setSelectedCloudMethod] = createSignal(null); const [apiKeyInput, setApiKeyInput] = createSignal(""); const [oauthCodeInput, setOauthCodeInput] = createSignal(""); const [oauthSession, setOauthSession] = createSignal(null); @@ -187,6 +188,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { } setView("list"); setSelectedProviderId(null); + setSelectedCloudMethod(null); setApiKeyInput(""); setOauthCodeInput(""); setOauthSession(null); @@ -462,12 +464,19 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { const entry = selectedEntry(); if (!entry || actionDisabled()) return; setLocalError(null); + setSelectedCloudMethod(null); if (method.type === "oauth") { await startOauth(entry, method.methodIndex); return; } + if (method.type === "cloud") { + setSelectedCloudMethod(method); + setView("cloud"); + return; + } + setView("api"); }; @@ -490,6 +499,19 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { } }; + const handleCloudSubmit = async () => { + const method = selectedCloudMethod(); + if (!method?.cloudProviderId || actionDisabled()) return; + + setLocalError(null); + try { + await props.onConnectCloudProvider(method.cloudProviderId); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to connect organization provider"; + setLocalError(message); + } + }; + const handleOauthCodeSubmit = async () => { const entry = selectedEntry(); const session = oauthSession(); @@ -521,16 +543,24 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { if (resolvedView() === "api" && (selectedEntry()?.methods.length ?? 0) > 1) { setView("method"); + setSelectedCloudMethod(null); setApiKeyInput(""); setLocalError(null); return; } + if (resolvedView() === "cloud" && (selectedEntry()?.methods.length ?? 0) > 1) { + setView("method"); + setSelectedCloudMethod(null); + setLocalError(null); + return; + } resetState(); }; const submittingLabel = () => { if (!props.submitting) return null; if (resolvedView() === "api") return "Saving API key..."; + if (resolvedView() === "cloud") return "Connecting organization provider..."; if (resolvedView() === "oauth-code") return "Verifying authorization code..."; if (resolvedView() === "oauth-auto") return "Waiting for OAuth confirmation..."; return "Opening authentication..."; @@ -584,6 +614,9 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) { if (method.type === "oauth") { return "Continue in the browser and let OpenWork finish the connection automatically."; } + if (method.type === "cloud") { + return method.description ?? "Use the provider and credential managed by your organization."; + } return "Paste a secret key that OpenWork stores locally on this device."; }; @@ -594,7 +627,7 @@ export default function ProviderAuthModal(props: ProviderAuthModalProps) {

Connect providers

-

Sign in to services you want OpenWork to use.

+

Sign in to services or use providers managed by your organization.

+ +
+
+
+
{selectedEntry()!.name}
+
Connect with the provider managed by your organization.
+
+ +
+
+ {selectedCloudMethod()!.description ?? "Use the provider and credential managed by your organization."} +
+ 0}> +
+ {(selectedCloudMethod()!.modelCount ?? 0)} curated model{(selectedCloudMethod()!.modelCount ?? 0) === 1 ? "" : "s"} will be added to this workspace. +
+
+ 0}> +
+ Env vars: {selectedCloudMethod()!.env!.join(", ")} +
+
+
+
+ OpenWork will install the provider config and use the credential stored for your org. +
+ +
+
+
+
diff --git a/apps/app/src/app/context/providers/store.ts b/apps/app/src/app/context/providers/store.ts index 4396c211..7a9f61e8 100644 --- a/apps/app/src/app/context/providers/store.ts +++ b/apps/app/src/app/context/providers/store.ts @@ -1,19 +1,30 @@ -import { createMemo, createSignal, type Accessor } from "solid-js"; +import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"; -import type { ProviderAuthAuthorization, ProviderListResponse } from "@opencode-ai/sdk/v2/client"; +import type { ProviderAuthAuthorization, ProviderConfig, ProviderListResponse } from "@opencode-ai/sdk/v2/client"; import { t } from "../../../i18n"; +import { createDenClient, readDenSettings, type DenOrgLlmProvider, type DenOrgLlmProviderConnection } from "../../lib/den"; import { unwrap, waitForHealthy } from "../../lib/opencode"; import type { Client, ProviderListItem, WorkspaceDisplay } from "../../types"; import { safeStringify } from "../../utils"; -import { filterProviderList, mapConfigProvidersToList } from "../../utils/providers"; +import { compareProviders, filterProviderList, mapConfigProvidersToList } from "../../utils/providers"; type ProviderReturnFocusTarget = "none" | "composer"; export type ProviderAuthMethod = { - type: "oauth" | "api"; + type: "oauth" | "api" | "cloud"; label: string; methodIndex?: number; + cloudProviderId?: string; + description?: string; + env?: string[]; + modelCount?: number; +}; + +export type ProviderAuthProvider = { + id: string; + name: string; + env: string[]; }; export type ProviderOAuthStartResult = { @@ -44,11 +55,183 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { const [providerAuthPreferredProviderId, setProviderAuthPreferredProviderId] = createSignal(null); const [providerAuthReturnFocusTarget, setProviderAuthReturnFocusTarget] = createSignal("none"); + const [cloudOrgProviders, setCloudOrgProviders] = createSignal([]); + + let cloudOrgProvidersLoadKey = ""; + + const getStringList = (value: unknown) => + Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : []; + + const getCloudProviderEnv = (config: Record) => getStringList(config.env); + + const buildCloudProviderMethod = (provider: DenOrgLlmProvider): ProviderAuthMethod => ({ + type: "cloud", + label: + provider.name.trim().toLowerCase() === provider.providerId.trim().toLowerCase() + ? "Use organization provider" + : `Use ${provider.name}`, + cloudProviderId: provider.id, + description: + provider.models.length > 0 + ? `${provider.models.length} curated model${provider.models.length === 1 ? "" : "s"} managed by your organization.` + : "Use the provider and credential managed by your organization.", + env: getCloudProviderEnv(provider.providerConfig), + modelCount: provider.models.length, + }); + + const buildCloudProviderConfig = ( + provider: DenOrgLlmProviderConnection, + ): ProviderConfig => { + const models = Object.fromEntries( + provider.models.map((model) => { + const next: NonNullable[string] = { + id: model.id, + name: model.name, + }; + const raw = model.config; + for (const key of [ + "family", + "release_date", + "attachment", + "reasoning", + "temperature", + "tool_call", + "interleaved", + "cost", + "limit", + "modalities", + "status", + "options", + "headers", + "provider", + "variants", + ] as const) { + const value = raw[key]; + if (value !== undefined) { + (next as Record)[key] = value; + } + } + return [model.id, next]; + }), + ); + + const next: ProviderConfig = { + id: provider.providerId, + name: provider.name, + env: getCloudProviderEnv(provider.providerConfig), + models, + }; + + if (typeof provider.providerConfig.npm === "string" && provider.providerConfig.npm.trim()) { + next.npm = provider.providerConfig.npm; + } + if (typeof provider.providerConfig.api === "string" && provider.providerConfig.api.trim()) { + next.api = provider.providerConfig.api; + } + if (provider.providerConfig.options && typeof provider.providerConfig.options === "object") { + next.options = provider.providerConfig.options as Record; + } + if (Array.isArray(provider.providerConfig.whitelist)) { + next.whitelist = getStringList(provider.providerConfig.whitelist); + } + if (Array.isArray(provider.providerConfig.blacklist)) { + next.blacklist = getStringList(provider.providerConfig.blacklist); + } + + return next; + }; const providerAuthWorkerType = createMemo<"local" | "remote">(() => options.selectedWorkspaceDisplay().workspaceType === "remote" ? "remote" : "local", ); + const providerAuthProviders = createMemo(() => { + const merged = new Map(); + + for (const provider of options.providers()) { + const id = provider.id?.trim(); + if (!id) continue; + merged.set(id, { + id, + name: provider.name?.trim() || id, + env: Array.isArray(provider.env) ? provider.env : [], + }); + } + + for (const provider of cloudOrgProviders()) { + const id = provider.providerId.trim(); + if (!id || merged.has(id)) continue; + merged.set(id, { + id, + name: provider.name.trim() || id, + env: getCloudProviderEnv(provider.providerConfig), + }); + } + + return [...merged.values()].sort(compareProviders); + }); + + const getCloudOrgProvidersKey = () => { + const settings = readDenSettings(); + return [ + settings.baseUrl, + settings.apiBaseUrl ?? "", + settings.activeOrgId?.trim() ?? "", + settings.authToken?.trim() ?? "", + ].join("::"); + }; + + const refreshCloudOrgProviders = async (optionsArg?: { force?: boolean }) => { + const settings = readDenSettings(); + const loadKey = getCloudOrgProvidersKey(); + const token = settings.authToken?.trim() ?? ""; + const orgId = settings.activeOrgId?.trim() ?? ""; + + if (!optionsArg?.force && cloudOrgProvidersLoadKey === loadKey) { + return cloudOrgProviders(); + } + + if (!token || !orgId) { + setCloudOrgProviders([]); + cloudOrgProvidersLoadKey = loadKey; + return []; + } + + const client = createDenClient({ + baseUrl: settings.baseUrl, + token, + }); + try { + const providers = await client.listOrgLlmProviders(orgId); + setCloudOrgProviders(providers); + cloudOrgProvidersLoadKey = loadKey; + return providers; + } catch (error) { + setCloudOrgProviders([]); + cloudOrgProvidersLoadKey = ""; + throw error; + } + }; + + createEffect(() => { + if (typeof window === "undefined") { + return; + } + + const handleDenSessionUpdate = () => { + cloudOrgProvidersLoadKey = ""; + setCloudOrgProviders([]); + setProviderAuthMethods({}); + }; + + window.addEventListener("openwork-den-session-updated", handleDenSessionUpdate as EventListener); + onCleanup(() => { + window.removeEventListener("openwork-den-session-updated", handleDenSessionUpdate as EventListener); + }); + }); + const applyProviderListState = (value: ProviderListResponse) => { options.setProviders(value.all ?? []); options.setProviderDefaults(value.default ?? {}); @@ -149,8 +332,9 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { const buildProviderAuthMethods = ( methods: Record, - availableProviders: ProviderListItem[], + availableProviders: ProviderAuthProvider[], workerType: "local" | "remote", + cloudProviders: DenOrgLlmProvider[], ) => { const merged = Object.fromEntries( Object.entries(methods ?? {}).map(([id, providerMethods]) => [ @@ -182,6 +366,17 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { return workerType === "remote" ? isHeadless : !isHeadless; }); } + + for (const provider of cloudProviders) { + const id = provider.providerId.trim(); + if (!id) continue; + const existing = merged[id] ?? []; + if (existing.some((method) => method.type === "cloud" && method.cloudProviderId === provider.id)) { + continue; + } + merged[id] = [...existing, buildCloudProviderMethod(provider)]; + } + return merged; }; @@ -191,10 +386,14 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { throw new Error(t("providers.not_connected")); } const methods = unwrap(await c.provider.auth()); + const cloudProviders = await refreshCloudOrgProviders().catch( + () => [] as DenOrgLlmProvider[], + ); return buildProviderAuthMethods( methods as Record, - options.providers(), + providerAuthProviders(), workerType, + cloudProviders, ); }; @@ -405,6 +604,69 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { } } + async function connectCloudProvider(cloudProviderId: string) { + setProviderAuthError(null); + const c = options.client(); + if (!c) { + throw new Error(t("providers.not_connected")); + } + + const settings = readDenSettings(); + const token = settings.authToken?.trim() ?? ""; + const orgId = settings.activeOrgId?.trim() ?? ""; + if (!token || !orgId) { + throw new Error("Sign in to OpenWork Cloud and choose an organization first."); + } + + try { + const den = createDenClient({ + baseUrl: settings.baseUrl, + token, + }); + const provider = await den.getOrgLlmProviderConnection(orgId, cloudProviderId); + const apiKey = provider.apiKey?.trim() ?? ""; + const env = getCloudProviderEnv(provider.providerConfig); + if (!apiKey && env.length > 0) { + throw new Error(`${provider.name} does not have a stored organization credential yet.`); + } + + if (apiKey) { + await c.auth.set({ + providerID: provider.providerId, + auth: { + type: "api", + key: apiKey, + }, + }); + } + + const config = unwrap(await c.config.get()); + const disabledProviders = Array.isArray(config.disabled_providers) + ? config.disabled_providers + : []; + const nextDisabledProviders = disabledProviders.filter((id) => id !== provider.providerId); + + await c.config.update({ + config: { + ...config, + disabled_providers: nextDisabledProviders, + provider: { + ...(config.provider ?? {}), + [provider.providerId]: buildCloudProviderConfig(provider), + }, + }, + }); + + options.setDisabledProviders(nextDisabledProviders); + await refreshProviders({ dispose: true }); + return `${t("status.connected")} ${provider.name}`; + } catch (error) { + const message = describeProviderError(error, "Failed to connect organization provider."); + setProviderAuthError(message); + throw error instanceof Error ? error : new Error(message); + } + } + async function disconnectProvider(providerId: string) { setProviderAuthError(null); const c = options.client(); @@ -552,10 +814,12 @@ export function createProvidersStore(options: CreateProvidersStoreOptions) { providerAuthMethods, providerAuthPreferredProviderId, providerAuthWorkerType, + providerAuthProviders, startProviderAuth, refreshProviders, completeProviderAuthOAuth, submitProviderApiKey, + connectCloudProvider, disconnectProvider, openProviderAuthModal, closeProviderAuthModal, diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index ecc2f078..35bc6812 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -81,6 +81,29 @@ export type DenTemplate = { creator: DenTemplateCreator | null; }; +export type DenOrgLlmProviderModel = { + id: string; + name: string; + config: Record; + createdAt: string | null; +}; + +export type DenOrgLlmProvider = { + id: string; + source: "models_dev" | "custom"; + providerId: string; + name: string; + providerConfig: Record; + hasApiKey: boolean; + models: DenOrgLlmProviderModel[]; + createdAt: string | null; + updatedAt: string | null; +}; + +export type DenOrgLlmProviderConnection = DenOrgLlmProvider & { + apiKey: string | null; +}; + export type DenBillingPrice = { amount: number | null; currency: string | null; @@ -554,6 +577,71 @@ function getDenOrgSkillHubsFromPayload(payload: unknown): DenOrgSkillHubParsed[] .filter((e): e is DenOrgSkillHubParsed => e !== null); } +function parseDenOrgLlmProviderModel(value: unknown): DenOrgLlmProviderModel | null { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string") { + return null; + } + + return { + id: value.id, + name: value.name, + config: isRecord(value.config) ? value.config : {}, + createdAt: typeof value.createdAt === "string" ? value.createdAt : null, + }; +} + +function parseDenOrgLlmProvider(value: unknown): DenOrgLlmProvider | null { + if ( + !isRecord(value) || + typeof value.id !== "string" || + typeof value.providerId !== "string" || + typeof value.name !== "string" || + (value.source !== "models_dev" && value.source !== "custom") + ) { + return null; + } + + return { + id: value.id, + source: value.source, + providerId: value.providerId, + name: value.name, + providerConfig: isRecord(value.providerConfig) ? value.providerConfig : {}, + hasApiKey: value.hasApiKey === true, + models: Array.isArray(value.models) + ? value.models.map(parseDenOrgLlmProviderModel).filter((entry): entry is DenOrgLlmProviderModel => entry !== null) + : [], + createdAt: typeof value.createdAt === "string" ? value.createdAt : null, + updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : null, + }; +} + +function getDenOrgLlmProviders(payload: unknown): DenOrgLlmProvider[] { + if (!isRecord(payload) || !Array.isArray(payload.llmProviders)) { + return []; + } + + return payload.llmProviders + .map(parseDenOrgLlmProvider) + .filter((entry): entry is DenOrgLlmProvider => entry !== null); +} + +function getDenOrgLlmProviderConnection(payload: unknown): DenOrgLlmProviderConnection | null { + if (!isRecord(payload) || !payload.llmProvider) { + return null; + } + + const provider = parseDenOrgLlmProvider(payload.llmProvider); + if (!provider || !isRecord(payload.llmProvider)) { + return null; + } + + return { + ...provider, + apiKey: typeof payload.llmProvider.apiKey === "string" ? payload.llmProvider.apiKey : null, + }; +} + function getBillingPrice(value: unknown): DenBillingPrice | null { if (!isRecord(value)) { return null; @@ -946,6 +1034,30 @@ export function createDenClient(options: { baseUrl: string; token?: string | nul ); }, + async listOrgLlmProviders(orgId: string): Promise { + const payload = await requestJson(baseUrls, `/v1/orgs/${encodeURIComponent(orgId)}/llm-providers`, { + method: "GET", + token, + }); + return getDenOrgLlmProviders(payload); + }, + + async getOrgLlmProviderConnection(orgId: string, llmProviderId: string): Promise { + const payload = await requestJson( + baseUrls, + `/v1/orgs/${encodeURIComponent(orgId)}/llm-providers/${encodeURIComponent(llmProviderId)}/connect`, + { + method: "GET", + token, + }, + ); + const provider = getDenOrgLlmProviderConnection(payload); + if (!provider) { + throw new DenApiError(500, "invalid_llm_provider_payload", "LLM provider response was missing connection details."); + } + return provider; + }, + async getBillingStatus(options: { includeCheckout?: boolean; includePortal?: boolean; includeInvoices?: boolean } = {}): Promise { const params = new URLSearchParams(); if (options.includeCheckout) { diff --git a/apps/app/src/app/pages/session.tsx b/apps/app/src/app/pages/session.tsx index b374a823..ae4a6da4 100644 --- a/apps/app/src/app/pages/session.tsx +++ b/apps/app/src/app/pages/session.tsx @@ -66,6 +66,7 @@ import Button from "../components/button"; import ConfirmModal from "../components/confirm-modal"; import RenameSessionModal from "../components/rename-session-modal"; import { ProviderAuthModal, + type ProviderAuthProvider, type ProviderAuthMethod, type ProviderOAuthStartResult, } from "../context/providers"; @@ -219,6 +220,7 @@ export type SessionViewProps = { providerId: string, apiKey: string, ) => Promise; + connectCloudProvider: (cloudProviderId: string) => Promise; refreshProviders: () => Promise; openProviderAuthModal: (options?: { returnFocusTarget?: "none" | "composer"; @@ -229,6 +231,7 @@ export type SessionViewProps = { providerAuthBusy: boolean; providerAuthError: string | null; providerAuthMethods: Record; + providerAuthProviders: ProviderAuthProvider[]; providerAuthPreferredProviderId: string | null; providerAuthWorkerType: "local" | "remote"; providers: ProviderListItem[]; @@ -2456,6 +2459,21 @@ export default function SessionView(props: SessionViewProps) { } }; + const handleCloudProviderConnect = async (cloudProviderId: string) => { + if (providerAuthActionBusy()) return; + setProviderAuthActionBusy(true); + try { + const message = await props.connectCloudProvider(cloudProviderId); + showStatusToast(message || t("session.provider_connected"), "success"); + props.closeProviderAuthModal(); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to connect organization provider"; + showStatusToast(message, "error"); + } finally { + setProviderAuthActionBusy(false); + } + }; + const handleSendPrompt = (draft: ComposerDraft) => { suppressJumpControlsTemporarily(); sessionScroll.scrollToBottom(); @@ -3726,11 +3744,12 @@ export default function SessionView(props: SessionViewProps) { error={props.providerAuthError} preferredProviderId={props.providerAuthPreferredProviderId} workerType={props.providerAuthWorkerType} - providers={props.providers} + providers={props.providerAuthProviders} connectedProviderIds={props.providerConnectedIds} authMethods={props.providerAuthMethods} onSelect={handleProviderAuthSelect} onSubmitApiKey={handleProviderAuthApiKey} + onConnectCloudProvider={handleCloudProviderConnect} onSubmitOAuth={handleProviderAuthOAuth} onRefreshProviders={props.refreshProviders} onClose={() => props.closeProviderAuthModal()} diff --git a/apps/app/src/app/shell/settings-shell.tsx b/apps/app/src/app/shell/settings-shell.tsx index 3a24667d..ce971fdc 100644 --- a/apps/app/src/app/shell/settings-shell.tsx +++ b/apps/app/src/app/shell/settings-shell.tsx @@ -49,6 +49,7 @@ import Button from "../components/button"; import SettingsView from "../pages/settings"; import StatusBar from "../components/status-bar"; import { ProviderAuthModal, + type ProviderAuthProvider, type ProviderAuthMethod, type ProviderOAuthStartResult, } from "../context/providers"; @@ -77,6 +78,7 @@ export type SettingsShellProps = { providerAuthModalOpen: boolean; providerAuthError: string | null; providerAuthMethods: Record; + providerAuthProviders: ProviderAuthProvider[]; providerAuthPreferredProviderId: string | null; providerAuthWorkerType: "local" | "remote"; openProviderAuthModal: (options?: { @@ -92,6 +94,7 @@ export type SettingsShellProps = { code?: string ) => Promise<{ connected: boolean; pending?: boolean; message?: string }>; submitProviderApiKey: (providerId: string, apiKey: string) => Promise; + connectCloudProvider: (cloudProviderId: string) => Promise; refreshProviders: () => Promise; setView: (view: View, sessionId?: string) => void; toggleSettings: () => void; @@ -383,6 +386,19 @@ export default function SettingsShell(props: SettingsShellProps) { } }; + const handleCloudProviderConnect = async (cloudProviderId: string) => { + if (providerAuthActionBusy()) return; + setProviderAuthActionBusy(true); + try { + await props.connectCloudProvider(cloudProviderId); + props.closeProviderAuthModal(); + } catch { + // Errors are surfaced in the modal. + } finally { + setProviderAuthActionBusy(false); + } + }; + onCleanup(() => { // no-op }); @@ -1267,11 +1283,12 @@ export default function SettingsShell(props: SettingsShellProps) { error={props.providerAuthError} preferredProviderId={props.providerAuthPreferredProviderId} workerType={props.providerAuthWorkerType} - providers={props.providers} + providers={props.providerAuthProviders} connectedProviderIds={props.providerConnectedIds} authMethods={props.providerAuthMethods} onSelect={handleProviderAuthSelect} onSubmitApiKey={handleProviderAuthApiKey} + onConnectCloudProvider={handleCloudProviderConnect} onSubmitOAuth={handleProviderAuthOAuth} onRefreshProviders={props.refreshProviders} onClose={() => props.closeProviderAuthModal()} diff --git a/ee/apps/den-api/.env.example b/ee/apps/den-api/.env.example index d9ccd367..16a15d4e 100644 --- a/ee/apps/den-api/.env.example +++ b/ee/apps/den-api/.env.example @@ -2,6 +2,9 @@ PORT=8790 CORS_ORIGINS=http://localhost:3000,http://localhost:3001 DATABASE_URL=mysql://root:password@127.0.0.1:3306/den BETTER_AUTH_SECRET=replace-with-32-plus-character-secret +# Required dedicated DB encryption key for encrypted columns. Minimum 32 chars. +# Generate one with: openssl rand -base64 128 +DEN_DB_ENCRYPTION_KEY= BETTER_AUTH_URL=http://localhost:8790 DEN_BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:3001 LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL=replace-with-loops-template-id diff --git a/ee/apps/den-api/src/env.ts b/ee/apps/den-api/src/env.ts index ce84d9b8..b9b1362d 100644 --- a/ee/apps/den-api/src/env.ts +++ b/ee/apps/den-api/src/env.ts @@ -6,6 +6,7 @@ const EnvSchema = z.object({ DATABASE_HOST: z.string().min(1).optional(), DATABASE_USERNAME: z.string().min(1).optional(), DATABASE_PASSWORD: z.string().optional(), + DEN_DB_ENCRYPTION_KEY: z.string().trim().min(32), DB_MODE: z.enum(["mysql", "planetscale"]).optional(), BETTER_AUTH_SECRET: z.string().min(32), BETTER_AUTH_URL: z.string().min(1), @@ -145,6 +146,7 @@ const planetscaleCredentials = export const env = { databaseUrl: parsed.DATABASE_URL, + dbEncryptionKey: optionalString(parsed.DEN_DB_ENCRYPTION_KEY), dbMode: parsed.DB_MODE ?? (parsed.DATABASE_URL ? "mysql" : "planetscale"), planetscale: planetscaleCredentials, betterAuthSecret: parsed.BETTER_AUTH_SECRET, diff --git a/ee/apps/den-api/src/llm/models-dev.ts b/ee/apps/den-api/src/llm/models-dev.ts new file mode 100644 index 00000000..f2eb93d8 --- /dev/null +++ b/ee/apps/den-api/src/llm/models-dev.ts @@ -0,0 +1,143 @@ +const MODELS_DEV_API_URL = "https://models.dev/api.json" +const MODELS_DEV_CACHE_TTL_MS = 1000 * 60 * 10 + +type JsonRecord = Record + +export type ModelsDevProviderSummary = { + id: string + name: string + npm: string | null + env: string[] + doc: string | null + api: string | null + modelCount: number +} + +export type ModelsDevModel = { + id: string + name: string + config: JsonRecord +} + +export type ModelsDevProvider = { + id: string + name: string + npm: string | null + env: string[] + doc: string | null + api: string | null + config: JsonRecord + models: ModelsDevModel[] +} + +let modelsDevCache: + | { + expiresAt: number + providers: ModelsDevProvider[] + providersById: Map + } + | null = null + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null +} + +function asStringList(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + : [] +} + +async function loadModelsDevCatalog() { + if (modelsDevCache && modelsDevCache.expiresAt > Date.now()) { + return modelsDevCache + } + + const response = await fetch(MODELS_DEV_API_URL, { + headers: { + Accept: "application/json", + "User-Agent": "OpenWork Den API", + }, + }) + + if (!response.ok) { + throw new Error(`models.dev returned ${response.status}`) + } + + const payload = await response.json() + if (!isRecord(payload)) { + throw new Error("models.dev returned an invalid payload") + } + + const providers = Object.entries(payload) + .map(([providerKey, rawProvider]) => { + if (!isRecord(rawProvider)) { + return null + } + + const providerId = asString(rawProvider.id) ?? providerKey + const name = asString(rawProvider.name) ?? providerId + const modelsRecord = isRecord(rawProvider.models) ? rawProvider.models : {} + const { models: _models, ...providerConfig } = rawProvider + const models = Object.entries(modelsRecord) + .map(([modelKey, rawModel]) => { + if (!isRecord(rawModel)) { + return null + } + + const modelId = asString(rawModel.id) ?? modelKey + const modelName = asString(rawModel.name) ?? modelId + return { + id: modelId, + name: modelName, + config: rawModel, + } satisfies ModelsDevModel + }) + .filter((entry): entry is ModelsDevModel => entry !== null) + .sort((left, right) => left.name.localeCompare(right.name)) + + return { + id: providerId, + name, + npm: asString(rawProvider.npm), + env: asStringList(rawProvider.env), + doc: asString(rawProvider.doc), + api: asString(rawProvider.api), + config: providerConfig, + models, + } satisfies ModelsDevProvider + }) + .filter((entry): entry is ModelsDevProvider => entry !== null) + .sort((left, right) => left.name.localeCompare(right.name)) + + const nextCache = { + expiresAt: Date.now() + MODELS_DEV_CACHE_TTL_MS, + providers, + providersById: new Map(providers.map((provider) => [provider.id, provider])), + } + + modelsDevCache = nextCache + return nextCache +} + +export async function listModelsDevProviders(): Promise { + const catalog = await loadModelsDevCatalog() + return catalog.providers.map((provider) => ({ + id: provider.id, + name: provider.name, + npm: provider.npm, + env: provider.env, + doc: provider.doc, + api: provider.api, + modelCount: provider.models.length, + })) +} + +export async function getModelsDevProvider(providerId: string): Promise { + const catalog = await loadModelsDevCatalog() + return catalog.providersById.get(providerId) ?? null +} diff --git a/ee/apps/den-api/src/routes/org/index.ts b/ee/apps/den-api/src/routes/org/index.ts index 71cb000f..da7b4681 100644 --- a/ee/apps/den-api/src/routes/org/index.ts +++ b/ee/apps/den-api/src/routes/org/index.ts @@ -2,6 +2,7 @@ import type { Hono } from "hono" import type { OrgRouteVariables } from "./shared.js" import { registerOrgCoreRoutes } from "./core.js" import { registerOrgInvitationRoutes } from "./invitations.js" +import { registerOrgLlmProviderRoutes } from "./llm-providers.js" import { registerOrgMemberRoutes } from "./members.js" import { registerOrgRoleRoutes } from "./roles.js" import { registerOrgSkillRoutes } from "./skills.js" @@ -11,6 +12,7 @@ import { registerOrgTemplateRoutes } from "./templates.js" export function registerOrgRoutes(app: Hono) { registerOrgCoreRoutes(app) registerOrgInvitationRoutes(app) + registerOrgLlmProviderRoutes(app) registerOrgMemberRoutes(app) registerOrgRoleRoutes(app) registerOrgSkillRoutes(app) diff --git a/ee/apps/den-api/src/routes/org/llm-providers.ts b/ee/apps/den-api/src/routes/org/llm-providers.ts new file mode 100644 index 00000000..1a75b73a --- /dev/null +++ b/ee/apps/den-api/src/routes/org/llm-providers.ts @@ -0,0 +1,922 @@ +import { and, desc, eq, inArray, isNotNull, or } from "@openwork-ee/den-db/drizzle" +import { + AuthUserTable, + LlmProviderAccessTable, + LlmProviderModelTable, + LlmProviderTable, + MemberTable, + TeamTable, +} from "@openwork-ee/den-db/schema" +import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" +import type { Hono } from "hono" +import { z } from "zod" +import { db } from "../../db.js" +import { + jsonValidator, + paramValidator, + requireUserMiddleware, + resolveMemberTeamsMiddleware, + resolveOrganizationContextMiddleware, +} from "../../middleware/index.js" +import { getModelsDevProvider, listModelsDevProviders } from "../../llm/models-dev.js" +import type { MemberTeamsContext } from "../../middleware/member-teams.js" +import type { OrgRouteVariables } from "./shared.js" +import { idParamSchema, memberHasRole, orgIdParamSchema } from "./shared.js" + +type JsonRecord = Record +type LlmProviderId = typeof LlmProviderTable.$inferSelect.id +type LlmProviderAccessId = typeof LlmProviderAccessTable.$inferSelect.id +type MemberId = typeof MemberTable.$inferSelect.id +type TeamId = typeof TeamTable.$inferSelect.id +type LlmProviderRow = typeof LlmProviderTable.$inferSelect + +type RouteFailure = { + status: number + error: string + message?: string +} + +const providerCatalogParamsSchema = orgIdParamSchema.extend({ + providerId: z.string().trim().min(1).max(255), +}) + +const orgLlmProviderParamsSchema = orgIdParamSchema.extend(idParamSchema("llmProviderId").shape) + +const customModelSchema = z.object({ + id: z.string().trim().min(1).max(255), + name: z.string().trim().min(1).max(255), +}).passthrough() + +const customProviderSchema = z.object({ + id: z.string().trim().min(1).max(255), + name: z.string().trim().min(1).max(255), + npm: z.string().trim().min(1).max(255), + env: z.array(z.string().trim().min(1).max(255)).min(1), + doc: z.string().trim().min(1).max(2048), + api: z.string().trim().min(1).max(2048).optional(), + models: z.array(customModelSchema).min(1), +}).passthrough() + +const llmProviderWriteSchema = z.object({ + source: z.enum(["models_dev", "custom"]), + 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(), + memberIds: z.array(z.string().trim().min(1).max(255)).max(500).optional().default([]), + teamIds: z.array(z.string().trim().min(1).max(255)).max(500).optional().default([]), +}).superRefine((value, ctx) => { + if (value.source === "models_dev") { + if (!value.providerId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["providerId"], + message: "Select a provider.", + }) + } + + if (!value.modelIds?.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["modelIds"], + message: "Select at least one model.", + }) + } + } + + if (value.source === "custom" && !value.customConfigText) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["customConfigText"], + message: "Paste a custom provider config.", + }) + } +}) + +function createFailure(status: number, error: string, message?: string): RouteFailure { + return { status, error, message } +} + +function isRouteFailure(value: unknown): value is RouteFailure { + return typeof value === "object" && value !== null && "status" in value && "error" in value +} + +function isOrganizationAdmin(payload: { currentMember: { isOwner: boolean; role: string } }) { + return payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin") +} + +function canManageLlmProvider( + payload: { currentMember: { id: MemberId; isOwner: boolean; role: string } }, + provider: LlmProviderRow, +) { + return isOrganizationAdmin(payload) || provider.createdByOrgMembershipId === payload.currentMember.id +} + +async function canAccessLlmProvider(input: { + organizationId: typeof LlmProviderTable.$inferSelect.organizationId + llmProviderId: LlmProviderId + currentMemberId: MemberId + memberTeams: Array<{ id: TeamId }> + isAdmin: boolean +}) { + if (input.isAdmin) { + return true + } + + const access = await listAccessibleProviderAccess({ + organizationId: input.organizationId, + currentMemberId: input.currentMemberId, + memberTeams: input.memberTeams, + }) + + return access.some((entry) => entry.llmProviderId === input.llmProviderId) +} + +function parseLlmProviderId(value: string) { + return normalizeDenTypeId("llmProvider", value) +} + +function parseLlmProviderAccessId(value: string) { + return normalizeDenTypeId("llmProviderAccess", value) +} + +function parseMemberId(value: string) { + return normalizeDenTypeId("member", value) +} + +function parseTeamId(value: string) { + return normalizeDenTypeId("team", value) +} + +async function listAccessibleProviderAccess(input: { + organizationId: typeof LlmProviderTable.$inferSelect.organizationId + currentMemberId: MemberId + memberTeams: Array<{ id: TeamId }> +}) { + const teamIds = input.memberTeams.map((team) => team.id) + const accessWhere = teamIds.length > 0 + ? and( + eq(LlmProviderTable.organizationId, input.organizationId), + or( + eq(LlmProviderAccessTable.orgMembershipId, input.currentMemberId), + inArray(LlmProviderAccessTable.teamId, teamIds), + ), + ) + : and( + eq(LlmProviderTable.organizationId, input.organizationId), + eq(LlmProviderAccessTable.orgMembershipId, input.currentMemberId), + ) + + return db + .select({ + id: LlmProviderAccessTable.id, + llmProviderId: LlmProviderAccessTable.llmProviderId, + orgMembershipId: LlmProviderAccessTable.orgMembershipId, + teamId: LlmProviderAccessTable.teamId, + createdAt: LlmProviderAccessTable.createdAt, + }) + .from(LlmProviderAccessTable) + .innerJoin(LlmProviderTable, eq(LlmProviderAccessTable.llmProviderId, LlmProviderTable.id)) + .where(accessWhere) +} + +async function resolveMemberIds(input: { + organizationId: typeof LlmProviderTable.$inferSelect.organizationId + values: string[] +}) { + const uniqueValues = [...new Set(input.values)] + if (uniqueValues.length === 0) { + return [] as MemberId[] + } + + const memberIds = uniqueValues.map((value) => { + try { + return parseMemberId(value) + } catch { + throw createFailure(404, "member_not_found") + } + }) + + const rows = await db + .select({ id: MemberTable.id }) + .from(MemberTable) + .where(and(eq(MemberTable.organizationId, input.organizationId), inArray(MemberTable.id, memberIds))) + + if (rows.length !== memberIds.length) { + throw createFailure(404, "member_not_found") + } + + return memberIds +} + +async function resolveTeamIds(input: { + organizationId: typeof LlmProviderTable.$inferSelect.organizationId + values: string[] +}) { + const uniqueValues = [...new Set(input.values)] + if (uniqueValues.length === 0) { + return [] as TeamId[] + } + + const teamIds = uniqueValues.map((value) => { + try { + return parseTeamId(value) + } catch { + throw createFailure(404, "team_not_found") + } + }) + + const rows = await db + .select({ id: TeamTable.id }) + .from(TeamTable) + .where(and(eq(TeamTable.organizationId, input.organizationId), inArray(TeamTable.id, teamIds))) + + if (rows.length !== teamIds.length) { + throw createFailure(404, "team_not_found") + } + + return teamIds +} + +async function normalizeLlmProviderInput(input: z.infer) { + if (input.source === "models_dev") { + const provider = await getModelsDevProvider(input.providerId ?? "") + if (!provider) { + throw createFailure(404, "provider_not_found", "The selected provider was not found in models.dev.") + } + + const requestedModelIds = [...new Set(input.modelIds ?? [])] + const modelsById = new Map(provider.models.map((model) => [model.id, model])) + const models = requestedModelIds.map((modelId) => { + const model = modelsById.get(modelId) + if (!model) { + throw createFailure(404, "model_not_found", `Model ${modelId} is not available for ${provider.name}.`) + } + return model + }) + + const apiKey = input.apiKey?.trim() || null + + return { + source: input.source, + providerId: provider.id, + name: provider.name, + providerConfig: provider.config, + models: models.map((model) => ({ + id: model.id, + name: model.name, + config: model.config, + })), + apiKey, + } + } + + let parsed: unknown + try { + parsed = JSON.parse(input.customConfigText ?? "") + } catch { + throw createFailure(400, "invalid_custom_provider_config", "Custom provider config must be valid JSON.") + } + + const customProvider = customProviderSchema.safeParse(parsed) + if (!customProvider.success) { + throw createFailure( + 400, + "invalid_custom_provider_config", + customProvider.error.issues[0]?.message ?? "Custom provider config is invalid.", + ) + } + + const { models, ...providerConfig } = customProvider.data + + return { + source: input.source, + providerId: customProvider.data.id, + name: customProvider.data.name, + providerConfig: providerConfig as JsonRecord, + models: models.map((model) => ({ + id: model.id, + name: model.name, + config: model as JsonRecord, + })), + apiKey: input.apiKey?.trim() || null, + } +} + +async function loadLlmProviders(input: { + organizationId: typeof LlmProviderTable.$inferSelect.organizationId + currentMemberId: MemberId + memberTeams: Array<{ id: TeamId }> + isAdmin: boolean +}) { + const accessibleAccess = input.isAdmin + ? [] + : await listAccessibleProviderAccess({ + organizationId: input.organizationId, + currentMemberId: input.currentMemberId, + memberTeams: input.memberTeams, + }) + + const accessibleProviderIds = [...new Set(accessibleAccess.map((entry) => entry.llmProviderId))] + if (!input.isAdmin && accessibleProviderIds.length === 0) { + return [] + } + + const providers = await db + .select() + .from(LlmProviderTable) + .where( + input.isAdmin + ? eq(LlmProviderTable.organizationId, input.organizationId) + : and( + eq(LlmProviderTable.organizationId, input.organizationId), + inArray(LlmProviderTable.id, accessibleProviderIds), + ), + ) + .orderBy(desc(LlmProviderTable.updatedAt)) + + if (providers.length === 0) { + return [] + } + + const providerIds = providers.map((provider) => provider.id) + const models = await db + .select() + .from(LlmProviderModelTable) + .where(inArray(LlmProviderModelTable.llmProviderId, providerIds)) + + const memberAccessRows = await db + .select({ + access: { + id: LlmProviderAccessTable.id, + llmProviderId: LlmProviderAccessTable.llmProviderId, + createdAt: LlmProviderAccessTable.createdAt, + }, + member: { + id: MemberTable.id, + role: MemberTable.role, + }, + user: { + id: AuthUserTable.id, + name: AuthUserTable.name, + email: AuthUserTable.email, + image: AuthUserTable.image, + }, + }) + .from(LlmProviderAccessTable) + .innerJoin(MemberTable, eq(LlmProviderAccessTable.orgMembershipId, MemberTable.id)) + .innerJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id)) + .where(and(inArray(LlmProviderAccessTable.llmProviderId, providerIds), isNotNull(LlmProviderAccessTable.orgMembershipId))) + + const teamAccessRows = await db + .select({ + access: { + id: LlmProviderAccessTable.id, + llmProviderId: LlmProviderAccessTable.llmProviderId, + createdAt: LlmProviderAccessTable.createdAt, + }, + team: { + id: TeamTable.id, + name: TeamTable.name, + createdAt: TeamTable.createdAt, + updatedAt: TeamTable.updatedAt, + }, + }) + .from(LlmProviderAccessTable) + .innerJoin(TeamTable, eq(LlmProviderAccessTable.teamId, TeamTable.id)) + .where(and(inArray(LlmProviderAccessTable.llmProviderId, providerIds), isNotNull(LlmProviderAccessTable.teamId))) + + const modelsByProviderId = new Map() + for (const model of models) { + const existing = modelsByProviderId.get(model.llmProviderId) ?? [] + existing.push(model) + modelsByProviderId.set(model.llmProviderId, existing) + } + + const memberAccessByProviderId = new Map() + for (const row of memberAccessRows) { + const existing = memberAccessByProviderId.get(row.access.llmProviderId) ?? [] + existing.push(row) + memberAccessByProviderId.set(row.access.llmProviderId, existing) + } + + const teamAccessByProviderId = new Map() + for (const row of teamAccessRows) { + const existing = teamAccessByProviderId.get(row.access.llmProviderId) ?? [] + existing.push(row) + teamAccessByProviderId.set(row.access.llmProviderId, existing) + } + + const accessibleViaByProviderId = new Map() + for (const row of accessibleAccess) { + const existing = accessibleViaByProviderId.get(row.llmProviderId) ?? { orgMembershipIds: [], teamIds: [] } + if (row.orgMembershipId && !existing.orgMembershipIds.includes(row.orgMembershipId)) { + existing.orgMembershipIds.push(row.orgMembershipId) + } + if (row.teamId && !existing.teamIds.includes(row.teamId)) { + existing.teamIds.push(row.teamId) + } + accessibleViaByProviderId.set(row.llmProviderId, existing) + } + + return providers.map((provider) => ({ + ...provider, + hasApiKey: Boolean(provider.apiKey && provider.apiKey.trim().length > 0), + models: (modelsByProviderId.get(provider.id) ?? []) + .map((model) => ({ + id: model.modelId, + name: model.name, + config: model.modelConfig, + createdAt: model.createdAt, + })) + .sort((left, right) => left.name.localeCompare(right.name)), + access: { + 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, + name: row.team.name, + createdAt: row.team.createdAt, + updatedAt: row.team.updatedAt, + })), + }, + accessibleVia: accessibleViaByProviderId.get(provider.id) ?? { orgMembershipIds: [], teamIds: [] }, + })) +} + +export function registerOrgLlmProviderRoutes }>(app: Hono) { + app.get( + "/v1/orgs/:orgId/llm-provider-catalog", + requireUserMiddleware, + paramValidator(orgIdParamSchema), + resolveOrganizationContextMiddleware, + async (c) => { + try { + const providers = await listModelsDevProviders() + return c.json({ providers }) + } catch (error) { + return c.json({ + error: "provider_catalog_unavailable", + message: error instanceof Error ? error.message : "Could not load the models.dev catalog.", + }, 502) + } + }, + ) + + app.get( + "/v1/orgs/:orgId/llm-provider-catalog/:providerId", + requireUserMiddleware, + paramValidator(providerCatalogParamsSchema), + resolveOrganizationContextMiddleware, + async (c) => { + const params = c.req.valid("param") + + try { + const provider = await getModelsDevProvider(params.providerId) + if (!provider) { + return c.json({ error: "provider_not_found" }, 404) + } + + return c.json({ + provider: { + id: provider.id, + name: provider.name, + npm: provider.npm, + env: provider.env, + doc: provider.doc, + api: provider.api, + config: provider.config, + models: provider.models, + }, + }) + } catch (error) { + return c.json({ + error: "provider_catalog_unavailable", + message: error instanceof Error ? error.message : "Could not load the provider details.", + }, 502) + } + }, + ) + + app.get( + "/v1/orgs/:orgId/llm-providers", + requireUserMiddleware, + paramValidator(orgIdParamSchema), + resolveOrganizationContextMiddleware, + resolveMemberTeamsMiddleware, + async (c) => { + const payload = c.get("organizationContext") + const memberTeams = c.get("memberTeams") ?? [] + const providers = await loadLlmProviders({ + organizationId: payload.organization.id, + currentMemberId: payload.currentMember.id, + memberTeams, + isAdmin: isOrganizationAdmin(payload), + }) + + return c.json({ + llmProviders: providers.map((provider) => ({ + ...provider, + apiKey: undefined, + canManage: canManageLlmProvider(payload, provider), + })), + }) + }, + ) + + app.get( + "/v1/orgs/:orgId/llm-providers/:llmProviderId/connect", + requireUserMiddleware, + paramValidator(orgLlmProviderParamsSchema), + resolveOrganizationContextMiddleware, + resolveMemberTeamsMiddleware, + async (c) => { + const payload = c.get("organizationContext") + const memberTeams = c.get("memberTeams") ?? [] + 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) + } + + const accessible = await canAccessLlmProvider({ + organizationId: payload.organization.id, + llmProviderId, + currentMemberId: payload.currentMember.id, + memberTeams, + isAdmin: isOrganizationAdmin(payload), + }) + + if (!accessible) { + return c.json({ + error: "forbidden", + message: "You do not have access to this provider.", + }, 403) + } + + const models = await db + .select() + .from(LlmProviderModelTable) + .where(eq(LlmProviderModelTable.llmProviderId, llmProviderId)) + + return c.json({ + llmProvider: { + ...provider, + models: models + .map((model) => ({ + id: model.modelId, + name: model.name, + config: model.modelConfig, + createdAt: model.createdAt, + })) + .sort((left, right) => left.name.localeCompare(right.name)), + }, + }) + }, + ) + + app.post( + "/v1/orgs/:orgId/llm-providers", + requireUserMiddleware, + paramValidator(orgIdParamSchema), + resolveOrganizationContextMiddleware, + jsonValidator(llmProviderWriteSchema), + async (c) => { + const payload = c.get("organizationContext") + const input = c.req.valid("json") + + try { + const normalized = await normalizeLlmProviderInput(input) + const memberIds = await resolveMemberIds({ + organizationId: payload.organization.id, + values: input.memberIds, + }) + const teamIds = await resolveTeamIds({ + organizationId: payload.organization.id, + values: input.teamIds, + }) + + const llmProviderId = createDenTypeId("llmProvider") + const protectedMemberIds = [...new Set([payload.currentMember.id, ...memberIds])] + const now = new Date() + + await db.transaction(async (tx) => { + await tx.insert(LlmProviderTable).values({ + id: llmProviderId, + organizationId: payload.organization.id, + createdByOrgMembershipId: payload.currentMember.id, + source: normalized.source, + providerId: normalized.providerId, + name: normalized.name, + providerConfig: normalized.providerConfig, + apiKey: normalized.apiKey, + createdAt: now, + updatedAt: now, + }) + + if (normalized.models.length > 0) { + await tx.insert(LlmProviderModelTable).values( + normalized.models.map((model) => ({ + id: createDenTypeId("llmProviderModel"), + llmProviderId, + modelId: model.id, + name: model.name, + modelConfig: model.config, + createdAt: now, + })), + ) + } + + const accessRows = [ + ...protectedMemberIds.map((orgMembershipId) => ({ + id: createDenTypeId("llmProviderAccess"), + llmProviderId, + orgMembershipId, + teamId: null, + createdAt: now, + })), + ...teamIds.map((teamId) => ({ + id: createDenTypeId("llmProviderAccess"), + llmProviderId, + orgMembershipId: null, + teamId, + createdAt: now, + })), + ] + + if (accessRows.length > 0) { + await tx.insert(LlmProviderAccessTable).values(accessRows) + } + }) + + return c.json({ + llmProvider: { + id: llmProviderId, + organizationId: payload.organization.id, + createdByOrgMembershipId: payload.currentMember.id, + source: normalized.source, + providerId: normalized.providerId, + name: normalized.name, + providerConfig: normalized.providerConfig, + hasApiKey: Boolean(normalized.apiKey), + createdAt: now, + updatedAt: now, + }, + }, 201) + } catch (error) { + if (isRouteFailure(error)) { + return c.json( + { error: error.error, message: error.message }, + { status: error.status as 400 | 404 }, + ) + } + + throw error + } + }, + ) + + app.patch( + "/v1/orgs/:orgId/llm-providers/:llmProviderId", + requireUserMiddleware, + paramValidator(orgLlmProviderParamsSchema), + resolveOrganizationContextMiddleware, + jsonValidator(llmProviderWriteSchema), + async (c) => { + const payload = c.get("organizationContext") + const params = c.req.valid("param") + const input = c.req.valid("json") + + 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 (!canManageLlmProvider(payload, provider)) { + return c.json({ + error: "forbidden", + message: "Only the provider creator or an org admin can update providers.", + }, 403) + } + + try { + const normalized = await normalizeLlmProviderInput(input) + const memberIds = await resolveMemberIds({ + organizationId: payload.organization.id, + values: input.memberIds, + }) + const teamIds = await resolveTeamIds({ + organizationId: payload.organization.id, + values: input.teamIds, + }) + const protectedMemberIds = [...new Set([provider.createdByOrgMembershipId, ...memberIds])] + const updatedAt = new Date() + + await db.transaction(async (tx) => { + await tx + .update(LlmProviderTable) + .set({ + source: normalized.source, + providerId: normalized.providerId, + name: normalized.name, + providerConfig: normalized.providerConfig, + apiKey: input.apiKey === undefined ? provider.apiKey : normalized.apiKey, + updatedAt, + }) + .where(eq(LlmProviderTable.id, provider.id)) + + await tx.delete(LlmProviderModelTable).where(eq(LlmProviderModelTable.llmProviderId, provider.id)) + await tx.delete(LlmProviderAccessTable).where(eq(LlmProviderAccessTable.llmProviderId, provider.id)) + + if (normalized.models.length > 0) { + await tx.insert(LlmProviderModelTable).values( + normalized.models.map((model) => ({ + id: createDenTypeId("llmProviderModel"), + llmProviderId: provider.id, + modelId: model.id, + name: model.name, + modelConfig: model.config, + createdAt: updatedAt, + })), + ) + } + + const accessRows = [ + ...protectedMemberIds.map((orgMembershipId) => ({ + id: createDenTypeId("llmProviderAccess"), + llmProviderId: provider.id, + orgMembershipId, + teamId: null, + createdAt: updatedAt, + })), + ...teamIds.map((teamId) => ({ + id: createDenTypeId("llmProviderAccess"), + llmProviderId: provider.id, + orgMembershipId: null, + teamId, + createdAt: updatedAt, + })), + ] + + if (accessRows.length > 0) { + await tx.insert(LlmProviderAccessTable).values(accessRows) + } + }) + + return c.json({ + llmProvider: { + ...provider, + source: normalized.source, + providerId: normalized.providerId, + name: normalized.name, + providerConfig: normalized.providerConfig, + hasApiKey: input.apiKey === undefined ? Boolean(provider.apiKey) : Boolean(normalized.apiKey), + updatedAt, + }, + }) + } catch (error) { + if (isRouteFailure(error)) { + return c.json( + { error: error.error, message: error.message }, + { status: error.status as 400 | 404 }, + ) + } + + throw error + } + }, + ) + + app.delete( + "/v1/orgs/:orgId/llm-providers/:llmProviderId", + 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 (!canManageLlmProvider(payload, provider)) { + return c.json({ + error: "forbidden", + message: "Only the provider creator or an org admin can delete providers.", + }, 403) + } + + await db.transaction(async (tx) => { + await tx.delete(LlmProviderAccessTable).where(eq(LlmProviderAccessTable.llmProviderId, provider.id)) + await tx.delete(LlmProviderModelTable).where(eq(LlmProviderModelTable.llmProviderId, provider.id)) + await tx.delete(LlmProviderTable).where(eq(LlmProviderTable.id, provider.id)) + }) + + return c.body(null, 204) + }, + ) + + app.delete( + "/v1/orgs/:orgId/llm-providers/:llmProviderId/access/:accessId", + requireUserMiddleware, + paramValidator(orgLlmProviderParamsSchema.extend(idParamSchema("accessId").shape)), + resolveOrganizationContextMiddleware, + async (c) => { + const payload = c.get("organizationContext") + const params = c.req.valid("param") + + let llmProviderId: LlmProviderId + let accessId: LlmProviderAccessId + try { + llmProviderId = parseLlmProviderId(params.llmProviderId) + accessId = parseLlmProviderAccessId(params.accessId) + } catch { + return c.json({ error: "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 (!canManageLlmProvider(payload, provider)) { + return c.json({ error: "forbidden", message: "Only the provider creator or an org admin can manage access." }, 403) + } + + const accessRows = await db + .select() + .from(LlmProviderAccessTable) + .where(and(eq(LlmProviderAccessTable.id, accessId), eq(LlmProviderAccessTable.llmProviderId, provider.id))) + .limit(1) + + const access = accessRows[0] + if (!access) { + return c.json({ error: "llm_provider_access_not_found" }, 404) + } + + if (access.orgMembershipId === provider.createdByOrgMembershipId) { + return c.json({ + error: "protected_access", + message: "The provider creator always keeps direct access.", + }, 409) + } + + await db.delete(LlmProviderAccessTable).where(eq(LlmProviderAccessTable.id, access.id)) + return c.body(null, 204) + }, + ) +} diff --git a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx index da96b1a9..f0f4eed0 100644 --- a/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx +++ b/ee/apps/den-web/app/(den)/_components/checkout-screen.tsx @@ -255,7 +255,7 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:
Share setup across your team and org
Background agents in alpha for selected workflows
-
Custom LLM providers for teams, coming soon
+
Custom LLM providers with team access controls
@@ -266,9 +266,9 @@ export function CheckoutScreen({ customerSessionToken }: { customerSessionToken:

-

Custom LLM providers

+

LLM providers

- Standardize provider access for your team. Coming soon. + Standardize provider access, model selection, and team rollout.

diff --git a/ee/apps/den-web/app/(den)/_lib/den-org.ts b/ee/apps/den-web/app/(den)/_lib/den-org.ts index 5a273fb3..c1ed75e0 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-org.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-org.ts @@ -200,6 +200,22 @@ export function getCustomLlmProvidersRoute(orgSlug: string): string { return `${getOrgDashboardRoute(orgSlug)}/custom-llm-providers`; } +export function getLlmProvidersRoute(orgSlug: string): string { + return getCustomLlmProvidersRoute(orgSlug); +} + +export function getLlmProviderRoute(orgSlug: string, llmProviderId: string): string { + return `${getLlmProvidersRoute(orgSlug)}/${encodeURIComponent(llmProviderId)}`; +} + +export function getEditLlmProviderRoute(orgSlug: string, llmProviderId: string): string { + return `${getLlmProviderRoute(orgSlug, llmProviderId)}/edit`; +} + +export function getNewLlmProviderRoute(orgSlug: string): string { + return `${getLlmProvidersRoute(orgSlug)}/new`; +} + export function getBillingRoute(orgSlug: string): string { return `${getOrgDashboardRoute(orgSlug)}/billing`; } diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/custom-llm-providers-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/custom-llm-providers-screen.tsx deleted file mode 100644 index b9e7acf4..00000000 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/custom-llm-providers-screen.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { Cpu } from "lucide-react"; -import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template"; - -const comingSoonItems = [ - "Standardize provider access across your team.", - "Keep model choices consistent across shared setups.", - "Control rollout without reconfiguring every teammate by hand.", -]; - -export function CustomLlmProvidersScreen() { - return ( - -
- {comingSoonItems.map((text) => ( -
- - Coming soon - -

{text}

-
- ))} -
-
- ); -} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/dashboard-overview-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/dashboard-overview-screen.tsx index 1b28b27a..26e41e51 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/dashboard-overview-screen.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/dashboard-overview-screen.tsx @@ -69,7 +69,7 @@ export function DashboardOverviewScreen() { tint: "bg-orange-50 text-orange-500 group-hover:bg-orange-100", }, { - label: "Custom LLMs", + label: "LLM Providers", icon: Cpu, href: getCustomLlmProvidersRoute(orgSlug), tint: "bg-lime-50 text-lime-600 group-hover:bg-lime-100", diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-data.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-data.tsx new file mode 100644 index 00000000..be8c6f24 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-data.tsx @@ -0,0 +1,430 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getErrorMessage, requestJson } from "../../../../_lib/den-flow"; + +export type DenLlmProviderSource = "models_dev" | "custom"; + +export type DenLlmProviderModel = { + id: string; + name: string; + config: Record; + createdAt: string | null; +}; + +export type DenLlmProviderMemberAccess = { + id: string; + orgMembershipId: string; + role: string; + createdAt: string | null; + user: { + id: string; + name: string; + email: string; + image: string | null; + }; +}; + +export type DenLlmProviderTeamAccess = { + id: string; + teamId: string; + name: string; + createdAt: string | null; + updatedAt: string | null; +}; + +export type DenLlmProvider = { + id: string; + organizationId: string; + createdByOrgMembershipId: string; + source: DenLlmProviderSource; + providerId: string; + name: string; + providerConfig: Record; + hasApiKey: boolean; + createdAt: string | null; + updatedAt: string | null; + canManage: boolean; + accessibleVia: { + orgMembershipIds: string[]; + teamIds: string[]; + }; + models: DenLlmProviderModel[]; + access: { + members: DenLlmProviderMemberAccess[]; + teams: DenLlmProviderTeamAccess[]; + }; +}; + +export type DenModelsDevProviderSummary = { + id: string; + name: string; + npm: string | null; + env: string[]; + doc: string | null; + api: string | null; + modelCount: number; +}; + +export type DenModelsDevProviderDetail = DenModelsDevProviderSummary & { + config: Record; + models: Array<{ + id: string; + name: string; + config: Record; + }>; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function asIsoString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function asStringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} + +function asJsonRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function asLlmProviderModel(value: unknown): DenLlmProviderModel | null { + if (!isRecord(value)) { + return null; + } + + const id = asString(value.id); + const name = asString(value.name); + if (!id || !name) { + return null; + } + + return { + id, + name, + config: asJsonRecord(value.config), + createdAt: asIsoString(value.createdAt), + }; +} + +function asLlmProviderMemberAccess(value: unknown): DenLlmProviderMemberAccess | null { + if (!isRecord(value) || !isRecord(value.user)) { + return null; + } + + const id = asString(value.id); + const orgMembershipId = asString(value.orgMembershipId); + const role = asString(value.role); + const userId = asString(value.user.id); + const name = asString(value.user.name); + const email = asString(value.user.email); + if (!id || !orgMembershipId || !role || !userId || !name || !email) { + return null; + } + + return { + id, + orgMembershipId, + role, + createdAt: asIsoString(value.createdAt), + user: { + id: userId, + name, + email, + image: asString(value.user.image), + }, + }; +} + +function asLlmProviderTeamAccess(value: unknown): DenLlmProviderTeamAccess | null { + if (!isRecord(value)) { + return null; + } + + const id = asString(value.id); + const teamId = asString(value.teamId); + const name = asString(value.name); + if (!id || !teamId || !name) { + return null; + } + + return { + id, + teamId, + name, + createdAt: asIsoString(value.createdAt), + updatedAt: asIsoString(value.updatedAt), + }; +} + +function asLlmProvider(value: unknown): DenLlmProvider | null { + if (!isRecord(value) || !isRecord(value.access) || !isRecord(value.accessibleVia)) { + return null; + } + + const id = asString(value.id); + const organizationId = asString(value.organizationId); + const createdByOrgMembershipId = asString(value.createdByOrgMembershipId); + const providerId = asString(value.providerId); + const name = asString(value.name); + const source = value.source === "models_dev" || value.source === "custom" ? value.source : null; + if (!id || !organizationId || !createdByOrgMembershipId || !providerId || !name || !source) { + return null; + } + + return { + id, + organizationId, + createdByOrgMembershipId, + source, + providerId, + name, + providerConfig: asJsonRecord(value.providerConfig), + hasApiKey: value.hasApiKey === true, + createdAt: asIsoString(value.createdAt), + updatedAt: asIsoString(value.updatedAt), + canManage: value.canManage === true, + accessibleVia: { + orgMembershipIds: asStringList(value.accessibleVia.orgMembershipIds), + teamIds: asStringList(value.accessibleVia.teamIds), + }, + models: Array.isArray(value.models) + ? value.models.map(asLlmProviderModel).filter((entry): entry is DenLlmProviderModel => entry !== null) + : [], + access: { + members: Array.isArray(value.access.members) + ? value.access.members + .map(asLlmProviderMemberAccess) + .filter((entry): entry is DenLlmProviderMemberAccess => entry !== null) + : [], + teams: Array.isArray(value.access.teams) + ? value.access.teams + .map(asLlmProviderTeamAccess) + .filter((entry): entry is DenLlmProviderTeamAccess => entry !== null) + : [], + }, + }; +} + +function asCatalogProviderSummary(value: unknown): DenModelsDevProviderSummary | null { + if (!isRecord(value)) { + return null; + } + + const id = asString(value.id); + const name = asString(value.name); + if (!id || !name) { + return null; + } + + return { + id, + name, + npm: asString(value.npm), + env: asStringList(value.env), + doc: asString(value.doc), + api: asString(value.api), + modelCount: typeof value.modelCount === "number" ? value.modelCount : 0, + }; +} + +function asCatalogProviderDetail(value: unknown): DenModelsDevProviderDetail | null { + const summary = asCatalogProviderSummary(value); + if (!summary || !isRecord(value)) { + return null; + } + + const models = Array.isArray(value.models) + ? value.models + .map((model) => { + if (!isRecord(model)) { + return null; + } + + const id = asString(model.id); + const name = asString(model.name); + if (!id || !name) { + return null; + } + + return { + id, + name, + config: asJsonRecord(model.config), + }; + }) + .filter((entry): entry is DenModelsDevProviderDetail["models"][number] => entry !== null) + : []; + + return { + ...summary, + config: asJsonRecord(value.config), + models, + }; +} + +export function getProviderEnvNames(config: Record): string[] { + return asStringList(config.env); +} + +export function getProviderDocUrl(config: Record): string | null { + return asString(config.doc); +} + +export function getProviderNpmPackage(config: Record): string | null { + return asString(config.npm); +} + +export function getProviderApiBase(config: Record): string | null { + return asString(config.api); +} + +export function formatProviderTimestamp(value: string | null) { + if (!value) { + return "Recently updated"; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "Recently updated"; + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); +} + +export function buildCustomProviderTemplate() { + return JSON.stringify( + { + id: "custom-provider", + name: "Custom Provider", + npm: "@ai-sdk/openai-compatible", + env: ["CUSTOM_PROVIDER_API_KEY"], + doc: "https://example.com/docs/models", + api: "https://api.example.com/v1", + models: [ + { + id: "custom-provider/example-model", + name: "Example Model", + attachment: false, + reasoning: false, + tool_call: true, + structured_output: true, + temperature: true, + release_date: "2026-01-01", + last_updated: "2026-01-01", + open_weights: false, + limit: { + context: 128000, + input: 128000, + output: 8192, + }, + modalities: { + input: ["text"], + output: ["text"], + }, + }, + ], + }, + null, + 2, + ); +} + +export function buildEditableCustomProviderText(provider: DenLlmProvider) { + return JSON.stringify( + { + ...provider.providerConfig, + models: provider.models.map((model) => model.config), + }, + null, + 2, + ); +} + +export async function requestLlmProviderCatalog(orgId: string) { + const { response, payload } = await requestJson(`/v1/orgs/${encodeURIComponent(orgId)}/llm-provider-catalog`, { method: "GET" }, 20000); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to load the provider catalog (${response.status}).`)); + } + + return isRecord(payload) && Array.isArray(payload.providers) + ? payload.providers.map(asCatalogProviderSummary).filter((entry): entry is DenModelsDevProviderSummary => entry !== null) + : []; +} + +export async function requestLlmProviderCatalogDetail(orgId: string, providerId: string) { + const { response, payload } = await requestJson( + `/v1/orgs/${encodeURIComponent(orgId)}/llm-provider-catalog/${encodeURIComponent(providerId)}`, + { method: "GET" }, + 20000, + ); + + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to load provider details (${response.status}).`)); + } + + if (!isRecord(payload) || !payload.provider) { + throw new Error("Provider details were missing from the response."); + } + + const detail = asCatalogProviderDetail(payload.provider); + if (!detail) { + throw new Error("Provider details could not be parsed."); + } + + return detail; +} + +export function useOrgLlmProviders(orgId: string | null) { + const [llmProviders, setLlmProviders] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function loadProviders() { + if (!orgId) { + setLlmProviders([]); + setError("Organization not found."); + return; + } + + setBusy(true); + setError(null); + try { + const { response, payload } = await requestJson(`/v1/orgs/${encodeURIComponent(orgId)}/llm-providers`, { method: "GET" }, 15000); + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to load providers (${response.status}).`)); + } + + const nextProviders = isRecord(payload) && Array.isArray(payload.llmProviders) + ? payload.llmProviders.map(asLlmProvider).filter((entry): entry is DenLlmProvider => entry !== null) + : []; + setLlmProviders(nextProviders); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "Failed to load the provider library."); + } finally { + setBusy(false); + } + } + + useEffect(() => { + void loadProviders(); + }, [orgId]); + + return { + llmProviders, + busy, + error, + reloadProviders: loadProviders, + }; +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-detail-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-detail-screen.tsx new file mode 100644 index 00000000..9857341e --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-detail-screen.tsx @@ -0,0 +1,291 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, ExternalLink, KeyRound, Trash2, Users } from "lucide-react"; +import { DenButton } from "../../../../_components/ui/button"; +import { getErrorMessage, requestJson } from "../../../../_lib/den-flow"; +import { + getEditLlmProviderRoute, + getLlmProvidersRoute, +} from "../../../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; +import { + formatProviderTimestamp, + getProviderApiBase, + getProviderDocUrl, + getProviderEnvNames, + getProviderNpmPackage, + useOrgLlmProviders, +} from "./llm-provider-data"; + +function formatCountLabel(count: number, singular: string, plural: string) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function getLimitLabel(config: Record) { + const limit = typeof config.limit === "object" && config.limit !== null ? config.limit as Record : null; + const context = typeof limit?.context === "number" ? limit.context : null; + return context ? `${context.toLocaleString()} ctx` : null; +} + +export function LlmProviderDetailScreen({ llmProviderId }: { llmProviderId: string }) { + const router = useRouter(); + const { orgId, orgSlug } = useOrgDashboard(); + const { llmProviders, busy, error, reloadProviders } = useOrgLlmProviders(orgId); + const [deleteBusy, setDeleteBusy] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + const provider = useMemo( + () => llmProviders.find((entry) => entry.id === llmProviderId) ?? null, + [llmProviderId, llmProviders], + ); + + async function deleteProvider() { + if (!orgId || !provider) { + return; + } + + if (!window.confirm(`Delete ${provider.name}? This will remove its saved model list and access rules.`)) { + return; + } + + setDeleteBusy(true); + setDeleteError(null); + try { + const { response, payload } = await requestJson( + `/v1/orgs/${encodeURIComponent(orgId)}/llm-providers/${encodeURIComponent(provider.id)}`, + { method: "DELETE" }, + 12000, + ); + + if (response.status !== 204 && !response.ok) { + throw new Error(getErrorMessage(payload, `Failed to delete provider (${response.status}).`)); + } + + await reloadProviders(); + router.push(getLlmProvidersRoute(orgSlug)); + router.refresh(); + } catch (nextError) { + setDeleteError(nextError instanceof Error ? nextError.message : "Could not delete the provider."); + } finally { + setDeleteBusy(false); + } + } + + if (busy && !provider) { + return ( +
+
+ Loading provider details... +
+
+ ); + } + + if (!provider) { + return ( +
+
+ {error ?? "That provider could not be found."} +
+
+ ); + } + + const envNames = getProviderEnvNames(provider.providerConfig); + const npmPackage = getProviderNpmPackage(provider.providerConfig); + const apiBase = getProviderApiBase(provider.providerConfig); + const docUrl = getProviderDocUrl(provider.providerConfig); + + return ( +
+
+

LLM provider

+
+
+

{provider.name}

+

+ Control which models this provider exposes, keep the credential in one place, and share access with the right people or teams. +

+
+ +
+ {provider.source === "custom" ? "Custom" : "Catalog"} + {formatCountLabel(provider.models.length, "model", "models")} + {provider.hasApiKey ? "Credential saved" : "Credential missing"} +
+
+
+ +
+ + + Back to providers + + +
+ {provider.canManage ? ( + <> + + Edit Provider + + void deleteProvider()}> + + Delete + + + ) : null} +
+
+ + {deleteError ? ( +
+ {deleteError} +
+ ) : null} + +
+
+
+

Provider configuration

+

The core provider metadata and credential state stored for this workspace.

+
+ +
+ + {provider.hasApiKey ? "Credential saved" : "Credential missing"} +
+
+ +
+
+

Provider id

+

{provider.providerId}

+
+
+

NPM package

+

{npmPackage ?? "Not set"}

+
+
+

API base

+

{apiBase ?? "Not set"}

+
+
+

Updated

+

{formatProviderTimestamp(provider.updatedAt)}

+
+
+ +
+ {envNames.map((envName) => ( + + {envName} + + ))} + {docUrl ? ( + + Docs + + + ) : null} +
+
+ +
+
+
+

Selected models

+

The exact models this provider configuration exposes today.

+
+
+ {formatCountLabel(provider.models.length, "model", "models")} +
+
+ +
+ {provider.models.map((model) => { + const limitLabel = getLimitLabel(model.config); + return ( +
+
+
+

{model.name}

+

{model.id}

+
+ {limitLabel ? ( + + {limitLabel} + + ) : null} +
+
+ ); + })} +
+
+ +
+
+
+

Access

+

People and teams who can use this provider.

+
+
+ + {provider.access.members.length + provider.access.teams.length} grants +
+
+ +
+
+

People

+
+ {provider.access.members.length === 0 ? ( +

No direct people access yet.

+ ) : provider.access.members.map((member) => ( +
+

{member.user.name}

+

{member.user.email}

+
+ ))} +
+
+ +
+

Teams

+
+ {provider.access.teams.length === 0 ? ( +

No team access yet.

+ ) : provider.access.teams.map((team) => ( +
+

{team.name}

+

Updated {formatProviderTimestamp(team.updatedAt)}

+
+ ))} +
+
+
+
+ + {provider.source === "custom" ? ( +
+

Custom provider payload

+

The raw provider config saved for this custom source.

+
+            {JSON.stringify(provider.providerConfig, null, 2)}
+          
+
+ ) : null} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-editor-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-editor-screen.tsx new file mode 100644 index 00000000..be5e9641 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-provider-editor-screen.tsx @@ -0,0 +1,514 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { ArrowLeft, CheckCircle2, Circle, Cpu, Search } from "lucide-react"; +import { DenButton } from "../../../../_components/ui/button"; +import { DenInput } from "../../../../_components/ui/input"; +import { UnderlineTabs } from "../../../../_components/ui/tabs"; +import { DenTextarea } from "../../../../_components/ui/textarea"; +import { getErrorMessage, requestJson } from "../../../../_lib/den-flow"; +import { + getLlmProviderRoute, + getLlmProvidersRoute, +} from "../../../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; +import { + buildCustomProviderTemplate, + buildEditableCustomProviderText, + getProviderApiBase, + getProviderDocUrl, + getProviderEnvNames, + getProviderNpmPackage, + requestLlmProviderCatalog, + requestLlmProviderCatalogDetail, + useOrgLlmProviders, + type DenLlmProvider, + type DenModelsDevProviderDetail, + type DenModelsDevProviderSummary, + type DenLlmProviderSource, +} from "./llm-provider-data"; + +const SOURCE_TABS = [ + { value: "models_dev" as const, label: "Catalog provider", icon: Cpu }, + { value: "custom" as const, label: "Custom provider", icon: Cpu }, +]; + +function getLockMemberId(provider: DenLlmProvider | null, currentMemberId: string | null) { + return provider?.createdByOrgMembershipId ?? currentMemberId; +} + +export function LlmProviderEditorScreen({ llmProviderId }: { llmProviderId?: string }) { + const router = useRouter(); + const { orgId, orgSlug, orgContext } = useOrgDashboard(); + const { llmProviders, busy, error, reloadProviders } = useOrgLlmProviders(orgId); + const provider = useMemo( + () => (llmProviderId ? llmProviders.find((entry) => entry.id === llmProviderId) ?? null : null), + [llmProviderId, llmProviders], + ); + const [source, setSource] = useState("models_dev"); + const [catalogProviders, setCatalogProviders] = useState([]); + const [catalogBusy, setCatalogBusy] = useState(false); + const [catalogError, setCatalogError] = useState(null); + const [selectedProviderId, setSelectedProviderId] = useState(""); + const [catalogDetail, setCatalogDetail] = useState(null); + const [detailBusy, setDetailBusy] = useState(false); + const [detailError, setDetailError] = useState(null); + const [selectedModelIds, setSelectedModelIds] = useState([]); + const [modelQuery, setModelQuery] = useState(""); + const [customConfigText, setCustomConfigText] = useState(buildCustomProviderTemplate()); + const [apiKey, setApiKey] = useState(""); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); + const [selectedTeamIds, setSelectedTeamIds] = useState([]); + const [saveBusy, setSaveBusy] = useState(false); + const [saveError, setSaveError] = useState(null); + + useEffect(() => { + if (!orgId) { + setCatalogProviders([]); + return; + } + + let canceled = false; + setCatalogBusy(true); + setCatalogError(null); + void requestLlmProviderCatalog(orgId) + .then((providers) => { + if (!canceled) { + setCatalogProviders(providers); + } + }) + .catch((loadError) => { + if (!canceled) { + setCatalogError(loadError instanceof Error ? loadError.message : "Failed to load the provider catalog."); + } + }) + .finally(() => { + if (!canceled) { + setCatalogBusy(false); + } + }); + + return () => { + canceled = true; + }; + }, [orgId]); + + useEffect(() => { + if (provider) { + setSource(provider.source); + setSelectedProviderId(provider.providerId); + setSelectedModelIds(provider.models.map((entry) => entry.id)); + setSelectedMemberIds(provider.access.members.map((entry) => entry.orgMembershipId)); + setSelectedTeamIds(provider.access.teams.map((entry) => entry.teamId)); + setCustomConfigText(provider.source === "custom" ? buildEditableCustomProviderText(provider) : buildCustomProviderTemplate()); + setApiKey(""); + return; + } + + setSource("models_dev"); + setSelectedProviderId(""); + setSelectedModelIds([]); + setSelectedMemberIds(orgContext?.currentMember.id ? [orgContext.currentMember.id] : []); + setSelectedTeamIds([]); + setCustomConfigText(buildCustomProviderTemplate()); + setApiKey(""); + }, [orgContext?.currentMember.id, provider]); + + useEffect(() => { + if (source !== "models_dev" || !orgId || !selectedProviderId) { + setCatalogDetail(null); + setDetailError(null); + setDetailBusy(false); + return; + } + + let canceled = false; + setDetailBusy(true); + setDetailError(null); + void requestLlmProviderCatalogDetail(orgId, selectedProviderId) + .then((detail) => { + if (!canceled) { + setCatalogDetail(detail); + setSelectedModelIds((current) => current.filter((entry) => detail.models.some((model) => model.id === entry))); + } + }) + .catch((loadError) => { + if (!canceled) { + setCatalogDetail(null); + setDetailError(loadError instanceof Error ? loadError.message : "Failed to load provider details."); + } + }) + .finally(() => { + if (!canceled) { + setDetailBusy(false); + } + }); + + return () => { + canceled = true; + }; + }, [orgId, selectedProviderId, source]); + + const currentMemberId = orgContext?.currentMember.id ?? null; + const lockedMemberId = getLockMemberId(provider, currentMemberId); + + const filteredModels = useMemo(() => { + const models = catalogDetail?.models ?? []; + const normalizedQuery = modelQuery.trim().toLowerCase(); + if (!normalizedQuery) { + return models; + } + + return models.filter((model) => model.name.toLowerCase().includes(normalizedQuery) || model.id.toLowerCase().includes(normalizedQuery)); + }, [catalogDetail?.models, modelQuery]); + + async function saveProvider() { + if (!orgId) { + setSaveError("Organization not found."); + return; + } + + if (source === "models_dev") { + if (!selectedProviderId) { + setSaveError("Select a provider."); + return; + } + if (!selectedModelIds.length) { + setSaveError("Select at least one model."); + return; + } + } + + if (source === "custom" && !customConfigText.trim()) { + setSaveError("Paste a custom provider config."); + return; + } + + setSaveBusy(true); + setSaveError(null); + try { + const body: Record = { + source, + memberIds: [...new Set(selectedMemberIds)], + teamIds: [...new Set(selectedTeamIds)], + }; + + if (source === "models_dev") { + body.providerId = selectedProviderId; + body.modelIds = selectedModelIds; + } else { + body.customConfigText = customConfigText; + } + + if (apiKey.trim() || !provider) { + body.apiKey = apiKey.trim(); + } + + const path = provider + ? `/v1/orgs/${encodeURIComponent(orgId)}/llm-providers/${encodeURIComponent(provider.id)}` + : `/v1/orgs/${encodeURIComponent(orgId)}/llm-providers`; + const method = provider ? "PATCH" : "POST"; + + const { response, payload } = await requestJson( + path, + { + method, + body: JSON.stringify(body), + }, + 20000, + ); + + if (!response.ok) { + throw new Error(getErrorMessage(payload, `Failed to save provider (${response.status}).`)); + } + + const nextProvider = payload && typeof payload === "object" && payload && "llmProvider" in payload && payload.llmProvider && typeof payload.llmProvider === "object" + ? payload.llmProvider as { id?: unknown } + : null; + const nextProviderId = typeof nextProvider?.id === "string" ? nextProvider.id : provider?.id ?? null; + if (!nextProviderId) { + throw new Error("The provider was saved, but no provider id was returned."); + } + + await reloadProviders(); + router.push(getLlmProviderRoute(orgSlug, nextProviderId)); + router.refresh(); + } catch (nextError) { + setSaveError(nextError instanceof Error ? nextError.message : "Could not save the provider."); + } finally { + setSaveBusy(false); + } + } + + if (busy && llmProviderId && !provider) { + return ( +
+
+ Loading provider details... +
+
+ ); + } + + if (llmProviderId && !provider) { + return ( +
+
+ {error ?? "That provider could not be found."} +
+
+ ); + } + + const providerDoc = catalogDetail ? getProviderDocUrl(catalogDetail.config) : null; + const providerNpm = catalogDetail ? getProviderNpmPackage(catalogDetail.config) : null; + const providerApiBase = catalogDetail ? getProviderApiBase(catalogDetail.config) : null; + const providerEnv = catalogDetail ? getProviderEnvNames(catalogDetail.config) : []; + + return ( +
+
+

+ {provider ? "Edit provider" : "Add provider"} +

+
+
+

+ {provider ? provider.name : "Add a new LLM provider"} +

+

+ Pick a models.dev provider or paste a custom config, then decide which models and teammates can use it. +

+
+ +
+ + {selectedModelIds.length} {selectedModelIds.length === 1 ? "model selected" : "models selected"} + + + {selectedMemberIds.length} people · {selectedTeamIds.length} teams + +
+
+
+ +
+ + + Back + + + void saveProvider()}> + {provider ? "Save Provider" : "Create Provider"} + +
+ + {saveError ? ( +
+ {saveError} +
+ ) : null} + +
+

Provider type

+ + + {source === "models_dev" ? ( +
+ + + {catalogBusy ?

Loading provider catalog...

: null} + {catalogError ?

{catalogError}

: null} + + {detailBusy ?

Loading provider details...

: null} + {detailError ?

{detailError}

: null} + + {catalogDetail ? ( +
+
+
+

NPM package

+

{providerNpm ?? "Not set"}

+
+
+

API base

+

{providerApiBase ?? "Not set"}

+
+
+

Env keys

+

{providerEnv.join(", ") || "None listed"}

+
+
+

Docs

+

{providerDoc ?? "Not set"}

+
+
+
+ ) : null} +
+ ) : ( +
+ Custom provider JSON + setCustomConfigText(event.target.value)} + rows={18} + /> +

+ Use the models.dev-style schema and include a models array. +

+
+ )} +
+ +
+
+
+

Credential

+

+ Save the provider credential in a dedicated text column for now. Leave this blank on edit to keep the existing saved value. +

+
+ {provider?.hasApiKey ? ( + Existing credential saved + ) : null} +
+ + +
+ + {source === "models_dev" ? ( +
+
+
+

Models

+

Pick the exact models this provider should expose.

+
+ + setModelQuery(event.target.value)} + placeholder="Search models..." + /> +
+ + {catalogDetail ? ( +
+ {filteredModels.map((model) => { + const selected = selectedModelIds.includes(model.id); + return ( + + ); + })} +
+ ) : ( +
+ Select a provider to browse its models. +
+ )} +
+ ) : null} + +
+

People access

+

Grant direct access to specific members. The provider creator always keeps access.

+ +
+ {orgContext?.members.map((member) => { + const selected = selectedMemberIds.includes(member.id); + const locked = lockedMemberId === member.id; + return ( + + ); + }) ?? null} +
+
+ +
+

Team access

+

Grant access to entire teams in one step.

+ + {orgContext?.teams.length ? ( +
+ {orgContext.teams.map((team) => { + const selected = selectedTeamIds.includes(team.id); + return ( + + ); + })} +
+ ) : ( +
+ Create teams from the Members page before assigning team access. +
+ )} +
+
+ ); +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-providers-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-providers-screen.tsx new file mode 100644 index 00000000..7ca407b2 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/llm-providers-screen.tsx @@ -0,0 +1,154 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { Cpu, KeyRound, Plus, Search } from "lucide-react"; +import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template"; +import { buttonVariants } from "../../../../_components/ui/button"; +import { DenInput } from "../../../../_components/ui/input"; +import { + getLlmProviderRoute, + getNewLlmProviderRoute, +} from "../../../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; +import { + formatProviderTimestamp, + getProviderDocUrl, + getProviderEnvNames, + useOrgLlmProviders, +} from "./llm-provider-data"; + +function getProviderSourceLabel(source: "models_dev" | "custom") { + return source === "custom" ? "Custom" : "Catalog"; +} + +export function LlmProvidersScreen() { + const { orgId, orgSlug } = useOrgDashboard(); + const { llmProviders, busy, error } = useOrgLlmProviders(orgId); + const [query, setQuery] = useState(""); + + const filteredProviders = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return llmProviders; + } + + return llmProviders.filter((provider) => { + const env = getProviderEnvNames(provider.providerConfig).join(" ").toLowerCase(); + const doc = (getProviderDocUrl(provider.providerConfig) ?? "").toLowerCase(); + return ( + provider.name.toLowerCase().includes(normalizedQuery) || + provider.providerId.toLowerCase().includes(normalizedQuery) || + provider.models.some((model) => model.name.toLowerCase().includes(normalizedQuery)) || + env.includes(normalizedQuery) || + doc.includes(normalizedQuery) + ); + }); + }, [llmProviders, query]); + + return ( + +
+ setQuery(event.target.value)} + placeholder="Search providers, models, or env keys..." + /> + + +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {busy ? ( +
+ Loading your provider library... +
+ ) : filteredProviders.length === 0 ? ( +
+

+ {llmProviders.length === 0 ? "No providers configured yet." : "No providers match that search yet."} +

+

+ {llmProviders.length === 0 + ? "Start with a models.dev provider, select the models you want to expose, add the credential, and then grant access to the right people or teams." + : "Try a broader search term, or create a new provider if this org needs a different stack."} +

+
+ ) : ( +
+ {filteredProviders.map((provider) => { + const envNames = getProviderEnvNames(provider.providerConfig); + const memberAccessCount = provider.access.members.length; + const teamAccessCount = provider.access.teams.length; + return ( + +
+
+
+ + {getProviderSourceLabel(provider.source)} +
+

{provider.name}

+

{provider.providerId}

+
+ +
+ {provider.models.length} {provider.models.length === 1 ? "model" : "models"} +
+
+ +
+ + + {provider.hasApiKey ? "Credential saved" : "Credential missing"} + + {envNames.slice(0, 2).map((envName) => ( + + {envName} + + ))} + {envNames.length > 2 ? ( + + +{envNames.length - 2} more keys + + ) : null} +
+ +
+
+

Access

+

{memberAccessCount} people · {teamAccessCount} teams

+
+
+

Updated

+

{formatProviderTimestamp(provider.updatedAt)}

+
+
+ + ); + })} +
+ )} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx index 2d3fc32d..86f9efcf 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx @@ -100,7 +100,7 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) { return "Shared Workspaces"; } if (pathname.startsWith(getCustomLlmProvidersRoute(orgSlug))) { - return "Custom LLMs"; + return "LLM Providers"; } if (pathname.startsWith(getSkillHubsRoute(orgSlug))) { return "Skill Hubs"; @@ -149,12 +149,12 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) { icon: Bot, badge: "Alpha", }, - { - href: activeOrg ? getCustomLlmProvidersRoute(activeOrg.slug) : "#", - label: "Custom LLMs", - icon: Cpu, - badge: "Soon", - }, + { + href: activeOrg ? getCustomLlmProvidersRoute(activeOrg.slug) : "#", + label: "LLM Providers", + icon: Cpu, + badge: "New", + }, { href: activeOrg ? getSkillHubsRoute(activeOrg.slug) : "#", label: "Skill Hubs", diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/[llmProviderId]/edit/page.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/[llmProviderId]/edit/page.tsx new file mode 100644 index 00000000..7a3a25ec --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/[llmProviderId]/edit/page.tsx @@ -0,0 +1,10 @@ +import { LlmProviderEditorScreen } from "../../../_components/llm-provider-editor-screen"; + +export default async function EditLlmProviderPage({ + params, +}: { + params: Promise<{ llmProviderId: string }>; +}) { + const { llmProviderId } = await params; + return ; +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/[llmProviderId]/page.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/[llmProviderId]/page.tsx new file mode 100644 index 00000000..6f04e8a3 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/[llmProviderId]/page.tsx @@ -0,0 +1,10 @@ +import { LlmProviderDetailScreen } from "../../_components/llm-provider-detail-screen"; + +export default async function LlmProviderPage({ + params, +}: { + params: Promise<{ llmProviderId: string }>; +}) { + const { llmProviderId } = await params; + return ; +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/new/page.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/new/page.tsx new file mode 100644 index 00000000..aa52ad60 --- /dev/null +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/new/page.tsx @@ -0,0 +1,5 @@ +import { LlmProviderEditorScreen } from "../../_components/llm-provider-editor-screen"; + +export default function NewLlmProviderPage() { + return ; +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/page.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/page.tsx index 6531c827..081573a5 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/page.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/custom-llm-providers/page.tsx @@ -1,5 +1,5 @@ -import { CustomLlmProvidersScreen } from "../_components/custom-llm-providers-screen"; +import { LlmProvidersScreen } from "../_components/llm-providers-screen"; export default function CustomLlmProvidersPage() { - return ; + return ; } diff --git a/ee/packages/den-db/.env.example b/ee/packages/den-db/.env.example index 346af836..2d9c4371 100644 --- a/ee/packages/den-db/.env.example +++ b/ee/packages/den-db/.env.example @@ -1,6 +1,10 @@ # MySQL mode: if DATABASE_URL is set, den-db uses mysql/mysql2. DATABASE_URL= +# Required dedicated DB encryption key for encrypted columns. Minimum 32 chars. +# Generate one with: openssl rand -base64 128 +DEN_DB_ENCRYPTION_KEY= + # PlanetScale mode: used when DATABASE_URL is not set. DATABASE_HOST= DATABASE_USERNAME= diff --git a/ee/packages/den-db/drizzle/0008_cynical_boomerang.sql b/ee/packages/den-db/drizzle/0008_cynical_boomerang.sql new file mode 100644 index 00000000..e15db629 --- /dev/null +++ b/ee/packages/den-db/drizzle/0008_cynical_boomerang.sql @@ -0,0 +1,45 @@ +CREATE TABLE `llm_provider_access` ( + `id` varchar(64) NOT NULL, + `llm_provider_id` varchar(64) NOT NULL, + `org_membership_id` varchar(64), + `team_id` varchar(64), + `created_at` timestamp(3) NOT NULL DEFAULT (now()), + CONSTRAINT `llm_provider_access_id` PRIMARY KEY(`id`), + CONSTRAINT `llm_provider_access_provider_org_membership` UNIQUE(`llm_provider_id`,`org_membership_id`), + CONSTRAINT `llm_provider_access_provider_team` UNIQUE(`llm_provider_id`,`team_id`) +); +--> statement-breakpoint +CREATE TABLE `llm_provider_model` ( + `id` varchar(64) NOT NULL, + `llm_provider_id` varchar(64) NOT NULL, + `model_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `model_config` json NOT NULL, + `created_at` timestamp(3) NOT NULL DEFAULT (now()), + CONSTRAINT `llm_provider_model_id` PRIMARY KEY(`id`), + CONSTRAINT `llm_provider_model_provider_model` UNIQUE(`llm_provider_id`,`model_id`) +); +--> statement-breakpoint +CREATE TABLE `llm_provider` ( + `id` varchar(64) NOT NULL, + `organization_id` varchar(64) NOT NULL, + `created_by_org_membership_id` varchar(64) NOT NULL, + `source` enum('models_dev','custom') NOT NULL, + `provider_id` varchar(255) NOT NULL, + `name` varchar(255) NOT NULL, + `provider_config` json NOT NULL, + `api_key` text, + `created_at` timestamp(3) NOT NULL DEFAULT (now()), + `updated_at` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + CONSTRAINT `llm_provider_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE INDEX `llm_provider_access_llm_provider_id` ON `llm_provider_access` (`llm_provider_id`);--> statement-breakpoint +CREATE INDEX `llm_provider_access_org_membership_id` ON `llm_provider_access` (`org_membership_id`);--> statement-breakpoint +CREATE INDEX `llm_provider_access_team_id` ON `llm_provider_access` (`team_id`);--> statement-breakpoint +CREATE INDEX `llm_provider_model_llm_provider_id` ON `llm_provider_model` (`llm_provider_id`);--> statement-breakpoint +CREATE INDEX `llm_provider_model_model_id` ON `llm_provider_model` (`model_id`);--> statement-breakpoint +CREATE INDEX `llm_provider_organization_id` ON `llm_provider` (`organization_id`);--> statement-breakpoint +CREATE INDEX `llm_provider_created_by_org_membership_id` ON `llm_provider` (`created_by_org_membership_id`);--> statement-breakpoint +CREATE INDEX `llm_provider_source` ON `llm_provider` (`source`);--> statement-breakpoint +CREATE INDEX `llm_provider_provider_id` ON `llm_provider` (`provider_id`); \ No newline at end of file diff --git a/ee/packages/den-db/drizzle/meta/0008_snapshot.json b/ee/packages/den-db/drizzle/meta/0008_snapshot.json new file mode 100644 index 00000000..1b7c3d68 --- /dev/null +++ b/ee/packages/den-db/drizzle/meta/0008_snapshot.json @@ -0,0 +1,2358 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "e465c2af-b7a2-457f-89cd-2a0f02645bc8", + "prevId": "0c7f8a74-8ffd-40db-9a67-f5ae72db8656", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "account_user_id": { + "name": "account_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "account_id": { + "name": "account_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_team_id": { + "name": "active_team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "session_token": { + "name": "session_token", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_user_id": { + "name": "session_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "session_id": { + "name": "session_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "user_email": { + "name": "user_email", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_id": { + "name": "user_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_id": { + "name": "verification_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "desktop_handoff_grant": { + "name": "desktop_handoff_grant", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "desktop_handoff_grant_user_id": { + "name": "desktop_handoff_grant_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "desktop_handoff_grant_expires_at": { + "name": "desktop_handoff_grant_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "desktop_handoff_grant_id": { + "name": "desktop_handoff_grant_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inviter_id": { + "name": "inviter_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "invitation_organization_id": { + "name": "invitation_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invitation_email": { + "name": "invitation_email", + "columns": [ + "email" + ], + "isUnique": false + }, + "invitation_status": { + "name": "invitation_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "invitation_team_id": { + "name": "invitation_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "invitation_id": { + "name": "invitation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "member": { + "name": "member", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "member_organization_id": { + "name": "member_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "member_user_id": { + "name": "member_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "member_organization_user": { + "name": "member_organization_user", + "columns": [ + "organization_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "member_id": { + "name": "member_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization_role": { + "name": "organization_role", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "organization_role_organization_id": { + "name": "organization_role_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_role_name": { + "name": "organization_role_name", + "columns": [ + "organization_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_role_id": { + "name": "organization_role_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "organization": { + "name": "organization", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo": { + "name": "logo", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "organization_slug": { + "name": "organization_slug", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "organization_id": { + "name": "organization_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "temp_template_sharing": { + "name": "temp_template_sharing", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_member_id": { + "name": "creator_member_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "creator_user_id": { + "name": "creator_user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template_json": { + "name": "template_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "temp_template_sharing_org_id": { + "name": "temp_template_sharing_org_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "temp_template_sharing_creator_member_id": { + "name": "temp_template_sharing_creator_member_id", + "columns": [ + "creator_member_id" + ], + "isUnique": false + }, + "temp_template_sharing_creator_user_id": { + "name": "temp_template_sharing_creator_user_id", + "columns": [ + "creator_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "temp_template_sharing_id": { + "name": "temp_template_sharing_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llm_provider_access": { + "name": "llm_provider_access", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "llm_provider_id": { + "name": "llm_provider_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "llm_provider_access_llm_provider_id": { + "name": "llm_provider_access_llm_provider_id", + "columns": [ + "llm_provider_id" + ], + "isUnique": false + }, + "llm_provider_access_org_membership_id": { + "name": "llm_provider_access_org_membership_id", + "columns": [ + "org_membership_id" + ], + "isUnique": false + }, + "llm_provider_access_team_id": { + "name": "llm_provider_access_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "llm_provider_access_provider_org_membership": { + "name": "llm_provider_access_provider_org_membership", + "columns": [ + "llm_provider_id", + "org_membership_id" + ], + "isUnique": true + }, + "llm_provider_access_provider_team": { + "name": "llm_provider_access_provider_team", + "columns": [ + "llm_provider_id", + "team_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "llm_provider_access_id": { + "name": "llm_provider_access_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llm_provider_model": { + "name": "llm_provider_model", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "llm_provider_id": { + "name": "llm_provider_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_config": { + "name": "model_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "llm_provider_model_llm_provider_id": { + "name": "llm_provider_model_llm_provider_id", + "columns": [ + "llm_provider_id" + ], + "isUnique": false + }, + "llm_provider_model_model_id": { + "name": "llm_provider_model_model_id", + "columns": [ + "model_id" + ], + "isUnique": false + }, + "llm_provider_model_provider_model": { + "name": "llm_provider_model_provider_model", + "columns": [ + "llm_provider_id", + "model_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "llm_provider_model_id": { + "name": "llm_provider_model_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "llm_provider": { + "name": "llm_provider", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "enum('models_dev','custom')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "llm_provider_organization_id": { + "name": "llm_provider_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "llm_provider_created_by_org_membership_id": { + "name": "llm_provider_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + }, + "llm_provider_source": { + "name": "llm_provider_source", + "columns": [ + "source" + ], + "isUnique": false + }, + "llm_provider_provider_id": { + "name": "llm_provider_provider_id", + "columns": [ + "provider_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "llm_provider_id": { + "name": "llm_provider_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "skill_hub_member": { + "name": "skill_hub_member", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_hub_id": { + "name": "skill_hub_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "skill_hub_member_skill_hub_id": { + "name": "skill_hub_member_skill_hub_id", + "columns": [ + "skill_hub_id" + ], + "isUnique": false + }, + "skill_hub_member_org_membership_id": { + "name": "skill_hub_member_org_membership_id", + "columns": [ + "org_membership_id" + ], + "isUnique": false + }, + "skill_hub_member_team_id": { + "name": "skill_hub_member_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "skill_hub_member_hub_org_membership": { + "name": "skill_hub_member_hub_org_membership", + "columns": [ + "skill_hub_id", + "org_membership_id" + ], + "isUnique": true + }, + "skill_hub_member_hub_team": { + "name": "skill_hub_member_hub_team", + "columns": [ + "skill_hub_id", + "team_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "skill_hub_member_id": { + "name": "skill_hub_member_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "skill_hub_skill": { + "name": "skill_hub_skill", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_hub_id": { + "name": "skill_hub_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_id": { + "name": "skill_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "skill_hub_skill_skill_hub_id": { + "name": "skill_hub_skill_skill_hub_id", + "columns": [ + "skill_hub_id" + ], + "isUnique": false + }, + "skill_hub_skill_skill_id": { + "name": "skill_hub_skill_skill_id", + "columns": [ + "skill_id" + ], + "isUnique": false + }, + "skill_hub_skill_hub_skill": { + "name": "skill_hub_skill_hub_skill", + "columns": [ + "skill_hub_id", + "skill_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "skill_hub_skill_id": { + "name": "skill_hub_skill_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "skill_hub": { + "name": "skill_hub", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "skill_hub_organization_id": { + "name": "skill_hub_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "skill_hub_created_by_org_membership_id": { + "name": "skill_hub_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "skill_hub_id": { + "name": "skill_hub_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "skill": { + "name": "skill", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_org_membership_id": { + "name": "created_by_org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skill_text": { + "name": "skill_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shared": { + "name": "shared", + "type": "enum('org','public')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "skill_organization_id": { + "name": "skill_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "skill_created_by_org_membership_id": { + "name": "skill_created_by_org_membership_id", + "columns": [ + "created_by_org_membership_id" + ], + "isUnique": false + }, + "skill_shared": { + "name": "skill_shared", + "columns": [ + "shared" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "skill_id": { + "name": "skill_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team_member": { + "name": "team_member", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_membership_id": { + "name": "org_membership_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "team_member_team_id": { + "name": "team_member_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "team_member_org_membership_id": { + "name": "team_member_org_membership_id", + "columns": [ + "org_membership_id" + ], + "isUnique": false + }, + "team_member_team_org_membership": { + "name": "team_member_team_org_membership", + "columns": [ + "team_id", + "org_membership_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_member_id": { + "name": "team_member_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "team": { + "name": "team", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "team_organization_id": { + "name": "team_organization_id", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "team_organization_name": { + "name": "team_organization_name", + "columns": [ + "organization_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "team_id": { + "name": "team_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "audit_event": { + "name": "audit_event", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "audit_event_org_id": { + "name": "audit_event_org_id", + "columns": [ + "org_id" + ], + "isUnique": false + }, + "audit_event_worker_id": { + "name": "audit_event_worker_id", + "columns": [ + "worker_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "audit_event_id": { + "name": "audit_event_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "daytona_sandbox": { + "name": "daytona_sandbox", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_volume_id": { + "name": "workspace_volume_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_volume_id": { + "name": "data_volume_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signed_preview_url": { + "name": "signed_preview_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signed_preview_url_expires_at": { + "name": "signed_preview_url_expires_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "daytona_sandbox_worker_id": { + "name": "daytona_sandbox_worker_id", + "columns": [ + "worker_id" + ], + "isUnique": true + }, + "daytona_sandbox_sandbox_id": { + "name": "daytona_sandbox_sandbox_id", + "columns": [ + "sandbox_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daytona_sandbox_id": { + "name": "daytona_sandbox_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "worker_bundle": { + "name": "worker_bundle", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_url": { + "name": "storage_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "worker_bundle_worker_id": { + "name": "worker_bundle_worker_id", + "columns": [ + "worker_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "worker_bundle_id": { + "name": "worker_bundle_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "worker_instance": { + "name": "worker_instance", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('provisioning','healthy','failed','stopped')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "worker_instance_worker_id": { + "name": "worker_instance_worker_id", + "columns": [ + "worker_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "worker_instance_id": { + "name": "worker_instance_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "worker": { + "name": "worker", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination": { + "name": "destination", + "type": "enum('local','cloud')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('provisioning','healthy','failed','stopped')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image_version": { + "name": "image_version", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_path": { + "name": "workspace_path", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sandbox_backend": { + "name": "sandbox_backend", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "worker_org_id": { + "name": "worker_org_id", + "columns": [ + "org_id" + ], + "isUnique": false + }, + "worker_created_by_user_id": { + "name": "worker_created_by_user_id", + "columns": [ + "created_by_user_id" + ], + "isUnique": false + }, + "worker_status": { + "name": "worker_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "worker_last_heartbeat_at": { + "name": "worker_last_heartbeat_at", + "columns": [ + "last_heartbeat_at" + ], + "isUnique": false + }, + "worker_last_active_at": { + "name": "worker_last_active_at", + "columns": [ + "last_active_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "worker_id": { + "name": "worker_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "worker_token": { + "name": "worker_token", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worker_id": { + "name": "worker_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "enum('client','host','activity')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worker_token_worker_id": { + "name": "worker_token_worker_id", + "columns": [ + "worker_id" + ], + "isUnique": false + }, + "worker_token_token": { + "name": "worker_token_token", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "worker_token_id": { + "name": "worker_token_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "admin_allowlist": { + "name": "admin_allowlist", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)" + } + }, + "indexes": { + "admin_allowlist_email": { + "name": "admin_allowlist_email", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "admin_allowlist_id": { + "name": "admin_allowlist_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rate_limit": { + "name": "rate_limit", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_request": { + "name": "last_request", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rate_limit_key": { + "name": "rate_limit_key", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rate_limit_id": { + "name": "rate_limit_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/ee/packages/den-db/drizzle/meta/_journal.json b/ee/packages/den-db/drizzle/meta/_journal.json index ff99f0ca..b9eccbf4 100644 --- a/ee/packages/den-db/drizzle/meta/_journal.json +++ b/ee/packages/den-db/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1775170250887, "tag": "0007_organization_metadata_limits", "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1775261156284, + "tag": "0008_cynical_boomerang", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/ee/packages/den-db/src/columns.ts b/ee/packages/den-db/src/columns.ts index 5eb735d2..3edab745 100644 --- a/ee/packages/den-db/src/columns.ts +++ b/ee/packages/den-db/src/columns.ts @@ -1,36 +1,130 @@ -import { customType, timestamp, varchar } from "drizzle-orm/mysql-core"; +import * as crypto from "node:crypto" +import { customType, timestamp, varchar } from "drizzle-orm/mysql-core" import { - type DenTypeId, - type DenTypeIdName, - normalizeDenTypeId, -} from "@openwork-ee/utils/typeid"; -import { sql } from "drizzle-orm"; + type DenTypeId, + type DenTypeIdName, + normalizeDenTypeId, +} from "@openwork-ee/utils/typeid" +import { sql } from "drizzle-orm" -const INTERNAL_ID_LENGTH = 64; -const AUTH_EXTERNAL_ID_LENGTH = 36; +const INTERNAL_ID_LENGTH = 64 +const AUTH_EXTERNAL_ID_LENGTH = 36 +const ENCRYPTION_VERSION_PREFIX = "enc:v1:" +const ENCRYPTION_ALGORITHM = "aes-256-gcm" +const MIN_ENCRYPTION_KEY_LENGTH = 32 export const authExternalIdColumn = (columnName: string) => - varchar(columnName, { length: AUTH_EXTERNAL_ID_LENGTH }); + varchar(columnName, { length: AUTH_EXTERNAL_ID_LENGTH }) + +function getDatabaseEncryptionSecret() { + const explicit = process.env.DEN_DB_ENCRYPTION_KEY?.trim() + if (!explicit) { + throw new Error( + "DEN_DB_ENCRYPTION_KEY is required to use encrypted database columns", + ) + } + + if (explicit.length < MIN_ENCRYPTION_KEY_LENGTH) { + throw new Error( + `DEN_DB_ENCRYPTION_KEY must be at least ${MIN_ENCRYPTION_KEY_LENGTH} characters long`, + ) + } + + return explicit +} + +function getDatabaseEncryptionKey() { + return new Uint8Array( + crypto.createHash("sha256").update(getDatabaseEncryptionSecret()).digest(), + ) +} + +function encryptDatabaseValue(value: string) { + const ivBuffer = crypto.randomBytes(12) + const iv = new Uint8Array(ivBuffer) + const cipher = crypto.createCipheriv( + ENCRYPTION_ALGORITHM, + getDatabaseEncryptionKey(), + iv, + ) + let encrypted = cipher.update(value, "utf8", "base64") + encrypted += cipher.final("base64") + const authTag = cipher.getAuthTag().toString("base64") + const ivBase64 = ivBuffer.toString("base64") + return `${ENCRYPTION_VERSION_PREFIX}${ivBase64}.${authTag}.${encrypted}` +} + +function decryptDatabaseValue(value: string) { + if (!value.startsWith(ENCRYPTION_VERSION_PREFIX)) { + throw new Error("Encrypted value is missing a supported prefix") + } + + const [ivBase64, authTagBase64, encrypted] = value + .slice(ENCRYPTION_VERSION_PREFIX.length) + .split(".") + if (!ivBase64 || !authTagBase64 || !encrypted) { + throw new Error("Encrypted value is malformed") + } + + const iv = new Uint8Array(Buffer.from(ivBase64, "base64")) + const decipher = crypto.createDecipheriv( + ENCRYPTION_ALGORITHM, + getDatabaseEncryptionKey(), + iv, + ) + decipher.setAuthTag(new Uint8Array(Buffer.from(authTagBase64, "base64"))) + let decrypted = decipher.update(encrypted, "base64", "utf8") + decrypted += decipher.final("utf8") + return decrypted +} + +type EncryptedColumnOptions = { + serialize: (value: TData) => string + deserialize: (value: string) => TData +} + +export function encryptedColumn( + columnName: string, + options: EncryptedColumnOptions, +) { + return customType<{ data: TData; driverData: string }>({ + dataType() { + return "text" + }, + toDriver(value) { + return encryptDatabaseValue(options.serialize(value)) + }, + fromDriver(value) { + return options.deserialize(decryptDatabaseValue(value)) + }, + })(columnName) +} + +export const encryptedTextColumn = (columnName: string) => + encryptedColumn(columnName, { + serialize: (value) => value, + deserialize: (value) => value, + }) export const denTypeIdColumn = ( - name: TName, - columnName: string, + name: TName, + columnName: string, ) => - customType<{ data: DenTypeId; driverData: string }>({ - dataType() { - return `varchar(${INTERNAL_ID_LENGTH})`; - }, - toDriver(value) { - return normalizeDenTypeId(name, value); - }, - fromDriver(value) { - return normalizeDenTypeId(name, value); - }, - })(columnName); + customType<{ data: DenTypeId; driverData: string }>({ + dataType() { + return `varchar(${INTERNAL_ID_LENGTH})` + }, + toDriver(value) { + return normalizeDenTypeId(name, value) + }, + fromDriver(value) { + return normalizeDenTypeId(name, value) + }, + })(columnName) export const timestamps = { - created_at: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), - updated_at: timestamp("updated_at", { fsp: 3 }) - .notNull() - .default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`), -}; + created_at: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + updated_at: timestamp("updated_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`), +} diff --git a/ee/packages/den-db/src/schema/index.ts b/ee/packages/den-db/src/schema/index.ts index 0270b129..eff1c37a 100644 --- a/ee/packages/den-db/src/schema/index.ts +++ b/ee/packages/den-db/src/schema/index.ts @@ -1,5 +1,6 @@ export * from "./auth" export * from "./org" +export * from "./sharables/llm-providers" export * from "./sharables/skills" export * from "./teams" export * from "./workers" diff --git a/ee/packages/den-db/src/schema/sharables/llm-providers.ts b/ee/packages/den-db/src/schema/sharables/llm-providers.ts new file mode 100644 index 00000000..c004f400 --- /dev/null +++ b/ee/packages/den-db/src/schema/sharables/llm-providers.ts @@ -0,0 +1,138 @@ +import { relations, sql } from "drizzle-orm" +import { + index, + json, + mysqlEnum, + mysqlTable, + timestamp, + uniqueIndex, + varchar, +} from "drizzle-orm/mysql-core" +import { denTypeIdColumn, encryptedTextColumn } from "../../columns" +import { MemberTable, OrganizationTable } from "../org" +import { TeamTable } from "../teams" + +export const LlmProviderTable = mysqlTable( + "llm_provider", + { + id: denTypeIdColumn("llmProvider", "id").notNull().primaryKey(), + organizationId: denTypeIdColumn( + "organization", + "organization_id", + ).notNull(), + createdByOrgMembershipId: denTypeIdColumn( + "member", + "created_by_org_membership_id", + ).notNull(), + source: mysqlEnum("source", ["models_dev", "custom"]).notNull(), + providerId: varchar("provider_id", { length: 255 }).notNull(), + name: varchar("name", { length: 255 }).notNull(), + providerConfig: json("provider_config") + .$type>() + .notNull(), + apiKey: encryptedTextColumn("api_key"), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { fsp: 3 }) + .notNull() + .default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`), + }, + (table) => [ + index("llm_provider_organization_id").on(table.organizationId), + index("llm_provider_created_by_org_membership_id").on( + table.createdByOrgMembershipId, + ), + index("llm_provider_source").on(table.source), + index("llm_provider_provider_id").on(table.providerId), + ], +) + +export const LlmProviderModelTable = mysqlTable( + "llm_provider_model", + { + id: denTypeIdColumn("llmProviderModel", "id").notNull().primaryKey(), + llmProviderId: denTypeIdColumn("llmProvider", "llm_provider_id").notNull(), + modelId: varchar("model_id", { length: 255 }).notNull(), + name: varchar("name", { length: 255 }).notNull(), + modelConfig: json("model_config") + .$type>() + .notNull(), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + }, + (table) => [ + index("llm_provider_model_llm_provider_id").on(table.llmProviderId), + index("llm_provider_model_model_id").on(table.modelId), + uniqueIndex("llm_provider_model_provider_model").on( + table.llmProviderId, + table.modelId, + ), + ], +) + +export const LlmProviderAccessTable = mysqlTable( + "llm_provider_access", + { + id: denTypeIdColumn("llmProviderAccess", "id").notNull().primaryKey(), + llmProviderId: denTypeIdColumn("llmProvider", "llm_provider_id").notNull(), + orgMembershipId: denTypeIdColumn("member", "org_membership_id"), + teamId: denTypeIdColumn("team", "team_id"), + createdAt: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + }, + (table) => [ + index("llm_provider_access_llm_provider_id").on(table.llmProviderId), + index("llm_provider_access_org_membership_id").on(table.orgMembershipId), + index("llm_provider_access_team_id").on(table.teamId), + uniqueIndex("llm_provider_access_provider_org_membership").on( + table.llmProviderId, + table.orgMembershipId, + ), + uniqueIndex("llm_provider_access_provider_team").on( + table.llmProviderId, + table.teamId, + ), + ], +) + +export const llmProviderRelations = relations(LlmProviderTable, ({ many, one }) => ({ + organization: one(OrganizationTable, { + fields: [LlmProviderTable.organizationId], + references: [OrganizationTable.id], + }), + createdByOrgMembership: one(MemberTable, { + fields: [LlmProviderTable.createdByOrgMembershipId], + references: [MemberTable.id], + }), + models: many(LlmProviderModelTable), + accessLinks: many(LlmProviderAccessTable), +})) + +export const llmProviderModelRelations = relations( + LlmProviderModelTable, + ({ one }) => ({ + llmProvider: one(LlmProviderTable, { + fields: [LlmProviderModelTable.llmProviderId], + references: [LlmProviderTable.id], + }), + }), +) + +export const llmProviderAccessRelations = relations( + LlmProviderAccessTable, + ({ one }) => ({ + llmProvider: one(LlmProviderTable, { + fields: [LlmProviderAccessTable.llmProviderId], + references: [LlmProviderTable.id], + }), + orgMembership: one(MemberTable, { + fields: [LlmProviderAccessTable.orgMembershipId], + references: [MemberTable.id], + }), + team: one(TeamTable, { + fields: [LlmProviderAccessTable.teamId], + references: [TeamTable.id], + }), + }), +) + +export const llmProvider = LlmProviderTable +export const llmProviderModel = LlmProviderModelTable +export const llmProviderAccess = LlmProviderAccessTable diff --git a/ee/packages/utils/src/typeid.ts b/ee/packages/utils/src/typeid.ts index eece8e3c..8f36bc02 100644 --- a/ee/packages/utils/src/typeid.ts +++ b/ee/packages/utils/src/typeid.ts @@ -18,6 +18,9 @@ export const denTypeIdPrefixes = { skillHub: "shb", skillHubSkill: "shs", skillHubMember: "shm", + llmProvider: "lpr", + llmProviderModel: "lpm", + llmProviderAccess: "lpa", organizationRole: "orl", tempTemplateSharing: "tts", adminAllowlist: "aal", diff --git a/packaging/docker/den-dev-up.sh b/packaging/docker/den-dev-up.sh index 73b0aa9a..ff95c798 100755 --- a/packaging/docker/den-dev-up.sh +++ b/packaging/docker/den-dev-up.sh @@ -10,6 +10,12 @@ set -euo pipefail # - Cloud web app URL # - Den control plane demo/API URL # - Runtime env file path with ports + project name +# +# Notes: +# - This script auto-generates a Better Auth secret per run. +# - It also uses a premade dev-only DB encryption key unless you override +# DEN_DB_ENCRYPTION_KEY yourself. +# - Generate a replacement with: openssl rand -base64 128 ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" COMPOSE_FILE="$ROOT_DIR/packaging/docker/docker-compose.den-dev.yml" @@ -136,6 +142,7 @@ LAN_IPV4="$(detect_lan_ipv4 || true)" TAILSCALE_DNS_NAME="$(detect_tailscale_dns_name || true)" DEN_BETTER_AUTH_SECRET="${DEN_BETTER_AUTH_SECRET:-$(random_hex 32)}" +DEN_DB_ENCRYPTION_KEY="${DEN_DB_ENCRYPTION_KEY:-dev-den-db-encryption-key-please-change-1234567890}" DEN_BETTER_AUTH_URL="${DEN_BETTER_AUTH_URL:-http://$PUBLIC_HOST:$DEN_WEB_PORT}" DEN_PROVISIONER_MODE="${DEN_PROVISIONER_MODE:-stub}" DEN_WORKER_URL_TEMPLATE="${DEN_WORKER_URL_TEMPLATE:-https://workers.local/{workerId}}" @@ -202,6 +209,7 @@ DEN_WEB_PUBLIC_URL=http://$PUBLIC_HOST:$DEN_WEB_PORT DEN_WORKER_PROXY_PUBLIC_URL=http://$PUBLIC_HOST:$DEN_WORKER_PROXY_PORT DEN_MYSQL_URL=mysql://root:password@127.0.0.1:$DEN_MYSQL_PORT/openwork_den DEN_BETTER_AUTH_URL=$DEN_BETTER_AUTH_URL +DEN_DB_ENCRYPTION_KEY=$DEN_DB_ENCRYPTION_KEY COMPOSE_FILE=$COMPOSE_FILE DEN_WATCH_OTP_CODES=$DEN_WATCH_OTP_CODES EOF @@ -212,6 +220,7 @@ echo "- DEN_WEB_PORT=$DEN_WEB_PORT" >&2 echo "- DEN_WORKER_PROXY_PORT=$DEN_WORKER_PROXY_PORT" >&2 echo "- DEN_MYSQL_PORT=$DEN_MYSQL_PORT" >&2 echo "- DEN_BETTER_AUTH_URL=$DEN_BETTER_AUTH_URL" >&2 +echo "- DEN_DB_ENCRYPTION_KEY=[set] (dev default unless overridden)" >&2 echo "- DEN_PROVISIONER_MODE=$DEN_PROVISIONER_MODE" >&2 echo "- OTP verification codes will stream back to this terminal" >&2 if [ "$DEN_PROVISIONER_MODE" = "daytona" ]; then @@ -226,6 +235,7 @@ if ! DEN_API_PORT="$DEN_API_PORT" \ DEN_WORKER_PROXY_PORT="$DEN_WORKER_PROXY_PORT" \ DEN_MYSQL_PORT="$DEN_MYSQL_PORT" \ DEN_BETTER_AUTH_SECRET="$DEN_BETTER_AUTH_SECRET" \ + DEN_DB_ENCRYPTION_KEY="$DEN_DB_ENCRYPTION_KEY" \ DEN_BETTER_AUTH_URL="$DEN_BETTER_AUTH_URL" \ DEN_CORS_ORIGINS="$DEN_CORS_ORIGINS" \ DEN_BETTER_AUTH_TRUSTED_ORIGINS="$DEN_BETTER_AUTH_TRUSTED_ORIGINS" \ diff --git a/packaging/docker/docker-compose.den-dev.yml b/packaging/docker/docker-compose.den-dev.yml index 0f0c5f19..189e6527 100644 --- a/packaging/docker/docker-compose.den-dev.yml +++ b/packaging/docker/docker-compose.den-dev.yml @@ -11,6 +11,9 @@ # DEN_WORKER_PROXY_PORT — host port to map to the worker proxy :8789 # DEN_MYSQL_PORT — host port to map to MySQL :3306 # DEN_BETTER_AUTH_SECRET — Better Auth secret (auto-generated by den-dev-up.sh) +# DEN_DB_ENCRYPTION_KEY — dev-only DB encryption key for encrypted columns +# — defaults to a premade local key for Docker smoke tests +# — generate a replacement with: openssl rand -base64 128 # DEN_PUBLIC_HOST — browser-facing host/IP for LAN access (set by den-dev-up.sh) # DEN_BETTER_AUTH_URL — browser-facing auth origin (default: http://:) # DEN_BETTER_AUTH_TRUSTED_ORIGINS — Better Auth trusted origins (defaults to DEN_CORS_ORIGINS) @@ -70,6 +73,7 @@ services: OPENWORK_DEV_MODE: "1" DATABASE_URL: mysql://root:password@mysql:3306/openwork_den BETTER_AUTH_SECRET: ${DEN_BETTER_AUTH_SECRET:-dev-den-local-auth-secret-please-override-1234567890} + DEN_DB_ENCRYPTION_KEY: ${DEN_DB_ENCRYPTION_KEY:-dev-den-db-encryption-key-please-change-1234567890} BETTER_AUTH_URL: ${DEN_BETTER_AUTH_URL:-http://localhost:3005} DEN_BETTER_AUTH_TRUSTED_ORIGINS: ${DEN_BETTER_AUTH_TRUSTED_ORIGINS:-} PORT: "8788" diff --git a/pr/llm-hub/den-llm-provider-encrypted-flow.png b/pr/llm-hub/den-llm-provider-encrypted-flow.png new file mode 100644 index 00000000..3b6880f3 Binary files /dev/null and b/pr/llm-hub/den-llm-provider-encrypted-flow.png differ diff --git a/pr/llm-hub/llm-provider-detail-openai.png b/pr/llm-hub/llm-provider-detail-openai.png new file mode 100644 index 00000000..ae8bee1b Binary files /dev/null and b/pr/llm-hub/llm-provider-detail-openai.png differ diff --git a/pr/llm-hub/llm-provider-list.png b/pr/llm-hub/llm-provider-list.png new file mode 100644 index 00000000..caa54fc2 Binary files /dev/null and b/pr/llm-hub/llm-provider-list.png differ diff --git a/scripts/dev-local.mjs b/scripts/dev-local.mjs index c513f1b4..999f36b3 100644 --- a/scripts/dev-local.mjs +++ b/scripts/dev-local.mjs @@ -14,6 +14,9 @@ const apiPort = process.env.DEN_API_PORT?.trim() || process.env.DEN_CONTROLLER_P const workerProxyPort = process.env.DEN_WORKER_PROXY_PORT?.trim() || "8789" const webPort = process.env.DEN_WEB_PORT?.trim() || "3005" const databaseUrl = process.env.DATABASE_URL?.trim() || "mysql://root:password@127.0.0.1:3306/openwork_den" +const dbEncryptionKey = + process.env.DEN_DB_ENCRYPTION_KEY?.trim() || + "local-dev-db-encryption-key-please-change-1234567890" function detectWebOrigins() { const origins = new Set([ @@ -196,6 +199,7 @@ async function main() { ...process.env, OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE?.trim() || "1", DATABASE_URL: databaseUrl, + DEN_DB_ENCRYPTION_KEY: dbEncryptionKey, BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET?.trim() || "local-dev-secret-not-for-production-use!!", BETTER_AUTH_URL: process.env.BETTER_AUTH_URL?.trim() || `http://localhost:${webPort}`, DEN_BETTER_AUTH_TRUSTED_ORIGINS: process.env.DEN_BETTER_AUTH_TRUSTED_ORIGINS?.trim() || webOrigins, diff --git a/turbo.json b/turbo.json index 3debc014..8f8a4741 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,7 @@ "globalEnv": [ "OPENWORK_DEV_MODE", "DATABASE_URL", + "DEN_DB_ENCRYPTION_KEY", "BETTER_AUTH_SECRET", "BETTER_AUTH_URL", "DEN_BETTER_AUTH_TRUSTED_ORIGINS",