diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 6166fe8..3a2b288 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -117,6 +117,7 @@ import { ModelSelector } from "./components/ModelSelector"; import { NewAgentDialog } from "./components/NewAgentDialog"; import { PendingApprovalStub } from "./components/PendingApprovalStub"; import { PinDialog, validateAgentName } from "./components/PinDialog"; +import { ProviderSelector } from "./components/ProviderSelector"; // QuestionDialog removed - now using InlineQuestionApproval import { ReasoningMessage } from "./components/ReasoningMessageRich"; @@ -978,6 +979,7 @@ export default function App({ | "mcp-connect" | "help" | "hooks" + | "connect" | null; const [activeOverlay, setActiveOverlay] = useState(null); const [feedbackPrefill, setFeedbackPrefill] = useState(""); @@ -1053,6 +1055,40 @@ export default function App({ : null; const currentModelProvider = llmConfig?.provider_name ?? null; + // Billing tier for conditional UI (fetched once on mount) + const [billingTier, setBillingTier] = useState(null); + + // Fetch billing tier once on mount + useEffect(() => { + (async () => { + try { + const settings = settingsManager.getSettings(); + const baseURL = + process.env.LETTA_BASE_URL || + settings.env?.LETTA_BASE_URL || + "https://api.letta.com"; + const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY; + + const response = await fetch(`${baseURL}/v1/metadata/balance`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-Letta-Source": "letta-code", + }, + }); + + if (response.ok) { + const data = (await response.json()) as { billing_tier?: string }; + if (data.billing_tier) { + setBillingTier(data.billing_tier); + } + } + } catch { + // Silently ignore - billing tier is optional context + } + })(); + }, []); + // Token streaming preference (can be toggled at runtime) const [tokenStreamingEnabled, setTokenStreamingEnabled] = useState(tokenStreaming); @@ -4707,11 +4743,14 @@ export default function App({ return { submitted: true }; } - // Special handling for /connect command - OAuth connection - if (msg.trim().startsWith("/connect")) { - // Handle all /connect commands through the unified handler - // For codex: uses local ChatGPT OAuth server (no dialog needed) - // For zai: requires API key as argument + // Special handling for /connect command - opens provider selector + if (msg.trim() === "/connect") { + setActiveOverlay("connect"); + return { submitted: true }; + } + + // /connect codex - direct OAuth flow (kept for backwards compatibility) + if (msg.trim().startsWith("/connect codex")) { const { handleConnect } = await import("./commands/connect"); await handleConnect( { @@ -9094,6 +9133,34 @@ Plan file path: ${planFilePath}`; onCancel={closeOverlay} filterProvider={modelSelectorOptions.filterProvider} forceRefresh={modelSelectorOptions.forceRefresh} + billingTier={billingTier ?? undefined} + /> + )} + + {/* Provider Selector - for connecting BYOK providers */} + {activeOverlay === "connect" && ( + { + // Close selector and start OAuth flow + closeOverlay(); + const { handleConnect } = await import("./commands/connect"); + await handleConnect( + { + buffersRef, + refreshDerived, + setCommandRunning, + onCodexConnected: () => { + setModelSelectorOptions({ + filterProvider: "chatgpt-plus-pro", + forceRefresh: true, + }); + setActiveOverlay("model"); + }, + }, + "/connect codex", + ); + }} /> )} diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 0006b9b..416b73f 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -264,11 +264,11 @@ export const commands: Record = { // === Session management (order 40-49) === "/connect": { - desc: "Connect an existing account (/connect codex or /connect zai )", + desc: "Connect your LLM API keys (OpenAI, Anthropic, etc.)", order: 40, handler: () => { - // Handled specially in App.tsx - return "Initiating account connection..."; + // Handled specially in App.tsx - opens ProviderSelector + return "Opening provider connection..."; }, }, "/disconnect": { diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 20cad41..bfbbf5f 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -51,6 +51,7 @@ const InputFooter = memo(function InputFooter({ agentName, currentModel, isOpenAICodexProvider, + isByokProvider, isAutocompleteActive, }: { ctrlCPressed: boolean; @@ -62,6 +63,7 @@ const InputFooter = memo(function InputFooter({ agentName: string | null | undefined; currentModel: string | null | undefined; isOpenAICodexProvider: boolean; + isByokProvider: boolean; isAutocompleteActive: boolean; }) { // Hide footer when autocomplete is showing @@ -96,11 +98,12 @@ const InputFooter = memo(function InputFooter({ )} {agentName || "Unnamed"} - - {` [${currentModel ?? "unknown"}]`} + + {` [${currentModel ?? "unknown"}`} + {isByokProvider && ( + + )} + {"]"} @@ -903,6 +906,10 @@ export function Input({ isOpenAICodexProvider={ currentModelProvider === OPENAI_CODEX_PROVIDER_NAME } + isByokProvider={ + currentModelProvider?.startsWith("lc-") || + currentModelProvider === OPENAI_CODEX_PROVIDER_NAME + } isAutocompleteActive={isAutocompleteActive} /> diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx index 265ea67..09f4cb8 100644 --- a/src/cli/components/ModelSelector.tsx +++ b/src/cli/components/ModelSelector.tsx @@ -15,8 +15,18 @@ const SOLID_LINE = "─"; const VISIBLE_ITEMS = 8; -type ModelCategory = "supported" | "all"; -const MODEL_CATEGORIES: ModelCategory[] = ["supported", "all"]; +type ModelCategory = "supported" | "byok" | "byok-all" | "all"; + +// BYOK provider prefixes (ChatGPT OAuth + lc-* providers from /connect) +const BYOK_PROVIDER_PREFIXES = ["chatgpt-plus-pro/", "lc-"]; + +// Get tab order based on billing tier (free = BYOK first, paid = BYOK last) +function getModelCategories(billingTier?: string): ModelCategory[] { + const isFreeTier = billingTier?.toLowerCase() === "free"; + return isFreeTier + ? ["byok", "byok-all", "supported", "all"] + : ["supported", "all", "byok", "byok-all"]; +} type UiModel = { id: string; @@ -25,6 +35,7 @@ type UiModel = { description: string; isDefault?: boolean; isFeatured?: boolean; + free?: boolean; updateArgs?: Record; }; @@ -36,6 +47,8 @@ interface ModelSelectorProps { filterProvider?: string; /** Force refresh the models list on mount */ forceRefresh?: boolean; + /** User's billing tier - affects tab ordering (free = BYOK first) */ + billingTier?: string; } export function ModelSelector({ @@ -44,11 +57,20 @@ export function ModelSelector({ onCancel, filterProvider, forceRefresh: forceRefreshOnMount, + billingTier, }: ModelSelectorProps) { const terminalWidth = useTerminalWidth(); const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); const typedModels = models as UiModel[]; - const [category, setCategory] = useState("supported"); + + // Tab order depends on billing tier (free = BYOK first) + const modelCategories = useMemo( + () => getModelCategories(billingTier), + [billingTier], + ); + const defaultCategory = modelCategories[0] ?? "supported"; + + const [category, setCategory] = useState(defaultCategory); const [selectedIndex, setSelectedIndex] = useState(0); // undefined: not loaded yet (show spinner) @@ -117,6 +139,8 @@ export function ModelSelector({ // Supported models: models.json entries that are available // Featured models first, then non-featured, preserving JSON order within each group // If filterProvider is set, only show models from that provider + // For free tier, free models go first + const isFreeTier = billingTier?.toLowerCase() === "free"; const supportedModels = useMemo(() => { if (availableHandles === undefined) return []; let available = @@ -139,26 +163,156 @@ export function ModelSelector({ m.handle.toLowerCase().includes(query), ); } + + // For free tier, put free models first, then others with standard ordering + if (isFreeTier) { + const freeModels = available.filter((m) => m.free); + const paidModels = available.filter((m) => !m.free); + const featured = paidModels.filter((m) => m.isFeatured); + const nonFeatured = paidModels.filter((m) => !m.isFeatured); + return [...freeModels, ...featured, ...nonFeatured]; + } + const featured = available.filter((m) => m.isFeatured); const nonFeatured = available.filter((m) => !m.isFeatured); return [...featured, ...nonFeatured]; - }, [typedModels, availableHandles, filterProvider, searchQuery]); + }, [typedModels, availableHandles, filterProvider, searchQuery, isFreeTier]); - // All other models: API handles not in models.json + // BYOK models: models from chatgpt-plus-pro or lc-* providers + const isByokHandle = useCallback( + (handle: string) => + BYOK_PROVIDER_PREFIXES.some((prefix) => handle.startsWith(prefix)), + [], + ); + + // All other models: API handles not in models.json and not BYOK const otherModelHandles = useMemo(() => { const filtered = allApiHandles.filter( - (handle) => !staticModelHandles.has(handle), + (handle) => !staticModelHandles.has(handle) && !isByokHandle(handle), ); if (!searchQuery) return filtered; const query = searchQuery.toLowerCase(); return filtered.filter((handle) => handle.toLowerCase().includes(query)); - }, [allApiHandles, staticModelHandles, searchQuery]); + }, [allApiHandles, staticModelHandles, searchQuery, isByokHandle]); + + // Provider name mappings for BYOK -> models.json lookup + // Maps BYOK provider prefix to models.json provider prefix + const BYOK_PROVIDER_ALIASES: Record = { + "lc-anthropic": "anthropic", + "lc-openai": "openai", + "lc-zai": "zai", + "lc-gemini": "google_ai", + "chatgpt-plus-pro": "chatgpt-plus-pro", // No change needed + }; + + // Convert BYOK handle to base provider handle for models.json lookup + // e.g., "lc-anthropic/claude-3-5-haiku" -> "anthropic/claude-3-5-haiku" + // e.g., "lc-gemini/gemini-2.0-flash" -> "google_ai/gemini-2.0-flash" + const toBaseHandle = useCallback((handle: string): string => { + const slashIndex = handle.indexOf("/"); + if (slashIndex === -1) return handle; + + const provider = handle.slice(0, slashIndex); + const model = handle.slice(slashIndex + 1); + const baseProvider = BYOK_PROVIDER_ALIASES[provider]; + + if (baseProvider) { + return `${baseProvider}/${model}`; + } + return handle; + }, []); + + // BYOK (recommended): BYOK API handles that have matching entries in models.json + const byokModels = useMemo(() => { + if (availableHandles === undefined) return []; + + // Get all BYOK handles from API + const byokHandles = allApiHandles.filter(isByokHandle); + + // Find models.json entries that match (using alias for lc-* providers) + const matched: UiModel[] = []; + for (const handle of byokHandles) { + const baseHandle = toBaseHandle(handle); + const staticModel = typedModels.find((m) => m.handle === baseHandle); + if (staticModel) { + // Use models.json data but with the BYOK handle as the ID + matched.push({ + ...staticModel, + id: handle, + handle: handle, + }); + } + } + + // Apply search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return matched.filter( + (m) => + m.label.toLowerCase().includes(query) || + m.description.toLowerCase().includes(query) || + m.handle.toLowerCase().includes(query), + ); + } + + return matched; + }, [ + availableHandles, + allApiHandles, + typedModels, + searchQuery, + isByokHandle, + toBaseHandle, + ]); + + // BYOK (all): BYOK handles from API that don't have matching models.json entries + const byokAllModels = useMemo(() => { + if (availableHandles === undefined) return []; + + // Get BYOK handles that don't have a match in models.json (using alias) + const byokHandles = allApiHandles.filter((handle) => { + if (!isByokHandle(handle)) return false; + const baseHandle = toBaseHandle(handle); + // Exclude if there's a matching entry in models.json + return !staticModelHandles.has(baseHandle); + }); + + // Apply search filter + let filtered = byokHandles; + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filtered = byokHandles.filter((handle) => + handle.toLowerCase().includes(query), + ); + } + + return filtered; + }, [ + availableHandles, + allApiHandles, + staticModelHandles, + searchQuery, + isByokHandle, + toBaseHandle, + ]); // Get the list for current category const currentList: UiModel[] = useMemo(() => { if (category === "supported") { return supportedModels; } + if (category === "byok") { + return byokModels; + } + if (category === "byok-all") { + // Convert raw handles to UiModel + return byokAllModels.map((handle) => ({ + id: handle, + handle, + label: handle, + description: "", + })); + } // For "all" category, convert handles to simple UiModel objects return otherModelHandles.map((handle) => ({ id: handle, @@ -166,7 +320,7 @@ export function ModelSelector({ label: handle, description: "", })); - }, [category, supportedModels, otherModelHandles]); + }, [category, supportedModels, byokModels, byokAllModels, otherModelHandles]); // Show 1 fewer item because Search line takes space const visibleCount = VISIBLE_ITEMS - 1; @@ -191,14 +345,14 @@ export function ModelSelector({ // Reset selection when category changes const cycleCategory = useCallback(() => { setCategory((current) => { - const idx = MODEL_CATEGORIES.indexOf(current); - return MODEL_CATEGORIES[ - (idx + 1) % MODEL_CATEGORIES.length + const idx = modelCategories.indexOf(current); + return modelCategories[ + (idx + 1) % modelCategories.length ] as ModelCategory; }); setSelectedIndex(0); setSearchQuery(""); - }, []); + }, [modelCategories]); // Set initial selection to current model on mount const initializedRef = useRef(false); @@ -253,9 +407,9 @@ export function ModelSelector({ if (key.leftArrow) { // Cycle backwards through categories setCategory((current) => { - const idx = MODEL_CATEGORIES.indexOf(current); - return MODEL_CATEGORIES[ - idx === 0 ? MODEL_CATEGORIES.length - 1 : idx - 1 + const idx = modelCategories.indexOf(current); + return modelCategories[ + idx === 0 ? modelCategories.length - 1 : idx - 1 ] as ModelCategory; }); setSelectedIndex(0); @@ -272,7 +426,23 @@ export function ModelSelector({ return; } - // Disable other inputs while loading + // Capture text input for search (allow typing even with 0 results) + // Exclude special keys like Enter, arrows, etc. + if ( + input && + input.length === 1 && + !key.ctrl && + !key.meta && + !key.return && + !key.upArrow && + !key.downArrow + ) { + setSearchQuery((prev) => prev + input); + setSelectedIndex(0); + return; + } + + // Disable navigation/selection while loading or no results if (isLoading || refreshing || currentList.length === 0) { return; } @@ -286,10 +456,6 @@ export function ModelSelector({ if (selectedModel) { onSelect(selectedModel.id); } - } else if (input && input.length === 1) { - // Capture text input for search - setSearchQuery((prev) => prev + input); - setSelectedIndex(0); } }, // Keep active so ESC and 'r' work while loading. @@ -297,14 +463,34 @@ export function ModelSelector({ ); const getCategoryLabel = (cat: ModelCategory) => { - if (cat === "supported") return `Recommended (${supportedModels.length})`; - return `All Available (${otherModelHandles.length})`; + if (cat === "supported") return `Letta API [${supportedModels.length}]`; + if (cat === "byok") return `BYOK [${byokModels.length}]`; + if (cat === "byok-all") return `BYOK (all) [${byokAllModels.length}]`; + return `Letta API (all) [${otherModelHandles.length}]`; + }; + + const getCategoryDescription = (cat: ModelCategory) => { + if (cat === "supported") { + return isFreeTier + ? "Upgrade your account to access more models" + : "Recommended models on the Letta API"; + } + if (cat === "byok") + return "Recommended models via your API keys (use /connect to add more)"; + if (cat === "byok-all") + return "All models via your API keys (use /connect to add more)"; + if (cat === "all") { + return isFreeTier + ? "Upgrade your account to access more models" + : "All models on the Letta API"; + } + return "All models on the Letta API"; }; // Render tab bar (matches AgentSelector style) const renderTabBar = () => ( - {MODEL_CATEGORIES.map((cat) => { + {modelCategories.map((cat) => { const isActive = cat === category; return ( {renderTabBar()} - Search: {searchQuery || "(type to filter)"} + {getCategoryDescription(category)} + + Search: + {searchQuery ? ( + {searchQuery} + ) : ( + (type to filter) + )} + )} @@ -380,6 +574,11 @@ export function ModelSelector({ const actualIndex = startIndex + index; const isSelected = actualIndex === selectedIndex; const isCurrent = model.id === currentModelId; + // Show lock for non-free models when on free tier (only for Letta API tabs) + const showLock = + isFreeTier && + !model.free && + (category === "supported" || category === "all"); return ( @@ -388,6 +587,7 @@ export function ModelSelector({ > {isSelected ? "> " : " "} + {showLock && 🔒 } void; + /** Called when ChatGPT/Codex OAuth flow should start */ + onStartOAuth?: () => void; +} + +export function ProviderSelector({ + onCancel, + onStartOAuth, +}: ProviderSelectorProps) { + const terminalWidth = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(terminalWidth, 10)); + + // State + const [selectedIndex, setSelectedIndex] = useState(0); + const [connectedProviders, setConnectedProviders] = useState< + Map + >(new Map()); + const [isLoading, setIsLoading] = useState(true); + const [viewState, setViewState] = useState({ type: "list" }); + const [apiKeyInput, setApiKeyInput] = useState(""); + const [validationState, setValidationState] = + useState("idle"); + const [validationError, setValidationError] = useState(null); + const [optionIndex, setOptionIndex] = useState(0); + + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // Load connected providers on mount + useEffect(() => { + (async () => { + try { + const providers = await getConnectedProviders(); + if (mountedRef.current) { + setConnectedProviders(providers); + setIsLoading(false); + } + } catch { + if (mountedRef.current) { + setIsLoading(false); + } + } + })(); + }, []); + + // Check if a provider is connected + const isConnected = useCallback( + (provider: ByokProvider) => { + return connectedProviders.has(provider.providerName); + }, + [connectedProviders], + ); + + // Get provider ID if connected + const getProviderId = useCallback( + (provider: ByokProvider): string | undefined => { + return connectedProviders.get(provider.providerName)?.id; + }, + [connectedProviders], + ); + + // Handle selecting a provider from the list + const handleSelectProvider = useCallback( + (provider: ByokProvider) => { + if ("isOAuth" in provider && provider.isOAuth) { + // OAuth provider - trigger OAuth flow + if (onStartOAuth) { + onStartOAuth(); + } + return; + } + + const connected = isConnected(provider); + if (connected) { + // Show options for connected provider + const providerId = getProviderId(provider); + if (providerId) { + setViewState({ type: "options", provider, providerId }); + setOptionIndex(0); + } + } else { + // Show API key input for new provider + setViewState({ type: "input", provider }); + setApiKeyInput(""); + setValidationState("idle"); + setValidationError(null); + } + }, + [isConnected, getProviderId, onStartOAuth], + ); + + // Handle API key validation and saving + const handleValidateAndSave = useCallback(async () => { + if (viewState.type !== "input") return; + if (!apiKeyInput.trim()) return; + + const { provider } = viewState; + + // If already validated, save + if (validationState === "valid") { + try { + await createOrUpdateProvider( + provider.providerType, + provider.providerName, + apiKeyInput.trim(), + ); + // Refresh connected providers + const providers = await getConnectedProviders(); + if (mountedRef.current) { + setConnectedProviders(providers); + setViewState({ type: "list" }); + setApiKeyInput(""); + setValidationState("idle"); + } + } catch (err) { + if (mountedRef.current) { + setValidationError( + err instanceof Error ? err.message : "Failed to save", + ); + setValidationState("invalid"); + } + } + return; + } + + // Validate the key + setValidationState("validating"); + setValidationError(null); + + try { + await checkProviderApiKey(provider.providerType, apiKeyInput.trim()); + if (mountedRef.current) { + setValidationState("valid"); + } + } catch (err) { + if (mountedRef.current) { + setValidationState("invalid"); + setValidationError( + err instanceof Error ? err.message : "Invalid API key", + ); + } + } + }, [viewState, apiKeyInput, validationState]); + + // Handle disconnect + const handleDisconnect = useCallback(async () => { + if (viewState.type !== "options") return; + + const { provider } = viewState; + try { + await removeProviderByName(provider.providerName); + // Refresh connected providers + const providers = await getConnectedProviders(); + if (mountedRef.current) { + setConnectedProviders(providers); + setViewState({ type: "list" }); + } + } catch { + // Silently fail, stay on options view + } + }, [viewState]); + + // Handle update key option + const handleUpdateKey = useCallback(() => { + if (viewState.type !== "options") return; + const { provider } = viewState; + setViewState({ type: "input", provider }); + setApiKeyInput(""); + setValidationState("idle"); + setValidationError(null); + }, [viewState]); + + useInput((input, key) => { + // CTRL-C: immediately cancel + if (key.ctrl && input === "c") { + onCancel(); + return; + } + + // Handle based on view state + if (viewState.type === "list") { + if (isLoading) return; + + if (key.escape) { + onCancel(); + } else if (key.upArrow) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setSelectedIndex((prev) => + Math.min(BYOK_PROVIDERS.length - 1, prev + 1), + ); + } else if (key.return) { + const provider = BYOK_PROVIDERS[selectedIndex]; + if (provider) { + handleSelectProvider(provider); + } + } + } else if (viewState.type === "input") { + if (key.escape) { + // Back to list + setViewState({ type: "list" }); + setApiKeyInput(""); + setValidationState("idle"); + setValidationError(null); + } else if (key.return) { + handleValidateAndSave(); + } else if (key.backspace || key.delete) { + setApiKeyInput((prev) => prev.slice(0, -1)); + // Reset validation if key changed + if (validationState !== "idle") { + setValidationState("idle"); + setValidationError(null); + } + } else if (input && !key.ctrl && !key.meta) { + setApiKeyInput((prev) => prev + input); + // Reset validation if key changed + if (validationState !== "idle") { + setValidationState("idle"); + setValidationError(null); + } + } + } else if (viewState.type === "options") { + const options = ["Update API key", "Disconnect", "Back"]; + if (key.escape) { + setViewState({ type: "list" }); + } else if (key.upArrow) { + setOptionIndex((prev) => Math.max(0, prev - 1)); + } else if (key.downArrow) { + setOptionIndex((prev) => Math.min(options.length - 1, prev + 1)); + } else if (key.return) { + if (optionIndex === 0) { + handleUpdateKey(); + } else if (optionIndex === 1) { + handleDisconnect(); + } else { + setViewState({ type: "list" }); + } + } + } + }); + + // Mask API key for display + const maskApiKey = (key: string): string => { + if (key.length <= 8) return "*".repeat(key.length); + return key.slice(0, 4) + "*".repeat(Math.min(key.length - 4, 20)); + }; + + // Render list view + const renderListView = () => ( + <> + + + Connect your LLM API keys + + Change models with /model after connecting + + + {isLoading ? ( + + {" "}Loading providers... + + ) : ( + + {BYOK_PROVIDERS.map((provider, index) => { + const isSelected = index === selectedIndex; + const connected = isConnected(provider); + + return ( + + + {isSelected ? "> " : " "} + + + [{connected ? "✓" : " "}] + + + + {provider.displayName} + + + {" · "} + {connected ? ( + Connected + ) : ( + provider.description + )} + + + ); + })} + + )} + + {!isLoading && ( + + {" "}Enter select · ↑↓ navigate · Esc cancel + + )} + + ); + + // Render input view + const renderInputView = () => { + if (viewState.type !== "input") return null; + const { provider } = viewState; + + const statusText = + validationState === "validating" + ? " (validating...)" + : validationState === "valid" + ? " (key validated!)" + : validationState === "invalid" + ? ` (invalid key${validationError ? `: ${validationError}` : ""})` + : ""; + + const statusColor = + validationState === "valid" + ? "green" + : validationState === "invalid" + ? "red" + : undefined; + + const footerText = + validationState === "valid" + ? "Enter to save · Esc cancel" + : "Enter to validate · Esc cancel"; + + return ( + <> + + + {" "}Connect your {provider.displayName} key: + + + + + {"> "} + {apiKeyInput ? maskApiKey(apiKeyInput) : "(enter key)"} + + {statusText} + + + + + + {" "} + {footerText} + + + + ); + }; + + // Render options view (for connected providers) + const renderOptionsView = () => { + if (viewState.type !== "options") return null; + const { provider } = viewState; + const options = ["Update API key", "Disconnect", "Back"]; + + return ( + <> + + + {" "} + [✓] + + {provider.displayName} + · + Connected + + + + + {options.map((option, index) => { + const isSelected = index === optionIndex; + return ( + + + {isSelected ? "> " : " "} + + + {option} + + + ); + })} + + + + {" "}Enter select · ↑↓ navigate · Esc back + + + ); + }; + + return ( + + {/* Command header */} + {"> /connect"} + {solidLine} + + + + {viewState.type === "list" && renderListView()} + {viewState.type === "input" && renderInputView()} + {viewState.type === "options" && renderOptionsView()} + + ); +} diff --git a/src/models.json b/src/models.json index 5e0f125..69973b5 100644 --- a/src/models.json +++ b/src/models.json @@ -503,6 +503,7 @@ "label": "GLM-4.7", "description": "The best open weights coding model", "isFeatured": true, + "free": true, "updateArgs": { "context_window": 200000 } diff --git a/src/providers/byok-providers.ts b/src/providers/byok-providers.ts new file mode 100644 index 0000000..5f2c898 --- /dev/null +++ b/src/providers/byok-providers.ts @@ -0,0 +1,246 @@ +/** + * BYOK (Bring Your Own Key) Provider Service + * Unified module for managing custom LLM provider connections + */ + +import { LETTA_CLOUD_API_URL } from "../auth/oauth"; +import { settingsManager } from "../settings-manager"; + +// Provider configuration for the /connect UI +export const BYOK_PROVIDERS = [ + { + id: "codex", + displayName: "ChatGPT / Codex plan", + description: "Connect your ChatGPT coding plan", + providerType: "chatgpt_oauth", + providerName: "chatgpt-plus-pro", + isOAuth: true, + }, + { + id: "anthropic", + displayName: "Claude API", + description: "Connect an Anthropic API key", + providerType: "anthropic", + providerName: "lc-anthropic", + }, + { + id: "openai", + displayName: "OpenAI API", + description: "Connect an OpenAI API key", + providerType: "openai", + providerName: "lc-openai", + }, + { + id: "zai", + displayName: "zAI API", + description: "Connect a zAI key or coding plan", + providerType: "zai", + providerName: "lc-zai", + }, + { + id: "gemini", + displayName: "Gemini API", + description: "Connect a Google Gemini API key", + providerType: "google_ai", + providerName: "lc-gemini", + }, +] as const; + +export type ByokProviderId = (typeof BYOK_PROVIDERS)[number]["id"]; +export type ByokProvider = (typeof BYOK_PROVIDERS)[number]; + +// Response type from the providers API +export interface ProviderResponse { + id: string; + name: string; + provider_type: string; + api_key?: string; + base_url?: string; +} + +/** + * Get the Letta API base URL and auth token + */ +async function getLettaConfig(): Promise<{ baseUrl: string; apiKey: string }> { + const settings = await settingsManager.getSettingsWithSecureTokens(); + const baseUrl = + process.env.LETTA_BASE_URL || + settings.env?.LETTA_BASE_URL || + LETTA_CLOUD_API_URL; + const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY || ""; + return { baseUrl, apiKey }; +} + +/** + * Make a request to the Letta providers API + */ +async function providersRequest( + method: "GET" | "POST" | "PATCH" | "DELETE", + path: string, + body?: Record, +): Promise { + const { baseUrl, apiKey } = await getLettaConfig(); + const url = `${baseUrl}${path}`; + + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-Letta-Source": "letta-code", + }, + ...(body && { body: JSON.stringify(body) }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Provider API error (${response.status}): ${errorText}`); + } + + // Handle empty responses (e.g., DELETE) + const text = await response.text(); + if (!text) { + return {} as T; + } + return JSON.parse(text) as T; +} + +/** + * List all BYOK providers for the current user + */ +export async function listProviders(): Promise { + try { + const response = await providersRequest( + "GET", + "/v1/providers", + ); + return response; + } catch { + return []; + } +} + +/** + * Get a map of connected providers by name + */ +export async function getConnectedProviders(): Promise< + Map +> { + const providers = await listProviders(); + const map = new Map(); + for (const provider of providers) { + map.set(provider.name, provider); + } + return map; +} + +/** + * Check if a specific BYOK provider is connected + */ +export async function isProviderConnected( + providerName: string, +): Promise { + const providers = await listProviders(); + return providers.some((p) => p.name === providerName); +} + +/** + * Get a provider by name + */ +export async function getProviderByName( + providerName: string, +): Promise { + const providers = await listProviders(); + return providers.find((p) => p.name === providerName) || null; +} + +/** + * Validate an API key with the provider's check endpoint + * Returns true if valid, throws error if invalid + */ +export async function checkProviderApiKey( + providerType: string, + apiKey: string, +): Promise { + await providersRequest<{ message: string }>("POST", "/v1/providers/check", { + provider_type: providerType, + api_key: apiKey, + }); +} + +/** + * Create a new BYOK provider + */ +export async function createProvider( + providerType: string, + providerName: string, + apiKey: string, +): Promise { + return providersRequest("POST", "/v1/providers", { + name: providerName, + provider_type: providerType, + api_key: apiKey, + }); +} + +/** + * Update an existing provider's API key + */ +export async function updateProvider( + providerId: string, + apiKey: string, +): Promise { + return providersRequest( + "PATCH", + `/v1/providers/${providerId}`, + { + api_key: apiKey, + }, + ); +} + +/** + * Delete a provider by ID + */ +export async function deleteProvider(providerId: string): Promise { + await providersRequest("DELETE", `/v1/providers/${providerId}`); +} + +/** + * Create or update a BYOK provider + * If provider exists, updates the API key; otherwise creates new + */ +export async function createOrUpdateProvider( + providerType: string, + providerName: string, + apiKey: string, +): Promise { + const existing = await getProviderByName(providerName); + + if (existing) { + return updateProvider(existing.id, apiKey); + } + + return createProvider(providerType, providerName, apiKey); +} + +/** + * Remove a provider by name + */ +export async function removeProviderByName( + providerName: string, +): Promise { + const existing = await getProviderByName(providerName); + if (existing) { + await deleteProvider(existing.id); + } +} + +/** + * Get provider config by ID + */ +export function getProviderConfig( + id: ByokProviderId, +): ByokProvider | undefined { + return BYOK_PROVIDERS.find((p) => p.id === id); +}