diff --git a/frontend/src/components/apikeys/ProxyClientGuides.tsx b/frontend/src/components/apikeys/ProxyClientGuides.tsx new file mode 100644 index 00000000..a9fb3339 --- /dev/null +++ b/frontend/src/components/apikeys/ProxyClientGuides.tsx @@ -0,0 +1,302 @@ +import { Link } from "@tanstack/react-router"; +import { type ReactNode, useState } from "react"; +import { + AlertCircle, + Check, + Copy, + KeyRound, + Play, + ShieldCheck, + TerminalSquare +} from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + buildOpenCodeProviderFields, + buildCurlProxyExample, + buildPythonProxyExample, + getModelDisplayName +} from "@/services/proxyModels"; +import type { OpenSecretModel } from "@/state/LocalStateContextDef"; + +type ProxyClientGuidesProps = { + baseUrl: string; + isRunning: boolean; + hasApiKeys: boolean; + models: OpenSecretModel[]; + selectedModelId: string; + onSelectModel: (modelId: string) => void; +}; + +function CopyButton({ value, label }: { value: string; label: string }) { + const [copyState, setCopyState] = useState<"idle" | "copied" | "error">("idle"); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopyState("copied"); + setTimeout(() => setCopyState("idle"), 2000); + } catch (error) { + console.error(`Failed to copy ${label}:`, error); + setCopyState("error"); + } + }; + + return ( + + ); +} + +function SetupField({ + label, + value, + copyable = true +}: { + label: string; + value: string; + copyable?: boolean; +}) { + return ( +
+

+ {label} +

+
+ {value} + {copyable && } +
+
+ ); +} + +function CodeExample({ label, code }: { label: string; code: string }) { + return ( +
+
+ {label} + +
+
+        {code}
+      
+
+ ); +} + +function Step({ number, children }: { number: number; children: ReactNode }) { + return ( +
  • + + {children} +
  • + ); +} + +export function ProxyClientGuides({ + baseUrl, + isRunning, + hasApiKeys, + models, + selectedModelId, + onSelectModel +}: ProxyClientGuidesProps) { + const selectedModel = models.find((model) => model.id === selectedModelId); + const exampleModelId = selectedModel?.id ?? "SELECT_A_MODEL"; + const openCodeFields = buildOpenCodeProviderFields(baseUrl, selectedModel); + + return ( +
    + {!isRunning && ( + + + + Start the proxy above before connecting a client. You can prepare the remaining setup + now. + + + )} + +
    +
    + + +
    + +
    + + + + OpenCode + cURL + Python + + + +
      + + In Maple, create a dedicated API key above and copy it. The full key is shown only + once. Then return to Local Proxy. + + + In OpenCode, open Settings → Providers → Custom Provider → Connect. + + Enter the values below, then connect and select the model. +
    + + {hasApiKeys && ( +

    + Existing keys cannot be revealed again. If you did not save one, create a new key for + OpenCode. +

    + )} + +
    + + + + + + + +
    + + + + +

    + Use a real Maple key—never a placeholder. An incoming bearer key overrides the key + saved by Maple Desktop. +

    +

    + When replacing an old key, disconnect and reconnect the provider. OpenCode stores + credentials separately from project configuration. +

    +
    +
    +
    + + +

    + Set MAPLE_API_KEY to a real key created in Maple, then run this request. +

    + {selectedModel ? ( + + ) : ( +
    + Load and select a current model before copying this example. +
    + )} +
    + + +

    + Install the OpenAI Python package and set MAPLE_API_KEY to a real Maple + key. +

    + {selectedModel ? ( + + ) : ( +
    + Load and select a current model before copying this example. +
    + )} +
    +
    + + +
    + +

    + A 401 usually means an invalid or stale key; a 400 usually means an old model ID. A 5xx + can be a temporary model-provider outage even when this setup is correct. +

    +
    +
    +
    + ); +} diff --git a/frontend/src/components/apikeys/ProxyConfigSection.tsx b/frontend/src/components/apikeys/ProxyConfigSection.tsx index f602ea2b..ca2192dc 100644 --- a/frontend/src/components/apikeys/ProxyConfigSection.tsx +++ b/frontend/src/components/apikeys/ProxyConfigSection.tsx @@ -1,20 +1,49 @@ -import { useState, useEffect } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { + AlertCircle, + Check, + CheckCircle, + Copy, + Loader2, + Play, + Server, + ShieldCheck, + Square +} from "lucide-react"; +import { SettingsSection } from "@/components/settings/SettingsPage"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Play, Square, Loader2, AlertCircle, CheckCircle, Server, Copy, Check } from "lucide-react"; -import { proxyService, ProxyConfig, ProxyStatus } from "@/services/proxyService"; +import { Switch } from "@/components/ui/switch"; +import { DEFAULT_AGENT_MODEL } from "@/services/agentModels"; +import { proxyService, type ProxyConfig, type ProxyStatus } from "@/services/proxyService"; +import { getProxyBaseUrl, isCodingAgentModel } from "@/services/proxyModels"; +import type { OpenSecretModel } from "@/state/LocalStateContextDef"; import { isTauriDesktop } from "@/utils/platform"; -import { QUICK_MODEL_ALIAS } from "@/utils/utils"; +import { ProxyClientGuides } from "./ProxyClientGuides"; +import { ProxyModelList } from "./ProxyModelList"; interface ProxyConfigSectionProps { apiKeys: Array<{ name: string; created_at: string }>; onRequestNewApiKey: (name: string) => Promise; + models: OpenSecretModel[]; + isModelsLoading: boolean; + isModelsError: boolean; +} + +function isLoopbackHost(host: string): boolean { + return host.trim() === "127.0.0.1"; } -export function ProxyConfigSection({ apiKeys, onRequestNewApiKey }: ProxyConfigSectionProps) { +export function ProxyConfigSection({ + apiKeys, + onRequestNewApiKey, + models, + isModelsLoading, + isModelsError +}: ProxyConfigSectionProps) { const [proxyStatus, setProxyStatus] = useState(null); const [config, setConfig] = useState({ host: "127.0.0.1", @@ -27,14 +56,22 @@ export function ProxyConfigSection({ apiKeys, onRequestNewApiKey }: ProxyConfigS const [isLoading, setIsLoading] = useState(false); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [copied, setCopied] = useState(false); + const [selectedModelId, setSelectedModelId] = useState(""); const isTauriDesktopPlatform = isTauriDesktop(); + const guideModels = useMemo(() => models.filter(isCodingAgentModel), [models]); + useEffect(() => { - if (!isTauriDesktopPlatform) return; + if (guideModels.length === 0) { + setSelectedModelId(""); + return; + } - // Load saved config and status on mount - loadProxyState(); - }, [isTauriDesktopPlatform]); + setSelectedModelId((current) => { + if (guideModels.some((model) => model.id === current)) return current; + return guideModels.find((model) => model.id === DEFAULT_AGENT_MODEL)?.id ?? guideModels[0].id; + }); + }, [guideModels]); const loadProxyState = async () => { try { @@ -50,70 +87,62 @@ export function ProxyConfigSection({ apiKeys, onRequestNewApiKey }: ProxyConfigS } }; + useEffect(() => { + if (!isTauriDesktopPlatform) return; + void loadProxyState(); + }, [isTauriDesktopPlatform]); + const handleStartProxy = async () => { setIsLoading(true); + setMessage(null); try { - // If no API key is set, auto-generate one let apiKey = config.api_key; if (!apiKey) { const date = new Date().toISOString().split("T")[0].replace(/-/g, ""); const keyName = `maple-desktop-${date}`; - - // Check if a key with this name already exists - const existingKey = apiKeys.find((k) => k.name === keyName); + const existingKey = apiKeys.find((key) => key.name === keyName); if (existingKey) { - // Use the existing key (we don't have access to the actual key value) setMessage({ type: "error", text: "Please select an existing API key or create a new one" }); - setIsLoading(false); return; } - // Request a new API key try { apiKey = await onRequestNewApiKey(keyName); - setConfig((prev) => ({ ...prev, api_key: apiKey })); + setConfig((previous) => ({ ...previous, api_key: apiKey })); } catch { setMessage({ type: "error", text: "Failed to create API key. Please create an API key manually first" }); - setIsLoading(false); return; } } - // Validate port range (1-65535) const port = Number(config.port); if (!Number.isInteger(port) || port < 1 || port > 65535) { setMessage({ type: "error", text: `Invalid port: ${config.port}. Port must be between 1 and 65535.` }); - setIsLoading(false); return; } - // Get the backend URL from environment const backendUrl = import.meta.env.VITE_OPEN_SECRET_API_URL || "https://enclave.trymaple.ai"; - const updatedConfig = { ...config, api_key: apiKey, enabled: true, backend_url: backendUrl, - auto_start: config.auto_start // Preserve auto_start setting + auto_start: config.auto_start }; - // The user is explicitly taking control of the shared proxy. The service - // detaches the active Agent association only after the native start - // succeeds, while retaining its exact key name for later revocation. + const status = await proxyService.startManualProxy(updatedConfig); setProxyStatus(status); setConfig(updatedConfig); - setMessage({ type: "success", text: `Proxy is now running on ${config.host}:${config.port}` @@ -127,13 +156,13 @@ export function ProxyConfigSection({ apiKeys, onRequestNewApiKey }: ProxyConfigS const handleStopProxy = async () => { setIsLoading(true); + setMessage(null); try { const status = await proxyService.stopManualProxy(); setProxyStatus(status); - setConfig((prev) => ({ ...prev, enabled: false })); - - setMessage({ type: "success", text: "The proxy server has been stopped" }); + setConfig((previous) => ({ ...previous, enabled: false })); + setMessage({ type: "success", text: "The proxy has stopped" }); } catch (error) { setMessage({ type: "error", text: `Failed to stop proxy: ${error}` }); } finally { @@ -142,236 +171,278 @@ export function ProxyConfigSection({ apiKeys, onRequestNewApiKey }: ProxyConfigS }; const handleConfigChange = (field: keyof ProxyConfig, value: string | number | boolean) => { - setConfig((prev) => ({ ...prev, [field]: value })); + setConfig((previous) => ({ ...previous, [field]: value })); }; - const copyProxyUrl = () => { - const url = `http://${config.host}:${config.port}/v1`; - navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + const handleAutoStartChange = async (checked: boolean) => { + const updatedConfig = { ...config, auto_start: checked }; + setConfig(updatedConfig); + try { + await proxyService.saveManualProxySettings(updatedConfig); + setMessage({ + type: "success", + text: checked ? "Auto-start enabled" : "Auto-start disabled" + }); + } catch { + setMessage({ type: "error", text: "Failed to save the auto-start setting" }); + } }; - if (!isTauriDesktopPlatform) { - return null; // Don't show proxy config on non-desktop platforms (includes mobile) - } - + const proxyBaseUrl = getProxyBaseUrl(config.host, config.port); const isRunning = proxyStatus?.running || false; + const copyProxyUrl = async () => { + try { + await navigator.clipboard.writeText(proxyBaseUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error("Failed to copy proxy URL:", error); + setMessage({ type: "error", text: "Failed to copy the proxy URL" }); + } + }; + + if (!isTauriDesktopPlatform) return null; + return ( <> - {/* Show message alerts */} - {message && ( - -
    - {message.type === "error" ? ( - + +
    + {message && ( + +
    + {message.type === "error" ? ( + + ) : ( + + )} + {message.text} +
    +
    + )} + +
    +
    +
    + +
    +
    +
    +

    {isRunning ? "Proxy running" : "Proxy stopped"}

    + + {isRunning ? "Online" : "Offline"} + +
    +

    + {isRunning + ? "Keep Maple open while connected tools are using this endpoint." + : "Start the proxy when you are ready to connect an app or coding agent."} +

    +
    +
    + {!isRunning ? ( + ) : ( - + )} - {message.text}
    - - )} - -
    -

    - - Local OpenAI Proxy -

    -
    - {isRunning ? "Running" : "Stopped"} -
    -
    -
    - - {/* Configuration */} -
    -
    - - handleConfigChange("host", e.target.value)} - placeholder="127.0.0.1" - disabled={isRunning} - className="h-8 text-sm" - /> -
    -
    -
    - - {/* API Key Info */} - {config.api_key && ( -
    - -

    API key configured

    -
    - )} - - {/* Proxy URL */} - {isRunning && ( -
    - -
    - -
    -

    - Use this URL as your OpenAI base URL in any client +

    + Compatible with clients that use OpenAI Chat Completions.

    - )} - - {/* Control Buttons */} -
    - {!isRunning ? ( - - ) : ( - + + + + + Local processes can use Maple's saved proxy credential without supplying their + own key. Keep the proxy on loopback, run only trusted clients, and remember that proxy + usage counts toward your Maple account. + + + + {config.enable_cors && ( + + + + CORS is enabled, so browser pages may be able to reach this proxy while it is + running. Turn CORS off unless a browser client specifically requires it. + + )} -
    - {/* CORS Toggle */} -
    - - handleConfigChange("enable_cors", e.target.checked)} - disabled={isRunning} - className="h-4 w-4" - /> -
    + {!isLoopbackHost(config.host) && ( + + + + This host may expose the proxy beyond your device. Use 127.0.0.1 unless you fully + understand the network and billing risk. + + + )} - {/* Auto-start Toggle */} -
    -
    - -

    - Proxy will start automatically on app launch if configured -

    -
    - { - const newConfig = { ...config, auto_start: e.target.checked }; - setConfig(newConfig); - // Save immediately when toggling auto-start - try { - await proxyService.saveManualProxySettings(newConfig); - setMessage({ - type: "success", - text: e.target.checked ? "Auto-start enabled" : "Auto-start disabled" - }); - } catch { - setMessage({ type: "error", text: "Failed to save auto-start setting" }); - } - }} - disabled={!config.api_key} // Only enable if we have an API key - className="h-4 w-4" - /> -
    -
    - - {/* Usage Examples */} - {isRunning && ( -
    - -
    - Python Example: -
    -                {`from openai import OpenAI
    -client = OpenAI(
    -  base_url="http://${config.host}:${config.port}/v1",
    -  api_key="anything"  # API key handled by proxy
    -)
    -
    -response = client.chat.completions.create(
    -  model="${QUICK_MODEL_ALIAS}",
    -  messages=[{"role": "user", "content": "Hello!"}],
    -  stream=True
    -)
    -
    -for chunk in response:
    -    print(chunk.choices[0].delta.content or "", end="")`}
    -              
    -
    -
    - - -
    - cURL Example: -
    -                {`curl -N http://${config.host}:${config.port}/v1/chat/completions \\
    -  -H "Content-Type: application/json" \\
    -  -d '{
    -    "model": "${QUICK_MODEL_ALIAS}",
    -    "messages": [{"role": "user", "content": "Hello!"}],
    -    "stream": true
    -  }'`}
    -              
    +
    + + Advanced settings + +
    +
    +
    + + handleConfigChange("host", event.target.value)} + placeholder="127.0.0.1" + disabled={isRunning} + aria-describedby="proxy-host-description" + /> +

    + Use a numeric IPv4 address. 127.0.0.1 is recommended. +

    +
    +
    + + + handleConfigChange("port", Number.parseInt(event.target.value, 10) || 8080) + } + placeholder="8080" + disabled={isRunning} + /> +
    +
    + +
    +
    + +

    + Desktop clients such as OpenCode do not need CORS. Turn this off unless a + browser app specifically requires it. +

    +
    + handleConfigChange("enable_cors", checked)} + disabled={isRunning} + aria-describedby="enable-cors-description" + /> +
    + +
    +
    + +

    + Available after Maple has created and saved the proxy credential. +

    +
    + +
    + + {config.api_key && ( +
    + + Maple has saved its proxy credential. +
    + )}
    - +
    - )} + + + + 0} + models={guideModels} + selectedModelId={selectedModelId} + onSelectModel={setSelectedModelId} + /> + + + + + ); } diff --git a/frontend/src/components/apikeys/ProxyModelList.tsx b/frontend/src/components/apikeys/ProxyModelList.tsx new file mode 100644 index 00000000..0d4459fa --- /dev/null +++ b/frontend/src/components/apikeys/ProxyModelList.tsx @@ -0,0 +1,144 @@ +import { useState } from "react"; +import { AlertCircle, Check, Copy, Loader2 } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatModelContext, getModelDisplayName } from "@/services/proxyModels"; +import type { OpenSecretModel } from "@/state/LocalStateContextDef"; + +type ProxyModelListProps = { + models: OpenSecretModel[]; + isLoading: boolean; + isError: boolean; +}; + +function ModelId({ modelId }: { modelId: string }) { + const [copyState, setCopyState] = useState<"idle" | "copied" | "error">("idle"); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(modelId); + setCopyState("copied"); + setTimeout(() => setCopyState("idle"), 2000); + } catch (error) { + console.error("Failed to copy model ID:", error); + setCopyState("error"); + } + }; + + return ( +
    + {modelId} + +
    + ); +} + +function capabilityLabels(model: OpenSecretModel): string[] { + const labels: string[] = []; + if (model.capabilities?.reasoning) labels.push("Reasoning"); + if (model.capabilities?.tool_use) labels.push("Tools"); + if (model.capabilities?.vision) labels.push("Vision"); + if (model.capabilities?.tool_use === false) labels.push("No tools"); + return labels; +} + +export function ProxyModelList({ models, isLoading, isError }: ProxyModelListProps) { + return ( +
    +
    +

    + {models.length > 0 ? `${models.length} chat model IDs` : "Chat model IDs"} +

    +

    + Fetched from Maple's current client catalog. This confirms configured support, not + real-time provider uptime or every API-only model. +

    +
    + + {isLoading && ( +
    + + Loading the current Maple model catalog... +
    + )} + + {isError && ( + + + + Maple could not load the current model catalog. Restart Maple once you are back online. + + + )} + + {!isLoading && !isError && models.length === 0 && ( +
    + No chat models are currently listed. Restart Maple if this persists. +
    + )} + + {models.length > 0 && ( +
      + {models.map((model) => { + const context = formatModelContext(model); + + return ( +
    • +
      +
      +

      {getModelDisplayName(model)}

      + {(model.badges ?? []).map((badge) => ( + + {badge} + + ))} +
      + +
      + {context && {context}} + {capabilityLabels(model).map((capability) => ( + {capability} + ))} +
      +
      +
    • + ); + })} +
    + )} +
    + ); +} diff --git a/frontend/src/components/settings/api/ApiSettingsLayout.tsx b/frontend/src/components/settings/api/ApiSettingsLayout.tsx index 0734c007..908b78b6 100644 --- a/frontend/src/components/settings/api/ApiSettingsLayout.tsx +++ b/frontend/src/components/settings/api/ApiSettingsLayout.tsx @@ -14,7 +14,6 @@ import { getBillingService } from "@/billing/billingService"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { useLocalState } from "@/state/useLocalState"; -import { openExternalUrl } from "@/utils/openUrl"; import { isIOS, isTauriDesktop } from "@/utils/platform"; import { cn } from "@/utils/utils"; import { SettingsPage, SettingsSection } from "../SettingsPage"; @@ -196,17 +195,6 @@ export function ApiSettingsLayout() { - void openExternalUrl("https://blog.trymaple.ai/maple-proxy-documentation/") - } - className="text-sm font-medium text-[hsl(var(--maple-primary-strong))] underline underline-offset-4 hover:text-[hsl(var(--maple-primary))]" - > - API documentation - - } >