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:
Dayuan Jiang
2025-12-09 17:50:07 +09:00
committed by GitHub
parent 77cb10393b
commit 97ab82e027
12 changed files with 434 additions and 43 deletions

View File

@@ -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 }),
},
},
)

View File

@@ -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>

View File

@@ -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">