mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 22:32:27 +08:00
feat: add bring-your-own-API-key support (#186)
- Add AI provider settings to config panel (provider, model, API key, base URL) - Support 7 providers: OpenAI, Anthropic, Google, Azure, OpenRouter, DeepSeek, SiliconFlow - Client API keys stored in localStorage, never stored on server - Client settings override server env vars when provided - Skip server credential validation when client provides API key - Bypass usage limits (request/token/TPM) when using own API key - Add /api/config endpoint for fetching usage limits - Add privacy notices to settings dialog, about pages, and quota toast - Add clear settings button to reset saved API keys - Update README files (EN/CN/JA) with BYOK documentation Co-authored-by: dayuan.jiang <jiangdy@amazon.co.jp>
This commit is contained in:
@@ -24,6 +24,10 @@ import { QuotaLimitToast } from "@/components/quota-limit-toast"
|
||||
import {
|
||||
SettingsDialog,
|
||||
STORAGE_ACCESS_CODE_KEY,
|
||||
STORAGE_AI_API_KEY_KEY,
|
||||
STORAGE_AI_BASE_URL_KEY,
|
||||
STORAGE_AI_MODEL_KEY,
|
||||
STORAGE_AI_PROVIDER_KEY,
|
||||
} from "@/components/settings-dialog"
|
||||
|
||||
// localStorage keys for persistence
|
||||
@@ -119,11 +123,20 @@ export default function ChatPanel({
|
||||
}, [])
|
||||
|
||||
// Helper to check daily request limit
|
||||
// Check if user has their own API key configured (bypass limits)
|
||||
const hasOwnApiKey = useCallback((): boolean => {
|
||||
const provider = localStorage.getItem(STORAGE_AI_PROVIDER_KEY)
|
||||
const apiKey = localStorage.getItem(STORAGE_AI_API_KEY_KEY)
|
||||
return !!(provider && apiKey)
|
||||
}, [])
|
||||
|
||||
const checkDailyLimit = useCallback((): {
|
||||
allowed: boolean
|
||||
remaining: number
|
||||
used: number
|
||||
} => {
|
||||
// Skip limit if user has their own API key
|
||||
if (hasOwnApiKey()) return { allowed: true, remaining: -1, used: 0 }
|
||||
if (dailyRequestLimit <= 0)
|
||||
return { allowed: true, remaining: -1, used: 0 }
|
||||
|
||||
@@ -145,7 +158,7 @@ export default function ChatPanel({
|
||||
remaining: dailyRequestLimit - count,
|
||||
used: count,
|
||||
}
|
||||
}, [dailyRequestLimit])
|
||||
}, [dailyRequestLimit, hasOwnApiKey])
|
||||
|
||||
// Helper to increment request count
|
||||
const incrementRequestCount = useCallback((): void => {
|
||||
@@ -168,7 +181,7 @@ export default function ChatPanel({
|
||||
),
|
||||
{ duration: 15000 },
|
||||
)
|
||||
}, [dailyRequestLimit])
|
||||
}, [dailyRequestLimit, hasOwnApiKey])
|
||||
|
||||
// Helper to check daily token limit (checks if already over limit)
|
||||
const checkTokenLimit = useCallback((): {
|
||||
@@ -176,6 +189,8 @@ export default function ChatPanel({
|
||||
remaining: number
|
||||
used: number
|
||||
} => {
|
||||
// Skip limit if user has their own API key
|
||||
if (hasOwnApiKey()) return { allowed: true, remaining: -1, used: 0 }
|
||||
if (dailyTokenLimit <= 0)
|
||||
return { allowed: true, remaining: -1, used: 0 }
|
||||
|
||||
@@ -200,7 +215,7 @@ export default function ChatPanel({
|
||||
remaining: dailyTokenLimit - count,
|
||||
used: count,
|
||||
}
|
||||
}, [dailyTokenLimit])
|
||||
}, [dailyTokenLimit, hasOwnApiKey])
|
||||
|
||||
// Helper to increment token count
|
||||
const incrementTokenCount = useCallback((tokens: number): void => {
|
||||
@@ -242,6 +257,8 @@ export default function ChatPanel({
|
||||
remaining: number
|
||||
used: number
|
||||
} => {
|
||||
// Skip limit if user has their own API key
|
||||
if (hasOwnApiKey()) return { allowed: true, remaining: -1, used: 0 }
|
||||
if (tpmLimit <= 0) return { allowed: true, remaining: -1, used: 0 }
|
||||
|
||||
const currentMinute = Math.floor(Date.now() / 60000).toString()
|
||||
@@ -264,7 +281,7 @@ export default function ChatPanel({
|
||||
remaining: tpmLimit - count,
|
||||
used: count,
|
||||
}
|
||||
}, [tpmLimit])
|
||||
}, [tpmLimit, hasOwnApiKey])
|
||||
|
||||
// Helper to increment TPM count
|
||||
const incrementTPMCount = useCallback((tokens: number): void => {
|
||||
@@ -777,6 +794,14 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
|
||||
const accessCode =
|
||||
localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
const aiProvider =
|
||||
localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || ""
|
||||
const aiBaseUrl =
|
||||
localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || ""
|
||||
const aiApiKey =
|
||||
localStorage.getItem(STORAGE_AI_API_KEY_KEY) || ""
|
||||
const aiModel = localStorage.getItem(STORAGE_AI_MODEL_KEY) || ""
|
||||
|
||||
sendMessage(
|
||||
{ parts },
|
||||
{
|
||||
@@ -786,6 +811,10 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
},
|
||||
headers: {
|
||||
"x-access-code": accessCode,
|
||||
...(aiProvider && { "x-ai-provider": aiProvider }),
|
||||
...(aiBaseUrl && { "x-ai-base-url": aiBaseUrl }),
|
||||
...(aiApiKey && { "x-ai-api-key": aiApiKey }),
|
||||
...(aiModel && { "x-ai-model": aiModel }),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -886,6 +915,11 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
|
||||
// Now send the message after state is guaranteed to be updated
|
||||
const accessCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
const aiProvider = localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || ""
|
||||
const aiBaseUrl = localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || ""
|
||||
const aiApiKey = localStorage.getItem(STORAGE_AI_API_KEY_KEY) || ""
|
||||
const aiModel = localStorage.getItem(STORAGE_AI_MODEL_KEY) || ""
|
||||
|
||||
sendMessage(
|
||||
{ parts: userParts },
|
||||
{
|
||||
@@ -895,6 +929,10 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
},
|
||||
headers: {
|
||||
"x-access-code": accessCode,
|
||||
...(aiProvider && { "x-ai-provider": aiProvider }),
|
||||
...(aiBaseUrl && { "x-ai-base-url": aiBaseUrl }),
|
||||
...(aiApiKey && { "x-ai-api-key": aiApiKey }),
|
||||
...(aiModel && { "x-ai-model": aiModel }),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -972,6 +1010,11 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
|
||||
// Now send the edited message after state is guaranteed to be updated
|
||||
const accessCode = localStorage.getItem(STORAGE_ACCESS_CODE_KEY) || ""
|
||||
const aiProvider = localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || ""
|
||||
const aiBaseUrl = localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || ""
|
||||
const aiApiKey = localStorage.getItem(STORAGE_AI_API_KEY_KEY) || ""
|
||||
const aiModel = localStorage.getItem(STORAGE_AI_MODEL_KEY) || ""
|
||||
|
||||
sendMessage(
|
||||
{ parts: newParts },
|
||||
{
|
||||
@@ -981,6 +1024,10 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
|
||||
},
|
||||
headers: {
|
||||
"x-access-code": accessCode,
|
||||
...(aiProvider && { "x-ai-provider": aiProvider }),
|
||||
...(aiBaseUrl && { "x-ai-base-url": aiBaseUrl }),
|
||||
...(aiApiKey && { "x-ai-api-key": aiApiKey }),
|
||||
...(aiModel && { "x-ai-model": aiModel }),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -82,9 +82,9 @@ export function QuotaLimitToast({
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
The good news is that you can self-host the project in
|
||||
seconds on Vercel (it's fully open-source), or if you love
|
||||
it, consider sponsoring to help keep the lights on!
|
||||
<strong>Tip:</strong> You can use your own API key (click
|
||||
the Settings icon) or self-host the project to bypass these
|
||||
limits.
|
||||
</p>
|
||||
<p>Your limit resets tomorrow. Thanks for understanding!</p>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
|
||||
interface SettingsDialogProps {
|
||||
@@ -22,6 +29,10 @@ interface SettingsDialogProps {
|
||||
export const STORAGE_ACCESS_CODE_KEY = "next-ai-draw-io-access-code"
|
||||
export const STORAGE_CLOSE_PROTECTION_KEY = "next-ai-draw-io-close-protection"
|
||||
const STORAGE_ACCESS_CODE_REQUIRED_KEY = "next-ai-draw-io-access-code-required"
|
||||
export const STORAGE_AI_PROVIDER_KEY = "next-ai-draw-io-ai-provider"
|
||||
export const STORAGE_AI_BASE_URL_KEY = "next-ai-draw-io-ai-base-url"
|
||||
export const STORAGE_AI_API_KEY_KEY = "next-ai-draw-io-ai-api-key"
|
||||
export const STORAGE_AI_MODEL_KEY = "next-ai-draw-io-ai-model"
|
||||
|
||||
function getStoredAccessCodeRequired(): boolean | null {
|
||||
if (typeof window === "undefined") return null
|
||||
@@ -42,6 +53,10 @@ export function SettingsDialog({
|
||||
const [accessCodeRequired, setAccessCodeRequired] = useState(
|
||||
() => getStoredAccessCodeRequired() ?? false,
|
||||
)
|
||||
const [provider, setProvider] = useState("")
|
||||
const [baseUrl, setBaseUrl] = useState("")
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
const [modelId, setModelId] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch if not cached in localStorage
|
||||
@@ -77,6 +92,13 @@ export function SettingsDialog({
|
||||
)
|
||||
// Default to true if not set
|
||||
setCloseProtection(storedCloseProtection !== "false")
|
||||
|
||||
// Load AI provider settings
|
||||
setProvider(localStorage.getItem(STORAGE_AI_PROVIDER_KEY) || "")
|
||||
setBaseUrl(localStorage.getItem(STORAGE_AI_BASE_URL_KEY) || "")
|
||||
setApiKey(localStorage.getItem(STORAGE_AI_API_KEY_KEY) || "")
|
||||
setModelId(localStorage.getItem(STORAGE_AI_MODEL_KEY) || "")
|
||||
|
||||
setError("")
|
||||
}
|
||||
}, [open])
|
||||
@@ -160,6 +182,181 @@ export function SettingsDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>AI Provider Settings</Label>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Use your own API key to bypass usage limits. Your
|
||||
key is stored locally in your browser and is never
|
||||
stored on the server.
|
||||
</p>
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-provider">Provider</Label>
|
||||
<Select
|
||||
value={provider || "default"}
|
||||
onValueChange={(value) => {
|
||||
const actualValue =
|
||||
value === "default" ? "" : value
|
||||
setProvider(actualValue)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_PROVIDER_KEY,
|
||||
actualValue,
|
||||
)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="ai-provider">
|
||||
<SelectValue placeholder="Use Server Default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
Use Server Default
|
||||
</SelectItem>
|
||||
<SelectItem value="openai">
|
||||
OpenAI
|
||||
</SelectItem>
|
||||
<SelectItem value="anthropic">
|
||||
Anthropic
|
||||
</SelectItem>
|
||||
<SelectItem value="google">
|
||||
Google
|
||||
</SelectItem>
|
||||
<SelectItem value="azure">
|
||||
Azure OpenAI
|
||||
</SelectItem>
|
||||
<SelectItem value="openrouter">
|
||||
OpenRouter
|
||||
</SelectItem>
|
||||
<SelectItem value="deepseek">
|
||||
DeepSeek
|
||||
</SelectItem>
|
||||
<SelectItem value="siliconflow">
|
||||
SiliconFlow
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{provider && provider !== "default" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-model">
|
||||
Model ID
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-model"
|
||||
value={modelId}
|
||||
onChange={(e) => {
|
||||
setModelId(e.target.value)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_MODEL_KEY,
|
||||
e.target.value,
|
||||
)
|
||||
}}
|
||||
placeholder={
|
||||
provider === "openai"
|
||||
? "e.g., gpt-4o"
|
||||
: provider === "anthropic"
|
||||
? "e.g., claude-sonnet-4-5"
|
||||
: provider === "google"
|
||||
? "e.g., gemini-2.0-flash-exp"
|
||||
: provider ===
|
||||
"deepseek"
|
||||
? "e.g., deepseek-chat"
|
||||
: "Model ID"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-api-key">
|
||||
API Key
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-api-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_API_KEY_KEY,
|
||||
e.target.value,
|
||||
)
|
||||
}}
|
||||
placeholder="Your API key"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-[0.8rem] text-muted-foreground">
|
||||
Overrides{" "}
|
||||
{provider === "openai"
|
||||
? "OPENAI_API_KEY"
|
||||
: provider === "anthropic"
|
||||
? "ANTHROPIC_API_KEY"
|
||||
: provider === "google"
|
||||
? "GOOGLE_GENERATIVE_AI_API_KEY"
|
||||
: provider === "azure"
|
||||
? "AZURE_API_KEY"
|
||||
: provider ===
|
||||
"openrouter"
|
||||
? "OPENROUTER_API_KEY"
|
||||
: provider ===
|
||||
"deepseek"
|
||||
? "DEEPSEEK_API_KEY"
|
||||
: provider ===
|
||||
"siliconflow"
|
||||
? "SILICONFLOW_API_KEY"
|
||||
: "server API key"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-base-url">
|
||||
Base URL (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => {
|
||||
setBaseUrl(e.target.value)
|
||||
localStorage.setItem(
|
||||
STORAGE_AI_BASE_URL_KEY,
|
||||
e.target.value,
|
||||
)
|
||||
}}
|
||||
placeholder={
|
||||
provider === "anthropic"
|
||||
? "https://api.anthropic.com/v1"
|
||||
: provider === "siliconflow"
|
||||
? "https://api.siliconflow.com/v1"
|
||||
: "Custom endpoint URL"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_PROVIDER_KEY,
|
||||
)
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_BASE_URL_KEY,
|
||||
)
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_API_KEY_KEY,
|
||||
)
|
||||
localStorage.removeItem(
|
||||
STORAGE_AI_MODEL_KEY,
|
||||
)
|
||||
setProvider("")
|
||||
setBaseUrl("")
|
||||
setApiKey("")
|
||||
setModelId("")
|
||||
}}
|
||||
>
|
||||
Clear Settings
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="close-protection">
|
||||
|
||||
Reference in New Issue
Block a user