feat: allow multiple API keys (#186)

* feat: allow multiple API keys

* chore: cleanup

* chore: upgrade pipecat

* feat: make default api_key as list
This commit is contained in:
Abhishek 2026-03-10 15:17:40 +05:30 committed by GitHub
parent 162bfabac3
commit 57e8768e0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 174 additions and 137 deletions

View file

@ -71,10 +71,10 @@ async def get_auth_user(
class UserConfigurationRequestResponseSchema(BaseModel):
llm: dict[str, Union[str, float]] | None = None
tts: dict[str, Union[str, float]] | None = None
stt: dict[str, Union[str, float]] | None = None
embeddings: dict[str, Union[str, float]] | None = None
llm: dict[str, Union[str, float, list[str]]] | None = None
tts: dict[str, Union[str, float, list[str]]] | None = None
stt: dict[str, Union[str, float, list[str]]] | None = None
embeddings: dict[str, Union[str, float, list[str]]] | None = None
test_phone_number: str | None = None
timezone: str | None = None
organization_pricing: dict[str, Union[float, str, bool]] | None = None

View file

@ -251,18 +251,18 @@ async def create_user_configuration_with_mps_key(
configuration = {
"llm": {
"provider": ServiceProviders.DOGRAH.value,
"api_key": service_key,
"api_key": [service_key],
"model": "default",
},
"tts": {
"provider": ServiceProviders.DOGRAH.value,
"api_key": service_key,
"api_key": [service_key],
"model": "default",
"voice": "default",
},
"stt": {
"provider": ServiceProviders.DOGRAH.value,
"api_key": service_key,
"api_key": [service_key],
"model": "default",
},
}

View file

@ -41,6 +41,36 @@ def is_mask_of(masked: str, real_key: str) -> bool:
return mask_key(real_key) == masked
def resolve_masked_api_keys(
incoming: str | list[str], existing: str | list[str]
) -> str | list[str]:
"""Resolve masked API keys against existing real keys.
For each incoming key, if it matches the mask of an existing key, the real
key is restored. New (unmasked) keys are kept as-is. This handles adds,
removes, reorders, and partial replacements correctly.
"""
if isinstance(incoming, str) and isinstance(existing, str):
return existing if is_mask_of(incoming, existing) else incoming
existing_list = existing if isinstance(existing, list) else [existing]
incoming_list = incoming if isinstance(incoming, list) else [incoming]
resolved: list[str] = []
used: set[int] = set()
for key in incoming_list:
matched = False
for i, real in enumerate(existing_list):
if i not in used and is_mask_of(key, real):
resolved.append(real)
used.add(i)
matched = True
break
if not matched:
resolved.append(key)
return resolved
# ---------------------------------------------------------------------------
# High-level helpers for UserConfiguration objects
# ---------------------------------------------------------------------------
@ -53,7 +83,11 @@ def _mask_service(service_cfg: Optional[ServiceConfig]) -> Optional[Dict[str, An
# Work on a dict copy so we don't mutate original models
data = service_cfg.model_dump()
if "api_key" in data and data["api_key"]:
data["api_key"] = mask_key(data["api_key"])
raw = data["api_key"]
if isinstance(raw, list):
data["api_key"] = [mask_key(k) for k in raw]
else:
data["api_key"] = mask_key(raw)
return data

View file

@ -7,7 +7,7 @@ stored, while honouring masked API keys.
from typing import Dict
from api.schemas.user_configuration import UserConfiguration
from api.services.configuration.masking import is_mask_of
from api.services.configuration.masking import resolve_masked_api_keys
SERVICE_FIELDS = ("llm", "tts", "stt", "embeddings")
@ -50,12 +50,10 @@ def merge_user_configurations(
if not provider_changed:
# conditional preservation of api_key
if incoming_api_key is not None:
if (
old_cfg
and "api_key" in old_cfg
and is_mask_of(incoming_api_key, old_cfg["api_key"])
):
incoming_cfg["api_key"] = old_cfg["api_key"]
if old_cfg and "api_key" in old_cfg:
incoming_cfg["api_key"] = resolve_masked_api_keys(
incoming_api_key, old_cfg["api_key"]
)
else:
if "api_key" in old_cfg:
incoming_cfg["api_key"] = old_cfg["api_key"]

View file

@ -1,7 +1,9 @@
import random
from enum import Enum, auto
from typing import Annotated, Dict, Literal, Type, TypeVar, Union
from pydantic import BaseModel, Field, computed_field
from loguru import logger
from pydantic import BaseModel, Field, computed_field, field_validator
class ServiceType(Enum):
@ -38,7 +40,29 @@ class BaseServiceConfiguration(BaseModel):
ServiceProviders.DOGRAH,
# ServiceProviders.SARVAM,
]
api_key: str
api_key: str | list[str]
@field_validator("api_key")
@classmethod
def validate_api_key(cls, v):
if isinstance(v, list) and len(v) == 0:
raise ValueError("api_key list must not be empty")
return v
def __getattribute__(self, name: str):
if name == "api_key":
value = super().__getattribute__(name)
if isinstance(value, list):
return random.choice(value)
return value
return super().__getattribute__(name)
def get_all_api_keys(self) -> list[str]:
"""Get all API keys as a list (bypasses random selection)."""
value = super().__getattribute__("api_key")
if isinstance(value, list):
return list(value)
return [value]
class BaseLLMConfiguration(BaseServiceConfiguration):
@ -150,7 +174,6 @@ DOGRAH_LLM_MODELS = ["default", "accurate", "fast", "lite", "zen"]
class OpenAILLMService(BaseLLMConfiguration):
provider: Literal[ServiceProviders.OPENAI] = ServiceProviders.OPENAI
model: str = Field(default="gpt-4.1", json_schema_extra={"examples": OPENAI_MODELS})
api_key: str
@register_llm
@ -159,7 +182,6 @@ class GoogleLLMService(BaseLLMConfiguration):
model: str = Field(
default="gemini-2.0-flash", json_schema_extra={"examples": GOOGLE_MODELS}
)
api_key: str
@register_llm
@ -168,7 +190,6 @@ class GroqLLMService(BaseLLMConfiguration):
model: str = Field(
default="llama-3.3-70b-versatile", json_schema_extra={"examples": GROQ_MODELS}
)
api_key: str
@register_llm
@ -177,7 +198,7 @@ class OpenRouterLLMConfiguration(BaseLLMConfiguration):
model: str = Field(
default="openai/gpt-4.1", json_schema_extra={"examples": OPENROUTER_MODELS}
)
api_key: str
base_url: str = Field(default="https://openrouter.ai/api/v1")
@ -187,7 +208,7 @@ class AzureLLMService(BaseLLMConfiguration):
model: str = Field(
default="gpt-4.1-mini", json_schema_extra={"examples": AZURE_MODELS}
)
api_key: str
endpoint: str
@ -197,7 +218,6 @@ class DograhLLMService(BaseLLMConfiguration):
model: str = Field(
default="default", json_schema_extra={"examples": DOGRAH_LLM_MODELS}
)
api_key: str
LLMConfig = Annotated[
@ -219,7 +239,6 @@ LLMConfig = Annotated[
class DeepgramTTSConfiguration(BaseServiceConfiguration):
provider: Literal[ServiceProviders.DEEPGRAM] = ServiceProviders.DEEPGRAM
voice: str = "aura-2-helena-en"
api_key: str
@computed_field
@property
@ -247,7 +266,6 @@ class ElevenlabsTTSConfiguration(BaseServiceConfiguration):
default="eleven_flash_v2_5",
json_schema_extra={"examples": ELEVENLABS_TTS_MODELS},
)
api_key: str
OPENAI_TTS_MODELS = ["gpt-4o-mini-tts"]
@ -260,7 +278,6 @@ class OpenAITTSService(BaseTTSConfiguration):
default="gpt-4o-mini-tts", json_schema_extra={"examples": OPENAI_TTS_MODELS}
)
voice: str = "alloy"
api_key: str
DOGRAH_TTS_MODELS = ["default"]
@ -274,7 +291,6 @@ class DograhTTSService(BaseTTSConfiguration):
)
voice: str = "default"
speed: float = Field(default=1.0, ge=0.5, le=2.0, description="Speed of the voice")
api_key: str
CARTESIA_TTS_MODELS = ["sonic-3"]
@ -287,7 +303,6 @@ class CartesiaTTSConfiguration(BaseTTSConfiguration):
default="sonic-3", json_schema_extra={"examples": CARTESIA_TTS_MODELS}
)
voice: str = Field(default="3faa81ae-d3d8-4ab1-9e44-e50e46d33c30")
api_key: str
SARVAM_TTS_MODELS = ["bulbul:v2", "bulbul:v3"]
@ -376,7 +391,6 @@ class SarvamTTSConfiguration(BaseTTSConfiguration):
language: str = Field(
default="hi-IN", json_schema_extra={"examples": SARVAM_LANGUAGES}
)
api_key: str
TTSConfig = Annotated[
@ -496,7 +510,6 @@ class DeepgramSTTConfiguration(BaseSTTConfiguration):
},
},
)
api_key: str
CARTESIA_STT_MODELS = ["ink-whisper"]
@ -508,7 +521,6 @@ class CartesiaSTTConfiguration(BaseSTTConfiguration):
model: str = Field(
default="ink-whisper", json_schema_extra={"examples": CARTESIA_STT_MODELS}
)
api_key: str
OPENAI_STT_MODELS = ["gpt-4o-transcribe"]
@ -520,7 +532,6 @@ class OpenAISTTConfiguration(BaseSTTConfiguration):
model: str = Field(
default="gpt-4o-transcribe", json_schema_extra={"examples": OPENAI_STT_MODELS}
)
api_key: str
# Dograh STT Service
@ -537,7 +548,6 @@ class DograhSTTService(BaseSTTConfiguration):
language: str = Field(
default="multi", json_schema_extra={"examples": DOGRAH_STT_LANGUAGES}
)
api_key: str
# Sarvam STT Service
@ -553,7 +563,6 @@ class SarvamSTTConfiguration(BaseSTTConfiguration):
language: str = Field(
default="hi-IN", json_schema_extra={"examples": SARVAM_LANGUAGES}
)
api_key: str
# Speechmatics STT Service
@ -593,7 +602,6 @@ class SpeechmaticsSTTConfiguration(BaseSTTConfiguration):
language: str = Field(
default="en", json_schema_extra={"examples": SPEECHMATICS_STT_LANGUAGES}
)
api_key: str
STTConfig = Annotated[
@ -619,7 +627,6 @@ class OpenAIEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
default="text-embedding-3-small",
json_schema_extra={"examples": OPENAI_EMBEDDING_MODELS},
)
api_key: str
OPENROUTER_EMBEDDING_MODELS = ["openai/text-embedding-3-small"]
@ -632,7 +639,7 @@ class OpenRouterEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
default="openai/text-embedding-3-small",
json_schema_extra={"examples": OPENROUTER_EMBEDDING_MODELS},
)
api_key: str
base_url: str = Field(default="https://openrouter.ai/api/v1")

@ -1 +1 @@
Subproject commit efcd34192ce28530053e90aed67890644c71f1e1
Subproject commit 10e8ded96672b08503db48c3d34e8345b11be4a2

File diff suppressed because one or more lines are too long

View file

@ -202,10 +202,10 @@ export type CircuitBreakerConfigRequest = {
};
export type CircuitBreakerConfigResponse = {
enabled: boolean;
failure_threshold: number;
window_seconds: number;
min_calls_in_window: number;
enabled?: boolean;
failure_threshold?: number;
window_seconds?: number;
min_calls_in_window?: number;
};
/**
@ -1150,16 +1150,16 @@ export type UsageHistoryResponse = {
export type UserConfigurationRequestResponseSchema = {
llm?: {
[key: string]: string | number;
[key: string]: string | number | Array<string>;
} | null;
tts?: {
[key: string]: string | number;
[key: string]: string | number | Array<string>;
} | null;
stt?: {
[key: string]: string | number;
[key: string]: string | number | Array<string>;
} | null;
embeddings?: {
[key: string]: string | number;
[key: string]: string | number | Array<string>;
} | null;
test_phone_number?: string | null;
timezone?: string | null;
@ -1599,35 +1599,6 @@ export type HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWo
200: unknown;
};
export type HandleCloudonixAmdCallbackApiV1TelephonyCloudonixAmdCallbackWorkflowRunIdPostData = {
body?: never;
path: {
workflow_run_id: number;
};
query?: never;
url: '/api/v1/telephony/cloudonix/amd-callback/{workflow_run_id}';
};
export type HandleCloudonixAmdCallbackApiV1TelephonyCloudonixAmdCallbackWorkflowRunIdPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type HandleCloudonixAmdCallbackApiV1TelephonyCloudonixAmdCallbackWorkflowRunIdPostError = HandleCloudonixAmdCallbackApiV1TelephonyCloudonixAmdCallbackWorkflowRunIdPostErrors[keyof HandleCloudonixAmdCallbackApiV1TelephonyCloudonixAmdCallbackWorkflowRunIdPostErrors];
export type HandleCloudonixAmdCallbackApiV1TelephonyCloudonixAmdCallbackWorkflowRunIdPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData = {
body?: never;
headers?: {

View file

@ -1,5 +1,6 @@
"use client";
import { Plus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@ -171,13 +172,19 @@ export default function ServiceConfiguration() {
stt: "",
embeddings: ""
});
const [apiKeys, setApiKeys] = useState<Record<ServiceSegment, string[]>>({
llm: [""],
tts: [""],
stt: [""],
embeddings: [""],
});
const [isManualModelInput, setIsManualModelInput] = useState(false);
const [hasCheckedManualMode, setHasCheckedManualMode] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
formState: { },
reset,
getValues,
setValue,
@ -207,10 +214,24 @@ export default function ServiceConfiguration() {
embeddings: response.data.default_providers.embeddings
};
const loadedApiKeys: Record<ServiceSegment, string[]> = {
llm: [""],
tts: [""],
stt: [""],
embeddings: [""],
};
const setServicePropertyValues = (service: ServiceSegment) => {
if (userConfig?.[service]?.provider) {
Object.entries(userConfig?.[service]).forEach(([field, value]) => {
if (field !== "provider") {
if (field === "api_key") {
// Handle api_key separately — it can be string or string[]
if (Array.isArray(value)) {
loadedApiKeys[service] = value.length > 0 ? value : [""];
} else {
loadedApiKeys[service] = value ? [value as string] : [""];
}
} else if (field !== "provider") {
defaultValues[`${service}_${field}`] = value;
}
});
@ -236,6 +257,7 @@ export default function ServiceConfiguration() {
// Otherwise, Radix Select sees old values that don't match new provider's enum
// and calls onValueChange('') to clear "invalid" values
reset(defaultValues);
setApiKeys(loadedApiKeys);
setServiceProviders(selectedProviders);
};
fetchConfigurations();
@ -320,6 +342,7 @@ export default function ServiceConfiguration() {
preservedValues[`${service}_provider`] = providerName;
reset(preservedValues);
setServiceProviders(prev => ({ ...prev, [service]: providerName }));
setApiKeys(prev => ({ ...prev, [service]: [""] }));
// Reset manual model input when LLM provider changes
if (service === "llm") {
@ -332,23 +355,27 @@ export default function ServiceConfiguration() {
setApiError(null);
setIsSaving(true);
const userConfig: Record<ServiceSegment, Record<string, string | number>> = {
// Collect non-empty API keys per service
const getServiceApiKeys = (service: ServiceSegment): string[] =>
apiKeys[service].map(k => k.trim()).filter(k => k.length > 0);
const userConfig: Record<ServiceSegment, Record<string, string | number | string[]>> = {
llm: {
provider: serviceProviders.llm,
api_key: data.llm_api_key as string,
api_key: getServiceApiKeys("llm"),
model: data.llm_model as string
},
tts: {
provider: serviceProviders.tts,
api_key: data.tts_api_key as string
api_key: getServiceApiKeys("tts"),
},
stt: {
provider: serviceProviders.stt,
api_key: data.stt_api_key as string
api_key: getServiceApiKeys("stt"),
},
embeddings: {
provider: serviceProviders.embeddings,
api_key: data.embeddings_api_key as string,
api_key: getServiceApiKeys("embeddings"),
model: data.embeddings_model as string
}
};
@ -359,6 +386,7 @@ export default function ServiceConfiguration() {
const service = parts[0] as ServiceSegment;
const field = parts.slice(1).join('_');
if (field === "api_key") return; // handled via apiKeys state
if (userConfig[service] && !(field in userConfig[service])) {
(userConfig[service] as Record<string, string>)[field] = value as string;
}
@ -366,10 +394,10 @@ export default function ServiceConfiguration() {
// Build save config - only include embeddings if api_key is provided
const saveConfig: {
llm: Record<string, string | number>;
tts: Record<string, string | number>;
stt: Record<string, string | number>;
embeddings?: Record<string, string | number>;
llm: Record<string, string | number | string[]>;
tts: Record<string, string | number | string[]>;
stt: Record<string, string | number | string[]>;
embeddings?: Record<string, string | number | string[]>;
} = {
llm: userConfig.llm,
tts: userConfig.tts,
@ -377,7 +405,8 @@ export default function ServiceConfiguration() {
};
// Only include embeddings if user has configured it (has api_key)
if (userConfig.embeddings.api_key) {
const embeddingsKeys = getServiceApiKeys("embeddings");
if (embeddingsKeys.length > 0) {
saveConfig.embeddings = userConfig.embeddings;
}
@ -459,25 +488,53 @@ export default function ServiceConfiguration() {
</div>
)}
{/* API Key in bottom row */}
{/* API Key(s) */}
{currentProvider && providerSchema && providerSchema.properties.api_key && (
<div className="space-y-2">
<Label>API Key</Label>
<Input
type="text"
placeholder="Enter API key"
{...register(`${service}_api_key`, {
// Embeddings is optional, so don't require its api_key
required: service !== "embeddings" && providerSchema.required?.includes("api_key"),
})}
/>
{errors[`${service}_api_key`] && (
<p className="text-sm text-red-500">
{typeof errors[`${service}_api_key`]?.message === 'string'
? String(errors[`${service}_api_key`]?.message)
: "This field is required"}
</p>
)}
<Label>API Key(s)</Label>
{apiKeys[service].map((key, index) => (
<div key={index} className="flex gap-2">
<Input
type="text"
placeholder="Enter API key"
value={key}
onChange={(e) => {
const newKeys = [...apiKeys[service]];
newKeys[index] = e.target.value;
setApiKeys(prev => ({ ...prev, [service]: newKeys }));
}}
/>
{apiKeys[service].length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
onClick={() => {
setApiKeys(prev => ({
...prev,
[service]: prev[service].filter((_, i) => i !== index),
}));
}}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setApiKeys(prev => ({
...prev,
[service]: [...prev[service], ""],
}));
}}
>
<Plus className="h-4 w-4 mr-1" /> Add API Key
</Button>
</div>
)}
</div>

View file

@ -10,24 +10,6 @@ import type { AuthUser } from '@/lib/auth';
import { useAuth } from '@/lib/auth';
export type SaveUserConfigFunctionParams = {
llm?: {
[key: string]: string | number;
} | null;
tts?: {
[key: string]: string | number;
} | null;
stt?: {
[key: string]: string | number;
} | null;
embeddings?: {
[key: string]: string | number;
} | null;
test_phone_number?: string | null;
timezone?: string | null;
};
interface TeamPermission {
id: string;
}
@ -40,7 +22,7 @@ interface OrganizationPricing {
interface UserConfigContextType {
userConfig: UserConfigurationRequestResponseSchema | null;
saveUserConfig: (userConfig: SaveUserConfigFunctionParams) => Promise<void>;
saveUserConfig: (userConfig: UserConfigurationRequestResponseSchema) => Promise<void>;
loading: boolean;
error: Error | null;
refreshConfig: () => Promise<void>;
@ -139,7 +121,7 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
fetchUserConfig();
}, [auth.loading, auth.isAuthenticated]);
const saveUserConfig = useCallback(async (userConfigRequest: SaveUserConfigFunctionParams) => {
const saveUserConfig = useCallback(async (userConfigRequest: UserConfigurationRequestResponseSchema) => {
if (!authRef.current.isAuthenticated) throw new Error('No authentication available');
const response = await updateUserConfigurationsApiV1UserConfigurationsUserPut({
body: {