Skip to content

Commit 7c76a83

Browse files
bidahclaude
andcommitted
Add Moonshot BYOK support and fix Kimi K2 API key flow
- Add Moonshot API Key field to BYOK panel alongside Anthropic key - Thread moonshotKey from client through chat route to sandbox service - Resolve API key early in service for settings.json and .env writes - Skip usage limits for Kimi K2 BYOK users (same as Anthropic BYOK) - Fix env file: write ANTHROPIC_AUTH_TOKEN for Claude SDK auth on Moonshot Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent df42aa5 commit 7c76a83

5 files changed

Lines changed: 115 additions & 77 deletions

File tree

‎apps/web/app/(app)/api/chat/route.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export async function POST(req: Request) {
3434
agentType,
3535
source,
3636
anthropicKey,
37+
moonshotKey,
3738
}: {
3839
messages: UIMessage[]
3940
projectId: string
@@ -46,6 +47,7 @@ export async function POST(req: Request) {
4647
agentType?: AgentType
4748
source?: string
4849
anthropicKey?: string
50+
moonshotKey?: string
4951
} = await req.json()
5052

5153
// Mobile (remote-control) requests never include userId — they only send projectId + messages
@@ -107,7 +109,8 @@ export async function POST(req: Request) {
107109
console.log('[Chat Route] lastUserMessageId:', lastUserMessageId)
108110

109111
// Check message usage limits before processing (skip for BYOK users)
110-
if (userId && lastUserMessage && !anthropicKey) {
112+
const hasByokKey = !!anthropicKey || (agentType === 'kimi-k2' && !!moonshotKey)
113+
if (userId && lastUserMessage && !hasByokKey) {
111114
console.log('[Chat Route] Checking message usage limits for user:', userId)
112115
const usageCheck = await canUserSendMessage(userId)
113116

@@ -242,7 +245,7 @@ export async function POST(req: Request) {
242245
}
243246

244247
// Check sandbox hours for BYOK users
245-
if (anthropicKey && userId) {
248+
if (hasByokKey && userId) {
246249
const sandboxCheck = await canUserCreateSandbox(userId)
247250
if (!sandboxCheck.canCreate) {
248251
const sandboxLimitData = {
@@ -298,7 +301,7 @@ export async function POST(req: Request) {
298301
}
299302

300303
// Increment message usage count before processing (skip for BYOK users)
301-
if (!anthropicKey) {
304+
if (!hasByokKey) {
302305
console.log('[Chat Route] Incrementing message usage for user:', userId)
303306
const usageResult = await incrementMessageUsage(userId)
304307

@@ -478,6 +481,7 @@ export async function POST(req: Request) {
478481
skills,
479482
agentType,
480483
anthropicKey,
484+
moonshotKey,
481485
},
482486
{
483487
onMessage: (message: string) => {

‎apps/web/app/(app)/p/[id]/project-page.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,9 @@ export function ProjectPageInternal({ opencodeEnabled = false, template: templat
333333
anthropicKey: typeof window !== 'undefined'
334334
? (localStorage.getItem('byok_anthropic_key') || undefined)
335335
: undefined,
336+
moonshotKey: typeof window !== 'undefined'
337+
? (localStorage.getItem('byok_moonshot_key') || undefined)
338+
: undefined,
336339
messages: messageToSend ? [messageToSend] : [],
337340
}
338341

‎apps/web/components/byok-panel.tsx‎

Lines changed: 96 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -8,42 +8,94 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
88
import { CheckCircle2, X, KeyRound } from 'lucide-react'
99

1010
const STORAGE_KEY = 'byok_anthropic_key'
11+
const MOONSHOT_STORAGE_KEY = 'byok_moonshot_key'
1112

1213
interface ByokPanelProps {
1314
onClose: () => void
1415
}
1516

16-
export function ByokPanel({ onClose }: ByokPanelProps) {
17+
function KeySection({
18+
label,
19+
storageKey,
20+
placeholder,
21+
}: {
22+
label: string
23+
storageKey: string
24+
placeholder?: string
25+
}) {
1726
const [key, setKey] = useState('')
1827
const [savedKey, setSavedKey] = useState<string | null>(null)
19-
const [usage, setUsage] = useState<{ sessionsUsed: number; sessionLimit: number; hoursUsed: number; hoursLimit: number } | null>(null)
2028

2129
useEffect(() => {
22-
const stored = localStorage.getItem(STORAGE_KEY)
30+
const stored = localStorage.getItem(storageKey)
2331
if (stored) setSavedKey(stored)
24-
}, [])
25-
26-
useEffect(() => {
27-
if (!savedKey) return
28-
fetch('/api/byok/usage')
29-
.then(r => r.json())
30-
.then(data => setUsage(data))
31-
.catch(() => {})
32-
}, [savedKey])
32+
}, [storageKey])
3333

3434
function handleSave() {
3535
if (!key.trim()) return
36-
localStorage.setItem(STORAGE_KEY, key.trim())
36+
localStorage.setItem(storageKey, key.trim())
3737
setSavedKey(key.trim())
3838
setKey('')
3939
}
4040

4141
function handleRemove() {
42-
localStorage.removeItem(STORAGE_KEY)
42+
localStorage.removeItem(storageKey)
4343
setSavedKey(null)
44-
setUsage(null)
44+
}
45+
46+
if (savedKey) {
47+
return (
48+
<div className="space-y-2">
49+
<Label>{label}</Label>
50+
<Alert className="border-green-400/50 bg-green-50 dark:bg-green-900/20 justify-center">
51+
<AlertDescription className="text-green-700 dark:text-green-300 items-center flex flex-row">
52+
<CheckCircle2 className="h-4 w-4 text-green-600 mr-2 shrink-0" />
53+
Key is active
54+
</AlertDescription>
55+
</Alert>
56+
<Button variant="outline" size="sm" onClick={handleRemove} className="w-full">
57+
<X className="h-4 w-4 mr-2" />
58+
Remove Key
59+
</Button>
60+
</div>
61+
)
4562
}
4663

64+
return (
65+
<div className="space-y-2">
66+
<Label htmlFor={storageKey}>{label}</Label>
67+
<Input
68+
id={storageKey}
69+
type="password"
70+
autoComplete="off"
71+
placeholder={placeholder}
72+
value={key}
73+
onChange={e => setKey(e.target.value)}
74+
onKeyDown={e => e.key === 'Enter' && handleSave()}
75+
/>
76+
<Button onClick={handleSave} disabled={!key.trim()} size="sm" className="w-full">
77+
Save Key
78+
</Button>
79+
</div>
80+
)
81+
}
82+
83+
export function ByokPanel({ onClose }: ByokPanelProps) {
84+
const [usage, setUsage] = useState<{ sessionsUsed: number; sessionLimit: number; hoursUsed: number; hoursLimit: number } | null>(null)
85+
const [hasAnthropicKey, setHasAnthropicKey] = useState(false)
86+
87+
useEffect(() => {
88+
setHasAnthropicKey(!!localStorage.getItem(STORAGE_KEY))
89+
}, [])
90+
91+
useEffect(() => {
92+
if (!hasAnthropicKey) return
93+
fetch('/api/byok/usage')
94+
.then(r => r.json())
95+
.then(data => setUsage(data))
96+
.catch(() => {})
97+
}, [hasAnthropicKey])
98+
4799
return (
48100
<div className="flex flex-col h-full p-4 gap-4">
49101
<div className="flex items-center gap-2 pt-2">
@@ -52,62 +104,39 @@ export function ByokPanel({ onClose }: ByokPanelProps) {
52104
</div>
53105

54106
<p className="text-sm text-muted-foreground">
55-
Add your Anthropic API key to use the service for free without a subscription.
56-
</p>
57-
<p className="text-sm text-muted-foreground">
58-
Your key is stored locally in your browser. We validate it to check it works and then whenever you send a message it is sent with the API key to the sandbox where the code agent will use it directly. Keys are never stored on database.
107+
Add your API keys to use the service without a subscription. Keys are stored locally in your browser and never saved on our servers.
59108
</p>
60109

61-
{savedKey ? (
62-
<div className="space-y-3">
63-
<Alert className="border-green-400/50 bg-green-50 dark:bg-green-900/20 justify-center">
64-
<AlertDescription className="text-green-700 dark:text-green-300 items-center flex flex-row">
65-
<CheckCircle2 className="h-4 w-4 text-green-600 mr-2" />
66-
API key is active. Messages are using your key
67-
</AlertDescription>
68-
</Alert>
69-
70-
{usage && (
71-
<div className="rounded-lg border p-3 space-y-2">
72-
<p className="text-sm font-medium">Free Sandbox Usage</p>
73-
<div className="flex justify-between text-sm text-muted-foreground">
74-
<span>Sessions used</span>
75-
<span>{usage.sessionsUsed} / {usage.sessionLimit}</span>
76-
</div>
77-
<div className="w-full bg-muted rounded-full h-2">
78-
<div
79-
className="bg-primary h-2 rounded-full transition-all"
80-
style={{ width: `${Math.min((usage.sessionsUsed / usage.sessionLimit) * 100, 100)}%` }}
81-
/>
82-
</div>
83-
<p className="text-xs text-muted-foreground">
84-
{usage.hoursUsed}h / {usage.hoursLimit}h used ({usage.sessionLimit - usage.sessionsUsed} sessions remaining)
85-
</p>
86-
</div>
87-
)}
88-
89-
<Button variant="outline" onClick={handleRemove} className="w-full">
90-
<X className="h-4 w-4 mr-2" />
91-
Remove Key
92-
</Button>
93-
</div>
94-
) : (
95-
<div className="space-y-3">
96-
<div className="space-y-1">
97-
<Label htmlFor="byok-key">Anthropic API Key</Label>
98-
<Input
99-
id="byok-key"
100-
type="password"
101-
autoComplete="off"
102-
value={key}
103-
onChange={e => setKey(e.target.value)}
104-
onKeyDown={e => e.key === 'Enter' && handleSave()}
110+
<div className="space-y-4">
111+
<KeySection
112+
label="Anthropic API Key"
113+
storageKey={STORAGE_KEY}
114+
placeholder="sk-ant-..."
115+
/>
116+
117+
<KeySection
118+
label="Moonshot API Key (Kimi K2)"
119+
storageKey={MOONSHOT_STORAGE_KEY}
120+
placeholder="sk-..."
121+
/>
122+
</div>
123+
124+
{usage && hasAnthropicKey && (
125+
<div className="rounded-lg border p-3 space-y-2">
126+
<p className="text-sm font-medium">Free Sandbox Usage</p>
127+
<div className="flex justify-between text-sm text-muted-foreground">
128+
<span>Sessions used</span>
129+
<span>{usage.sessionsUsed} / {usage.sessionLimit}</span>
130+
</div>
131+
<div className="w-full bg-muted rounded-full h-2">
132+
<div
133+
className="bg-primary h-2 rounded-full transition-all"
134+
style={{ width: `${Math.min((usage.sessionsUsed / usage.sessionLimit) * 100, 100)}%` }}
105135
/>
106136
</div>
107-
108-
<Button onClick={handleSave} disabled={!key.trim()} className="w-full">
109-
Save Key
110-
</Button>
137+
<p className="text-xs text-muted-foreground">
138+
{usage.hoursUsed}h / {usage.hoursLimit}h used ({usage.sessionLimit - usage.sessionsUsed} sessions remaining)
139+
</p>
111140
</div>
112141
)}
113142
</div>

‎apps/web/lib/claude-code-handler.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface ClaudeCodeHandlerRequest {
2424
claudeModel?: string
2525
skills?: string[]
2626
anthropicKey?: string
27+
moonshotKey?: string
2728
agentType?: string
2829
}
2930

@@ -208,6 +209,7 @@ export async function handleClaudeCodeGeneration(
208209
claudeModel: request.claudeModel,
209210
skills: request.skills,
210211
anthropicKey: request.anthropicKey,
212+
moonshotKey: request.moonshotKey,
211213
agentType: request.agentType,
212214
},
213215
sandbox,

‎apps/web/lib/claude-code-service.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface AppGenerationRequest {
3131
claudeModel?: string // Model ID for Claude (e.g., claude-sonnet-4-5-20250929)
3232
skills?: string[] // Selected AI skills (e.g., 'anthropic-chat', 'openai-dalle-3')
3333
anthropicKey?: string // BYOK: user-provided Anthropic API key
34+
moonshotKey?: string // BYOK: user-provided Moonshot API key (for Kimi K2)
3435
agentType?: string // Agent type (claude-code, opencode, kimi-k2)
3536
}
3637

@@ -197,8 +198,12 @@ export class ClaudeCodeService {
197198
console.error('[Claude Code Service] ❌ Failed to check cloud status:', dbError)
198199
}
199200

200-
// Determine if this is a Kimi K2 request (used for settings and env config below)
201+
// Determine if this is a Kimi K2 request and resolve the API key early
202+
// (used for settings, env file, and sandbox envs below)
201203
const isKimiK2 = request.agentType === 'kimi-k2'
204+
const apiKeyToUse = isKimiK2
205+
? (request.moonshotKey || globalThis.process.env.MOONSHOT_API_KEY || '')
206+
: (request.anthropicKey || globalThis.process.env.ANTHROPIC_API_KEY || '')
202207

203208
// Write Claude settings to skip the WebFetch preflight call to claude.ai.
204209
// Inside an E2B sandbox the preflight request (GET claude.ai/api/web/domain_info)
@@ -211,7 +216,7 @@ export class ClaudeCodeService {
211216
// For Kimi K2, configure the Claude SDK to use Moonshot's Anthropic-compatible API
212217
if (isKimiK2) {
213218
claudeSettings.env = {
214-
ANTHROPIC_AUTH_TOKEN: globalThis.process.env.MOONSHOT_API_KEY || '',
219+
ANTHROPIC_AUTH_TOKEN: apiKeyToUse,
215220
ANTHROPIC_BASE_URL: 'https://api.moonshot.ai/anthropic',
216221
}
217222
}
@@ -256,11 +261,6 @@ export class ClaudeCodeService {
256261
console.log('[Claude Code Service] 📝 No image attachments to add')
257262
}
258263

259-
// Determine which API key and base URL to use based on agent type
260-
const apiKeyToUse = isKimiK2
261-
? (globalThis.process.env.MOONSHOT_API_KEY || '')
262-
: (request.anthropicKey || globalThis.process.env.ANTHROPIC_API_KEY || '')
263-
264264
// Write the API key (and optional base URL) to /claude-sdk/.env so the executor's loadEnvFile() picks it up.
265265
// This is the most reliable way to pass the key since:
266266
// 1. E2B's envs option may not override existing sandbox env vars

0 commit comments

Comments
 (0)