Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 110 additions & 14 deletions frontend/components/APIKeyForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { FieldError, useForm, UseFormRegister } from 'react-hook-form';
Expand All @@ -14,8 +14,10 @@ import {
} from '@/frontend/components/ui/card';
import { Key } from 'lucide-react';
import { toast } from 'sonner';
import { useAPIKeyStore } from '@/frontend/stores/APIKeyStore';
import { useAPIKeyStore, Provider } from '@/frontend/stores/APIKeyStore';
import { Badge } from './ui/badge';
import { APIValidationResult, validateAPIKey as validateAPIKeyService } from '@/lib/apiValidationService';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';

const formSchema = z.object({
google: z.string().trim().min(1, {
Expand Down Expand Up @@ -48,27 +50,82 @@ export default function APIKeyForm() {

const Form = () => {
const { keys, setKeys } = useAPIKeyStore();
const [apiValidationResults, setApiValidationResults] = useState<Record<string, APIValidationResult>>({});
const [validatingKeys, setValidatingKeys] = useState<Set<string>>(new Set());

const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
watch,
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: keys,
});

const watchedValues = watch();

useEffect(() => {
reset(keys);
}, [keys, reset]);

useEffect(() => {
Object.keys(watchedValues).forEach(provider => {
if (apiValidationResults[provider]) {
setApiValidationResults(prev => {
const newResults = { ...prev };
delete newResults[provider];
return newResults;
});
}
});
}, [watchedValues, apiValidationResults]);


const validateWithAPI = useCallback(async (formValues: FormValues) => {
const providersToValidate = Object.entries(formValues).filter(([, value]) =>
value && value.trim().length > 0
) as [Provider, string][];

setValidatingKeys(new Set(providersToValidate.map(([provider]) => provider)));

const results: Record<string, APIValidationResult> = {};

await Promise.all(
providersToValidate.map(async ([provider, apiKey]) => {
try {
results[provider] = await validateAPIKeyService(provider, apiKey);
} catch {
results[provider] = { isValid: false, error: 'Validation failed' };
}
})
);

setApiValidationResults(results);
setValidatingKeys(new Set());

return results;
}, []);

const onSubmit = useCallback(
(values: FormValues) => {
async (values: FormValues) => {
// First validate with APIs
const validationResults = await validateWithAPI(values);

// Check if any validation failed
const hasErrors = Object.values(validationResults).some(result => !result.isValid);

if (hasErrors) {
toast.error('Please fix API key errors before saving');
return;
}

// Only save if all validations pass
setKeys(values);
toast.success('API keys saved successfully');
},
[setKeys]
[setKeys, validateWithAPI]
);

return (
Expand All @@ -81,6 +138,8 @@ const Form = () => {
placeholder="AIza..."
register={register}
error={errors.google}
apiValidation={apiValidationResults.google}
isValidating={validatingKeys.has('google')}
required
/>

Expand All @@ -92,6 +151,8 @@ const Form = () => {
placeholder="sk-or-..."
register={register}
error={errors.openrouter}
apiValidation={apiValidationResults.openrouter}
isValidating={validatingKeys.has('openrouter')}
/>

<ApiKeyField
Expand All @@ -102,6 +163,8 @@ const Form = () => {
placeholder="sk-..."
register={register}
error={errors.openai}
apiValidation={apiValidationResults.openai}
isValidating={validatingKeys.has('openai')}
/>

<Button type="submit" className="w-full" disabled={!isDirty}>
Expand All @@ -118,6 +181,8 @@ interface ApiKeyFieldProps {
models: string[];
placeholder: string;
error?: FieldError | undefined;
apiValidation?: APIValidationResult;
isValidating?: boolean;
required?: boolean;
register: UseFormRegister<FormValues>;
}
Expand All @@ -129,9 +194,32 @@ const ApiKeyField = ({
placeholder,
models,
error,
apiValidation,
isValidating,
required,
register,
}: ApiKeyFieldProps) => (
}: ApiKeyFieldProps) => {
const getInputClassName = () => {
if (error) return 'border-red-500';
if (apiValidation?.isValid === false) return 'border-red-500';
if (apiValidation?.isValid === true) return 'border-green-500';
return '';
};

const getValidationIcon = () => {
if (isValidating) {
return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />;
}
if (apiValidation?.isValid === true) {
return <CheckCircle className="w-4 h-4 text-green-500" />;
}
if (apiValidation?.isValid === false) {
return <XCircle className="w-4 h-4 text-red-500" />;
}
return null;
};

return (
<div className="flex flex-col gap-2">
<label
htmlFor={id}
Expand All @@ -146,12 +234,17 @@ const ApiKeyField = ({
))}
</div>

<Input
id={id}
placeholder={placeholder}
{...register(id as keyof FormValues)}
className={error ? 'border-red-500' : ''}
/>
<div className="relative">
<Input
id={id}
placeholder={placeholder}
{...register(id as keyof FormValues)}
className={getInputClassName()}
/>
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
{getValidationIcon()}
</div>
</div>

<a
href={linkUrl}
Expand All @@ -161,8 +254,11 @@ const ApiKeyField = ({
Create {label.split(' ')[0]} API Key
</a>

{error && (
<p className="text-[0.8rem] font-medium text-red-500">{error.message}</p>
{(error || (apiValidation && !apiValidation.isValid)) && (
<p className="text-[0.8rem] font-medium text-red-500">
{error?.message || apiValidation?.error}
</p>
)}
</div>
);
);
};
28 changes: 28 additions & 0 deletions frontend/stores/APIKeyStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { create, Mutate, StoreApi } from 'zustand';
import { persist } from 'zustand/middleware';
import { validateAPIKey as validateAPIKeyService, APIValidationResult } from '@/lib/apiValidationService';

export const PROVIDERS = ['google', 'openrouter', 'openai'] as const;
export type Provider = (typeof PROVIDERS)[number];
Expand All @@ -11,6 +12,8 @@ type APIKeyStore = {
setKeys: (newKeys: Partial<APIKeys>) => void;
hasRequiredKeys: () => boolean;
getKey: (provider: Provider) => string | null;
validateKeyAPI: (provider: Provider) => Promise<APIValidationResult>;
validateAllKeysAPI: () => Promise<Record<Provider, APIValidationResult>>;
};

type StoreWithPersist = Mutate<
Expand Down Expand Up @@ -55,6 +58,31 @@ export const useAPIKeyStore = create<APIKeyStore>()(
const key = get().keys[provider];
return key ? key : null;
},

validateKeyAPI: async (provider) => {
const key = get().keys[provider];
if (!key || key.trim().length === 0) {
return { isValid: false, error: `${provider} API key is required` };
}
return validateAPIKeyService(provider, key);
},

validateAllKeysAPI: async () => {
const keys = get().keys;
const results: Record<Provider, APIValidationResult> = {} as Record<Provider, APIValidationResult>;

const validationPromises = PROVIDERS.map(async (provider) => {
const key = keys[provider];
if (key && key.trim().length > 0) {
results[provider] = await validateAPIKeyService(provider, key);
} else {
results[provider] = { isValid: false, error: `${provider} API key is required` };
}
});

await Promise.all(validationPromises);
return results;
},
}),
{
name: 'api-keys',
Expand Down
85 changes: 85 additions & 0 deletions lib/apiValidationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Provider } from "@/frontend/stores/APIKeyStore";

export interface APIValidationResult {
isValid: boolean;
error?: string;
}

async function validateOpenAI(apiKey: string): Promise<APIValidationResult> {
try {
const response = await fetch("https://api.openai.com/v1/models", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});

if (response.ok) {
return { isValid: true };
} else {
return { isValid: false, error: "OpenAI key not valid" };
}
} catch {
return { isValid: false, error: "Unable to validate OpenAI key" };
}
}

async function validateGoogle(apiKey: string): Promise<APIValidationResult> {
try {
const response = await fetch(
`https://generativelanguage.googleapis.com/v1/models?key=${apiKey}`,
{
method: "GET",
}
);

if (response.ok) {
return { isValid: true };
} else {
return { isValid: false, error: "Google key not valid" };
}
} catch {
return { isValid: false, error: "Unable to validate Google key" };
}
}

async function validateOpenRouter(
apiKey: string
): Promise<APIValidationResult> {
try {
const response = await fetch("https://openrouter.ai/api/v1/auth/key", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});

if (response.ok) {
return { isValid: true };
} else {
return { isValid: false, error: "OpenRouter key not valid" };
}
} catch {
return { isValid: false, error: "Unable to validate OpenRouter key" };
}
}

export async function validateAPIKey(
provider: Provider,
apiKey: string
): Promise<APIValidationResult> {
if (!apiKey || apiKey.trim().length === 0) {
return { isValid: false, error: "API key is required" };
}

switch (provider) {
case "openai":
return validateOpenAI(apiKey);
case "google":
return validateGoogle(apiKey);
case "openrouter":
return validateOpenRouter(apiKey);
default:
return { isValid: false, error: "Unknown provider" };
}
}