This commit is contained in:
Jeffrey Ip
2026-08-29 23:56:02 +08:00
parent 8de6b55863
commit 6452f7d7ee
134 changed files with 11359 additions and 3122 deletions
+25
View File
@@ -56,6 +56,31 @@ ANTHROPIC_API_KEY=
# =========================
OPENROUTER_API_KEY=
# =========================
# Speech providers (voice mode TTS/STT)
# =========================
# A USE_*_TTS / USE_*_STT flag picks the provider for each family, and the
# DEEPEVAL_*_MODEL vars pick the model name. Both default to OpenAI.
# ELEVENLABS_API_KEY=
# DEEPGRAM_API_KEY=
# CARTESIA_API_KEY=
# ASSEMBLYAI_API_KEY=
# Text-to-speech: pick at most one.
# USE_OPENAI_TTS=
# USE_ELEVENLABS_TTS=
# USE_CARTESIA_TTS=
# USE_DEEPGRAM_TTS=
# DEEPEVAL_TTS_MODEL=
# Speech-to-text: pick at most one.
# USE_OPENAI_STT=
# USE_ELEVENLABS_STT=
# USE_CARTESIA_STT=
# USE_DEEPGRAM_STT=
# USE_ASSEMBLYAI_STT=
# DEEPEVAL_STT_MODEL=
# =========================
# Confident
# =========================
+2 -2
View File
@@ -12,8 +12,8 @@ from deepeval.dataset import (
Persona,
)
from deepeval.models.llms import OpenAIModel
from deepeval.models.stt_models import OpenAISTTModel
from deepeval.models.tts_models import OpenAITTSModel
from deepeval.models.stt import OpenAISTTModel
from deepeval.models.tts import OpenAITTSModel
from deepeval.simulator import ConversationSimulator
from deepeval.voice import CallbackVoiceConnector, VoiceConfig
from deepeval.voice.timeline import build_audio_timeline
+2 -2
View File
@@ -59,8 +59,8 @@ from contextlib import contextmanager
from typing import Dict, List, Optional, Tuple
from deepeval.models.llms import OpenAIModel
from deepeval.models.stt_models import OpenAISTTModel
from deepeval.models.tts_models import OpenAITTSModel
from deepeval.models.stt import OpenAISTTModel
from deepeval.models.tts import OpenAITTSModel
from deepeval.test_case import Audio
from deepeval.voice.connectors import audio_utils
+6
View File
@@ -0,0 +1,6 @@
"""The root Typer app, kept apart from `main` so command modules can register
onto it without importing `main` back."""
import typer
app = typer.Typer(name="deepeval", no_args_is_help=True)
+40 -2
View File
@@ -40,6 +40,7 @@ from deepeval.key_handler import (
EmbeddingKeyValues,
KeyValues,
ModelKeyValues,
SpeechKeyValues,
)
# Friendly provider labels for the model classes `initialize_model` /
@@ -63,6 +64,15 @@ _PROVIDER_BY_CLASS = {
"AzureOpenAIEmbeddingModel": "Azure OpenAI",
"OllamaEmbeddingModel": "Ollama",
"LocalEmbeddingModel": "Local model",
"OpenAITTSModel": "OpenAI",
"ElevenLabsTTSModel": "ElevenLabs",
"CartesiaTTSModel": "Cartesia",
"DeepgramTTSModel": "Deepgram",
"OpenAISTTModel": "OpenAI",
"ElevenLabsSTTModel": "ElevenLabs",
"CartesiaSTTModel": "Cartesia",
"DeepgramSTTModel": "Deepgram",
"AssemblyAISTTModel": "AssemblyAI",
}
# Settings fields worth showing in the "configured settings" table when set.
@@ -78,6 +88,8 @@ _RELEVANT_MARKERS = (
"DEEPEVAL_DEFAULT_SAVE",
"DEEPEVAL_RESULTS_FOLDER",
"DEEPEVAL_VOICE_FOLDER",
"DEEPEVAL_TTS_MODEL",
"DEEPEVAL_STT_MODEL",
)
@@ -121,7 +133,12 @@ def _legacy_keystore() -> Tuple[Path, Dict[str, Any]]:
def _env_key_to_json_key() -> Dict[str, str]:
mapping: Dict[str, str] = {}
for enum in (KeyValues, ModelKeyValues, EmbeddingKeyValues):
for enum in (
KeyValues,
ModelKeyValues,
EmbeddingKeyValues,
SpeechKeyValues,
):
for member in enum:
mapping[member.name] = member.value
return mapping
@@ -312,6 +329,22 @@ def _models_section() -> Dict[str, Any]:
"(override with embedder=... in ContextConstructionConfig)"
)
# Reported from the selection alone: those constructors need an API key.
from deepeval.models.speech_selection import (
describe_stt_selection,
describe_tts_selection,
)
for key, (class_name, model_name) in (
("tts", describe_tts_selection()),
("stt", describe_stt_selection()),
):
result[key] = {
"provider": _PROVIDER_BY_CLASS.get(class_name, class_name),
"model": model_name or "provider default",
"used_by": "voice simulations",
}
return result
@@ -440,7 +473,12 @@ def diagnose_command(
# Default models (global, overridable per class)
models = report["default_models"]
table = _kv_table("Default models")
for label, key in (("LLM", "llm"), ("Embeddings", "embeddings")):
for label, key in (
("LLM", "llm"),
("Embeddings", "embeddings"),
("TTS", "tts"),
("STT", "stt"),
):
info = models[key]
if "error" in info:
value = f"[red]⚠ unusable until fixed: {info['error']}[/red]"
+7 -2371
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
"""Provider configuration commands, registered on import."""
from deepeval.cli.providers import llms, tts, stt # noqa: F401
+122
View File
@@ -0,0 +1,122 @@
"""Helpers shared by the `set-tts` / `set-stt` command families."""
from typing import List, Optional
import typer
from rich import print
from rich.markup import escape
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import SpeechKeyValues
# provider -> (display name, API key setting)
_SPEECH_PROVIDER_INFO = {
"openai": ("OpenAI", "OPENAI_API_KEY"),
"elevenlabs": ("ElevenLabs", "ELEVENLABS_API_KEY"),
"cartesia": ("Cartesia", "CARTESIA_API_KEY"),
"deepgram": ("Deepgram", "DEEPGRAM_API_KEY"),
"assemblyai": ("AssemblyAI", "ASSEMBLYAI_API_KEY"),
}
def _set_speech_provider(
*,
family: str,
provider: str,
model: Optional[str],
prompt_api_key: bool,
save: Optional[str],
quiet: bool,
) -> None:
label, key_field = _SPEECH_PROVIDER_INFO[provider]
flag = SpeechKeyValues[f"USE_{provider.upper()}_{family}"]
model_field = f"DEEPEVAL_{family}_MODEL"
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt(f"{label} API key", hide_input=True)
)
model = coerce_blank_to_none(model)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(flag)
if model is not None:
setattr(settings, model_field, model)
if api_key is not None:
setattr(settings, key_field, api_key)
handled, path, updates = edit_ctx.result
effective_model = getattr(settings, model_field)
model_phrase = (
f"`{escape(effective_model)}`"
if effective_model
else "with its default model"
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using {label} "
f"{model_phrase} for {family} in voice simulations."
),
)
def _unset_speech_provider(
*,
family: str,
flags: List[str],
clear_secrets: bool,
save: Optional[str],
quiet: bool,
) -> None:
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
for flag in flags:
setattr(settings, flag, None)
setattr(settings, f"DEEPEVAL_{family}_MODEL", None)
if clear_secrets:
# OPENAI_API_KEY is deliberately left alone: it is shared with the
# LLM providers, so clearing it here would reach outside speech.
for flag in flags:
_, key_field = _SPEECH_PROVIDER_INFO[
flag[len("USE_") : -len(f"_{family}")].lower()
]
if key_field != "OPENAI_API_KEY":
setattr(settings, key_field, None)
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg=(f"Removed {family} environment variables from {{path}}."),
tip_msg=None,
):
if is_openai_configured():
print(
f":raised_hands: OpenAI will still be used for {family} by "
"default because OPENAI_API_KEY is set."
)
else:
print(
f"The {family} provider has been unset. Voice simulations "
"fall back to OpenAI, which needs OPENAI_API_KEY."
)
+21
View File
@@ -0,0 +1,21 @@
"""LLM commands, plus the embedding commands of providers that serve both.
Import order is the order `deepeval --help` lists them in.
"""
from deepeval.cli.providers.llms import ( # noqa: F401
openai,
azure,
anthropic,
bedrock,
ollama,
local_model,
grok,
moonshot,
deepseek,
local_embeddings,
gemini,
litellm,
portkey,
openrouter,
)
+159
View File
@@ -0,0 +1,159 @@
"""`deepeval set-anthropic, unset-anthropic` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
ModelKeyValues,
)
@app.command(name="set-anthropic")
def set_anthropic_model_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for ANTHROPIC_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
cost_per_input_token: Optional[float] = typer.Option(
None,
"-i",
"--cost-per-input-token",
help=(
"USD per input token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
cost_per_output_token: Optional[float] = typer.Option(
None,
"-o",
"--cost-per-output-token",
help=(
"USD per output token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("Anthropic API key", hide_input=True)
)
model = coerce_blank_to_none(model)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_ANTHROPIC_MODEL)
if api_key is not None:
settings.ANTHROPIC_API_KEY = api_key
if model is not None:
settings.ANTHROPIC_MODEL_NAME = model
if cost_per_input_token is not None:
settings.ANTHROPIC_COST_PER_INPUT_TOKEN = cost_per_input_token
if cost_per_output_token is not None:
settings.ANTHROPIC_COST_PER_OUTPUT_TOKEN = cost_per_output_token
handled, path, updates = edit_ctx.result
effective_model = settings.ANTHROPIC_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"Anthropic model name is not set. Pass --model (or set ANTHROPIC_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using Anthropic `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-anthropic")
def unset_anthropic_model_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the Anthropic model related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove ANTHROPIC_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.USE_ANTHROPIC_MODEL = None
settings.ANTHROPIC_MODEL_NAME = None
settings.ANTHROPIC_COST_PER_INPUT_TOKEN = None
settings.ANTHROPIC_COST_PER_OUTPUT_TOKEN = None
if clear_secrets:
settings.ANTHROPIC_API_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed Anthropic model environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The Anthropic model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
+275
View File
@@ -0,0 +1,275 @@
"""`deepeval set-azure-openai, set-azure-openai-embedding, unset-azure-openai, unset-azure-openai-embedding` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
EmbeddingKeyValues,
ModelKeyValues,
)
@app.command(name="set-azure-openai")
def set_azure_openai_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider (e.g., `gpt-4.1`).",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for AZURE_OPENAI_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
base_url: Optional[str] = typer.Option(
None,
"-u",
"--base-url",
help="Override the API endpoint/base URL used by this provider.",
),
api_version: Optional[str] = typer.Option(
None,
"-v",
"--api-version",
help="Azure OpenAI API version (passed to the Azure OpenAI client).",
),
model_version: Optional[str] = typer.Option(
None, "-V", "--model-version", help="Azure model version"
),
deployment_name: Optional[str] = typer.Option(
None, "-d", "--deployment-name", help="Azure OpenAI deployment name"
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("Azure OpenAI API key", hide_input=True)
)
model = coerce_blank_to_none(model)
base_url = coerce_blank_to_none(base_url)
api_version = coerce_blank_to_none(api_version)
deployment_name = coerce_blank_to_none(deployment_name)
model_version = coerce_blank_to_none(model_version)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_AZURE_OPENAI)
if model is not None:
settings.AZURE_MODEL_NAME = model
if api_key is not None:
settings.AZURE_OPENAI_API_KEY = api_key
if base_url is not None:
settings.AZURE_OPENAI_ENDPOINT = base_url
if api_version is not None:
settings.OPENAI_API_VERSION = api_version
if deployment_name is not None:
settings.AZURE_DEPLOYMENT_NAME = deployment_name
if model_version is not None:
settings.AZURE_MODEL_VERSION = model_version
handled, path, updates = edit_ctx.result
effective_model = settings.AZURE_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"Azure OpenAI model name is not set. Pass --model (or set AZURE_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using Azure OpenAI's `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-azure-openai")
def unset_azure_openai_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the Azure OpenAI–related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove AZURE_OPENAI_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.AZURE_OPENAI_ENDPOINT = None
settings.OPENAI_API_VERSION = None
settings.AZURE_DEPLOYMENT_NAME = None
settings.AZURE_MODEL_NAME = None
settings.AZURE_MODEL_VERSION = None
settings.USE_AZURE_OPENAI = None
if clear_secrets:
settings.AZURE_OPENAI_API_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed Azure OpenAI environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"Azure OpenAI has been unset. No active provider is configured. Set one with the CLI, or add credentials to .env[.local]."
)
@app.command(name="set-azure-openai-embedding")
def set_azure_openai_embedding_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider (e.g., `gpt-4.1`).",
),
deployment_name: Optional[str] = typer.Option(
None,
"-d",
"--deployment-name",
help="Azure embedding deployment name",
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
model = coerce_blank_to_none(model)
deployment_name = coerce_blank_to_none(deployment_name)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(
EmbeddingKeyValues.USE_AZURE_OPENAI_EMBEDDING
)
if model is not None:
settings.AZURE_EMBEDDING_MODEL_NAME = model
if deployment_name is not None:
settings.AZURE_EMBEDDING_DEPLOYMENT_NAME = deployment_name
handled, path, updates = edit_ctx.result
effective_model = settings.AZURE_EMBEDDING_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"Azure OpenAI embedding model name is not set. Pass --model (or set AZURE_EMBEDDING_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using Azure OpenAI embedding model `{escape(effective_model)}` for all evals that require text embeddings."
),
)
@app.command(name="unset-azure-openai-embedding")
def unset_azure_openai_embedding_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the Azure OpenAI embedding related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.AZURE_EMBEDDING_MODEL_NAME = None
settings.AZURE_EMBEDDING_DEPLOYMENT_NAME = None
settings.USE_AZURE_OPENAI_EMBEDDING = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed Azure OpenAI embedding environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"Azure OpenAI embedding has been unset. No active provider is configured. Set one with the CLI, or add credentials to .env[.local]."
)
+174
View File
@@ -0,0 +1,174 @@
"""`deepeval set-bedrock, unset-bedrock` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
ModelKeyValues,
)
@app.command(name="set-bedrock")
def set_bedrock_model_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider",
),
prompt_credentials: bool = typer.Option(
False,
"-a",
"--prompt-credentials",
help=(
"Prompt for AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (secret access key input is hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, credentials are written to dotenv in plaintext."
),
),
region: Optional[str] = typer.Option(
None,
"-r",
"--region",
help="AWS region for bedrock (e.g., `us-east-1`).",
),
cost_per_input_token: Optional[float] = typer.Option(
None,
"-i",
"--cost-per-input-token",
help=(
"USD per input token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
cost_per_output_token: Optional[float] = typer.Option(
None,
"-o",
"--cost-per-output-token",
help=(
"USD per output token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
access_key_id = None
secret_access_key = None
if prompt_credentials:
access_key_id = coerce_blank_to_none(typer.prompt("AWS Access key Id"))
secret_access_key = coerce_blank_to_none(
typer.prompt("AWS Secret Access key", hide_input=True)
)
model = coerce_blank_to_none(model)
region = coerce_blank_to_none(region)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_AWS_BEDROCK_MODEL)
if access_key_id is not None:
settings.AWS_ACCESS_KEY_ID = access_key_id
if secret_access_key is not None:
settings.AWS_SECRET_ACCESS_KEY = secret_access_key
if model is not None:
settings.AWS_BEDROCK_MODEL_NAME = model
if region is not None:
settings.AWS_BEDROCK_REGION = region
if cost_per_input_token is not None:
settings.AWS_BEDROCK_COST_PER_INPUT_TOKEN = cost_per_input_token
if cost_per_output_token is not None:
settings.AWS_BEDROCK_COST_PER_OUTPUT_TOKEN = cost_per_output_token
handled, path, updates = edit_ctx.result
effective_model = settings.AWS_BEDROCK_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"AWS Bedrock model name is not set. Pass --model (or set AWS_BEDROCK_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using AWS Bedrock `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-bedrock")
def unset_bedrock_model_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the AWS Bedrock model related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.USE_AWS_BEDROCK_MODEL = None
settings.AWS_BEDROCK_MODEL_NAME = None
settings.AWS_BEDROCK_REGION = None
settings.AWS_BEDROCK_COST_PER_INPUT_TOKEN = None
settings.AWS_BEDROCK_COST_PER_OUTPUT_TOKEN = None
if clear_secrets:
settings.AWS_ACCESS_KEY_ID = None
settings.AWS_SECRET_ACCESS_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed AWS Bedrock model environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The AWS Bedrock model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
+159
View File
@@ -0,0 +1,159 @@
"""`deepeval set-deepseek, unset-deepseek` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
ModelKeyValues,
)
@app.command(name="set-deepseek")
def set_deepseek_model_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for DEEPSEEK_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
cost_per_input_token: Optional[float] = typer.Option(
None,
"-i",
"--cost-per-input-token",
help=(
"USD per input token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
cost_per_output_token: Optional[float] = typer.Option(
None,
"-o",
"--cost-per-output-token",
help=(
"USD per output token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("DeepSeek API key", hide_input=True)
)
model = coerce_blank_to_none(model)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_DEEPSEEK_MODEL)
if model is not None:
settings.DEEPSEEK_MODEL_NAME = model
if api_key is not None:
settings.DEEPSEEK_API_KEY = api_key
if cost_per_input_token is not None:
settings.DEEPSEEK_COST_PER_INPUT_TOKEN = cost_per_input_token
if cost_per_output_token is not None:
settings.DEEPSEEK_COST_PER_OUTPUT_TOKEN = cost_per_output_token
handled, path, updates = edit_ctx.result
effective_model = settings.DEEPSEEK_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"DeepSeek model name is not set. Pass --model (or set DEEPSEEK_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using DeepSeek `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-deepseek")
def unset_deepseek_model_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the DeepSeek model related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove DEEPSEEK_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.USE_DEEPSEEK_MODEL = None
settings.DEEPSEEK_MODEL_NAME = None
settings.DEEPSEEK_COST_PER_INPUT_TOKEN = None
settings.DEEPSEEK_COST_PER_OUTPUT_TOKEN = None
if clear_secrets:
settings.DEEPSEEK_API_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed DeepSeek model environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The DeepSeek model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
+179
View File
@@ -0,0 +1,179 @@
"""`deepeval set-gemini, unset-gemini` commands."""
import typer
from pathlib import Path
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
load_service_account_key_file,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
ModelKeyValues,
)
@app.command(name="set-gemini")
def set_gemini_model_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for GOOGLE_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
project: Optional[str] = typer.Option(
None,
"-p",
"--project",
help="GCP project ID (used by Vertex AI / Gemini when applicable).",
),
location: Optional[str] = typer.Option(
None,
"-l",
"--location",
help="GCP location/region for Vertex AI (e.g., `us-central1`).",
),
service_account_file: Optional[Path] = typer.Option(
None,
"-S",
"--service-account-file",
help=("Path to a Google service account JSON key file."),
exists=True,
dir_okay=False,
readable=True,
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("Google API key", hide_input=True)
)
model = coerce_blank_to_none(model)
project = coerce_blank_to_none(project)
location = coerce_blank_to_none(location)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_GEMINI_MODEL)
if model is not None:
settings.GEMINI_MODEL_NAME = model
if project is not None:
settings.GOOGLE_CLOUD_PROJECT = project
if location is not None:
settings.GOOGLE_CLOUD_LOCATION = location
if service_account_file is not None:
settings.GOOGLE_SERVICE_ACCOUNT_KEY = load_service_account_key_file(
service_account_file
)
if api_key is not None:
settings.GOOGLE_API_KEY = api_key
settings.GOOGLE_GENAI_USE_VERTEXAI = False
elif (
project is not None
or location is not None
or service_account_file is not None
):
settings.GOOGLE_GENAI_USE_VERTEXAI = True
handled, path, updates = edit_ctx.result
effective_model = settings.GEMINI_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"Gemini model name is not set. Pass --model (or set GEMINI_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using Gemini `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-gemini")
def unset_gemini_model_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the Gemini related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove GOOGLE_API_KEY and GOOGLE_SERVICE_ACCOUNT_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.USE_GEMINI_MODEL = None
settings.GOOGLE_GENAI_USE_VERTEXAI = None
settings.GOOGLE_CLOUD_PROJECT = None
settings.GOOGLE_CLOUD_LOCATION = None
settings.GEMINI_MODEL_NAME = None
if clear_secrets:
settings.GOOGLE_API_KEY = None
settings.GOOGLE_SERVICE_ACCOUNT_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed Gemini model environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The Gemini model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
+158
View File
@@ -0,0 +1,158 @@
"""`deepeval set-grok, unset-grok` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
ModelKeyValues,
)
@app.command(name="set-grok")
def set_grok_model_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for GROK_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
cost_per_input_token: Optional[float] = typer.Option(
None,
"-i",
"--cost-per-input-token",
help=(
"USD per input token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
cost_per_output_token: Optional[float] = typer.Option(
None,
"-o",
"--cost-per-output-token",
help=(
"USD per output token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("Grok API key", hide_input=True)
)
model = coerce_blank_to_none(model)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_GROK_MODEL)
if api_key is not None:
settings.GROK_API_KEY = api_key
if model is not None:
settings.GROK_MODEL_NAME = model
if cost_per_input_token is not None:
settings.GROK_COST_PER_INPUT_TOKEN = cost_per_input_token
if cost_per_output_token is not None:
settings.GROK_COST_PER_OUTPUT_TOKEN = cost_per_output_token
handled, path, updates = edit_ctx.result
effective_model = settings.GROK_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"Grok model name is not set. Pass --model (or set GROK_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using Grok `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-grok")
def unset_grok_model_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the Grok model related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove GROK_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.USE_GROK_MODEL = None
settings.GROK_MODEL_NAME = None
settings.GROK_COST_PER_INPUT_TOKEN = None
settings.GROK_COST_PER_OUTPUT_TOKEN = None
if clear_secrets:
settings.GROK_API_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed Grok model environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The Grok model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
+172
View File
@@ -0,0 +1,172 @@
"""`deepeval set-litellm, unset-litellm` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
ModelKeyValues,
)
@app.command(name="set-litellm")
def set_litellm_model_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for LITELLM_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
base_url: Optional[str] = typer.Option(
None,
"-u",
"--base-url",
help="Override the API endpoint/base URL used by this provider.",
),
proxy_prompt_api_key: bool = typer.Option(
False,
"-K",
"--proxy-prompt-api-key",
help=(
"Prompt for LITELLM_PROXY_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
proxy_base_url: Optional[str] = typer.Option(
None,
"-U",
"--proxy-base-url",
help="Override the LITELLM_PROXY_API_BASE URL (useful for proxies, gateways, or self-hosted endpoints).",
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("LiteLLM API key", hide_input=True)
)
proxy_api_key = None
if proxy_prompt_api_key:
proxy_api_key = coerce_blank_to_none(
typer.prompt("LiteLLM Proxy API key", hide_input=True)
)
model = coerce_blank_to_none(model)
base_url = coerce_blank_to_none(base_url)
proxy_base_url = coerce_blank_to_none(proxy_base_url)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_LITELLM)
if model is not None:
settings.LITELLM_MODEL_NAME = model
if api_key is not None:
settings.LITELLM_API_KEY = api_key
if base_url is not None:
settings.LITELLM_API_BASE = base_url
if proxy_api_key is not None:
settings.LITELLM_PROXY_API_KEY = proxy_api_key
if proxy_base_url is not None:
settings.LITELLM_PROXY_API_BASE = proxy_base_url
handled, path, updates = edit_ctx.result
effective_model = settings.LITELLM_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"LiteLLM model name is not set. Pass --model (or set LITELLM_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using LiteLLM `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-litellm")
def unset_litellm_model_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the LiteLLM related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove LITELLM_API_KEY and LITELLM_PROXY_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.USE_LITELLM = None
settings.LITELLM_MODEL_NAME = None
settings.LITELLM_API_BASE = None
settings.LITELLM_PROXY_API_BASE = None
if clear_secrets:
settings.LITELLM_API_KEY = None
settings.LITELLM_PROXY_API_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed LiteLLM model environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The LiteLLM model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
@@ -0,0 +1,144 @@
"""`deepeval set-local-embeddings, unset-local-embeddings` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
EmbeddingKeyValues,
)
@app.command(name="set-local-embeddings")
def set_local_embeddings_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for LOCAL_EMBEDDING_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
base_url: Optional[str] = typer.Option(
None,
"-u",
"--base-url",
help="Override the API endpoint/base URL used by this provider.",
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("Local Embedding Model API key", hide_input=True)
)
model = coerce_blank_to_none(model)
base_url = coerce_blank_to_none(base_url)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(EmbeddingKeyValues.USE_LOCAL_EMBEDDINGS)
if model is not None:
settings.LOCAL_EMBEDDING_MODEL_NAME = model
if base_url is not None:
settings.LOCAL_EMBEDDING_BASE_URL = base_url
if api_key is not None:
settings.LOCAL_EMBEDDING_API_KEY = api_key
handled, path, updates = edit_ctx.result
effective_model = settings.LOCAL_EMBEDDING_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"Local embedding model name is not set. Pass --model (or set LOCAL_EMBEDDING_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using the local embedding model `{escape(effective_model)}` for all evals that require text embeddings."
),
)
@app.command(name="unset-local-embeddings")
def unset_local_embeddings_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the local embedding related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove LOCAL_MODEL_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.LOCAL_EMBEDDING_MODEL_NAME = None
settings.LOCAL_EMBEDDING_BASE_URL = None
settings.USE_LOCAL_EMBEDDINGS = None
if clear_secrets:
settings.LOCAL_EMBEDDING_API_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed local embedding environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The local embeddings model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
+154
View File
@@ -0,0 +1,154 @@
"""`deepeval set-local-model, unset-local-model` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
ModelKeyValues,
)
@app.command(name="set-local-model")
def set_local_model_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for LOCAL_MODEL_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
base_url: Optional[str] = typer.Option(
None,
"-u",
"--base-url",
help="Override the API endpoint/base URL used by this provider.",
),
model_format: Optional[str] = typer.Option(
None,
"-f",
"--format",
help="Format of the response from the local model (default: json)",
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("Local Model API key", hide_input=True)
)
model = coerce_blank_to_none(model)
base_url = coerce_blank_to_none(base_url)
model_format = coerce_blank_to_none(model_format)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_LOCAL_MODEL)
if model is not None:
settings.LOCAL_MODEL_NAME = model
if base_url is not None:
settings.LOCAL_MODEL_BASE_URL = base_url
if api_key is not None:
settings.LOCAL_MODEL_API_KEY = api_key
if model_format is not None:
settings.LOCAL_MODEL_FORMAT = model_format
handled, path, updates = edit_ctx.result
effective_model = settings.LOCAL_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"Local model name is not set. Pass --model (or set LOCAL_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using a local model `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-local-model")
def unset_local_model_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the local model related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove LOCAL_MODEL_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
if clear_secrets:
settings.LOCAL_MODEL_API_KEY = None
settings.LOCAL_MODEL_NAME = None
settings.LOCAL_MODEL_BASE_URL = None
settings.LOCAL_MODEL_FORMAT = None
settings.USE_LOCAL_MODEL = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed local model environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The local model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
+158
View File
@@ -0,0 +1,158 @@
"""`deepeval set-moonshot, unset-moonshot` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
ModelKeyValues,
)
@app.command(name="set-moonshot")
def set_moonshot_model_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for MOONSHOT_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
cost_per_input_token: Optional[float] = typer.Option(
None,
"-i",
"--cost-per-input-token",
help=(
"USD per input token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
cost_per_output_token: Optional[float] = typer.Option(
None,
"-o",
"--cost-per-output-token",
help=(
"USD per output token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("Moonshot API key", hide_input=True)
)
model = coerce_blank_to_none(model)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_MOONSHOT_MODEL)
if model is not None:
settings.MOONSHOT_MODEL_NAME = model
if api_key is not None:
settings.MOONSHOT_API_KEY = api_key
if cost_per_input_token is not None:
settings.MOONSHOT_COST_PER_INPUT_TOKEN = cost_per_input_token
if cost_per_output_token is not None:
settings.MOONSHOT_COST_PER_OUTPUT_TOKEN = cost_per_output_token
handled, path, updates = edit_ctx.result
effective_model = settings.MOONSHOT_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"Moonshot model name is not set. Pass --model (or set MOONSHOT_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using Moonshot `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-moonshot")
def unset_moonshot_model_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the Moonshot model related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove MOONSHOT_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.USE_MOONSHOT_MODEL = None
settings.MOONSHOT_MODEL_NAME = None
settings.MOONSHOT_COST_PER_INPUT_TOKEN = None
settings.MOONSHOT_COST_PER_OUTPUT_TOKEN = None
if clear_secrets:
settings.MOONSHOT_API_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed Moonshot model environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The Moonshot model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
+240
View File
@@ -0,0 +1,240 @@
"""`deepeval set-ollama, set-ollama-embeddings, unset-ollama, unset-ollama-embeddings` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
EmbeddingKeyValues,
ModelKeyValues,
)
@app.command(name="set-ollama")
def set_ollama_model_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider",
),
base_url: str = typer.Option(
"http://localhost:11434",
"-u",
"--base-url",
help="Override the API endpoint/base URL used by this provider.",
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
model = coerce_blank_to_none(model)
base_url = coerce_blank_to_none(base_url)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_LOCAL_MODEL)
settings.LOCAL_MODEL_API_KEY = "ollama"
if model is not None:
settings.OLLAMA_MODEL_NAME = model
if base_url is not None:
settings.LOCAL_MODEL_BASE_URL = base_url
handled, path, updates = edit_ctx.result
effective_model = settings.OLLAMA_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"Ollama model name is not set. Pass --model (or set OLLAMA_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using a local Ollama model `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-ollama")
def unset_ollama_model_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the Ollama related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove LOCAL_MODEL_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
if clear_secrets:
settings.LOCAL_MODEL_API_KEY = None
settings.OLLAMA_MODEL_NAME = None
settings.LOCAL_MODEL_BASE_URL = None
settings.USE_LOCAL_MODEL = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed local Ollama environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The local Ollama model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
@app.command(name="set-ollama-embeddings")
def set_ollama_embeddings_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider.",
),
base_url: str = typer.Option(
"http://localhost:11434",
"-u",
"--base-url",
help="Override the API endpoint/base URL used by this provider.",
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
model = coerce_blank_to_none(model)
base_url = coerce_blank_to_none(base_url)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(EmbeddingKeyValues.USE_LOCAL_EMBEDDINGS)
settings.LOCAL_EMBEDDING_API_KEY = "ollama"
if model is not None:
settings.LOCAL_EMBEDDING_MODEL_NAME = model
if base_url is not None:
settings.LOCAL_EMBEDDING_BASE_URL = base_url
handled, path, updates = edit_ctx.result
effective_model = settings.LOCAL_EMBEDDING_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"Ollama embedding model name is not set. Pass --model (or set LOCAL_EMBEDDING_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using the Ollama embedding model `{escape(effective_model)}` for all evals that require text embeddings."
),
)
@app.command(name="unset-ollama-embeddings")
def unset_ollama_embeddings_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the Ollama embedding related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove LOCAL_EMBEDDING_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
if clear_secrets:
settings.LOCAL_EMBEDDING_API_KEY = None
settings.LOCAL_EMBEDDING_MODEL_NAME = None
settings.LOCAL_EMBEDDING_BASE_URL = None
settings.USE_LOCAL_EMBEDDINGS = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed local Ollama embedding environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: Regular OpenAI embeddings will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The local Ollama embedding model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
+214
View File
@@ -0,0 +1,214 @@
"""`deepeval set-openai, unset-openai` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
ModelKeyValues,
)
@app.command(name="set-openai")
def set_openai_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider (e.g., `gpt-4.1`).",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for OPENAI_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
cost_per_input_token: Optional[float] = typer.Option(
None,
"-i",
"--cost-per-input-token",
help=(
"USD per input token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
cost_per_output_token: Optional[float] = typer.Option(
None,
"-o",
"--cost-per-output-token",
help=(
"USD per output token override used for cost tracking. Preconfigured for known models; "
"REQUIRED if you use a custom/unknown model."
),
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
"""
Configure OpenAI as the active LLM provider.
What this does:
- Sets the active provider flag to `USE_OPENAI_MODEL`.
- Persists the selected model name and any cost overrides in the JSON store.
- secrets are never written to `.deepeval/.deepeval` (JSON).
Pricing rules:
- If `model` is a known OpenAI model, you may omit costs (built‑in pricing is used).
- If `model` is custom/unsupported, you must provide both
`--cost-per-input-token` and `--cost-per-output-token`.
Secrets & saving:
- If you run with --prompt-api-key, DeepEval will set OPENAI_API_KEY for this session.
- If --save=dotenv[:path] is used (or DEEPEVAL_DEFAULT_SAVE is set), the key will be written to that dotenv file (plaintext).
Secrets are never written to .deepeval/.deepeval (legacy JSON store).
Args:
--model: OpenAI model name, such as `gpt-4o-mini`.
--prompt-api-key: Prompt interactively for OPENAI_API_KEY (input hidden). Avoids putting secrets on the command line (shell history/process args). Not suitable for CI.
--cost-per-input-token: USD per input token (optional for known models).
--cost-per-output-token: USD per output token (optional for known models).
--save: Persist config (and supported secrets) to a dotenv file; format `dotenv[:path]`.
--quiet: Suppress printing to the terminal.
Example:
deepeval set-openai \\
--model gpt-4o-mini \\
--cost-per-input-token 0.0005 \\
--cost-per-output-token 0.0015 \\
--save dotenv:.env.local
"""
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("OpenAI API key", hide_input=True)
)
model = coerce_blank_to_none(model)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_OPENAI_MODEL)
if model is not None:
settings.OPENAI_MODEL_NAME = model
if api_key is not None:
settings.OPENAI_API_KEY = api_key
if cost_per_input_token is not None:
settings.OPENAI_COST_PER_INPUT_TOKEN = cost_per_input_token
if cost_per_output_token is not None:
settings.OPENAI_COST_PER_OUTPUT_TOKEN = cost_per_output_token
handled, path, updates = edit_ctx.result
effective_model = settings.OPENAI_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"OpenAI model name is not set. Pass --model (or set OPENAI_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using OpenAI's `{escape(effective_model)}` "
"for all evals that require an LLM."
),
)
@app.command(name="unset-openai")
def unset_openai_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the OpenAI related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove OPENAI_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
"""
Unset OpenAI as the active provider.
Behavior:
- Removes OpenAI keys (model, costs, toggle) from the JSON store.
- If `--save` is provided, removes those keys from the specified dotenv file.
- After unsetting, if `OPENAI_API_KEY` is still set in the environment,
OpenAI may still be usable by default. Otherwise, no active provider is configured.
Args:
--save: Remove OpenAI keys from the given dotenv file as well.
--clear-secrets: Removes OPENAI_API_KEY from the dotenv store
--quiet: Suppress printing to the terminal
Example:
deepeval unset-openai --save dotenv:.env.local
"""
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.OPENAI_MODEL_NAME = None
settings.OPENAI_COST_PER_INPUT_TOKEN = None
settings.OPENAI_COST_PER_OUTPUT_TOKEN = None
settings.USE_OPENAI_MODEL = None
if clear_secrets:
settings.OPENAI_API_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed OpenAI environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"OpenAI has been unset. No active provider is configured. "
"Set one with the CLI, or add credentials to .env[.local]."
)
+180
View File
@@ -0,0 +1,180 @@
"""`deepeval set-openrouter, unset-openrouter` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
ModelKeyValues,
)
@app.command(name="set-openrouter")
def set_openrouter_model_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider (e.g., `openai/gpt-4.1`).",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for OPENROUTER_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
base_url: Optional[str] = typer.Option(
None,
"-u",
"--base-url",
help="Override the API endpoint/base URL used by this provider (default: https://openrouter.ai/api/v1).",
),
temperature: Optional[float] = typer.Option(
None,
"-t",
"--temperature",
help="Override the global TEMPERATURE used by LLM providers (e.g., 0.0 for deterministic behavior).",
),
cost_per_input_token: Optional[float] = typer.Option(
None,
"-i",
"--cost-per-input-token",
help=(
"USD per input token used for cost tracking. "
"If unset and OpenRouter does not return pricing metadata, "
"costs will not be calculated."
),
),
cost_per_output_token: Optional[float] = typer.Option(
None,
"-o",
"--cost-per-output-token",
help=(
"USD per output token used for cost tracking. "
"If unset and OpenRouter does not return pricing metadata, "
"costs will not be calculated."
),
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("OpenRouter API key", hide_input=True)
)
model = coerce_blank_to_none(model)
base_url = coerce_blank_to_none(base_url)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_OPENROUTER_MODEL)
if model is not None:
settings.OPENROUTER_MODEL_NAME = model
if api_key is not None:
settings.OPENROUTER_API_KEY = api_key
if base_url is not None:
settings.OPENROUTER_BASE_URL = base_url
if temperature is not None:
settings.TEMPERATURE = temperature
if cost_per_input_token is not None:
settings.OPENROUTER_COST_PER_INPUT_TOKEN = cost_per_input_token
if cost_per_output_token is not None:
settings.OPENROUTER_COST_PER_OUTPUT_TOKEN = cost_per_output_token
handled, path, updates = edit_ctx.result
effective_model = settings.OPENROUTER_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"OpenRouter model name is not set. Pass --model (or set OPENROUTER_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using OpenRouter `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-openrouter")
def unset_openrouter_model_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the OpenRouter model related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove OPENROUTER_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.USE_OPENROUTER_MODEL = None
settings.OPENROUTER_MODEL_NAME = None
settings.OPENROUTER_BASE_URL = None
settings.OPENROUTER_COST_PER_INPUT_TOKEN = None
settings.OPENROUTER_COST_PER_OUTPUT_TOKEN = None
# Intentionally do NOT touch TEMPERATURE here; it's a global dial.
if clear_secrets:
settings.OPENROUTER_API_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed OpenRouter model environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The OpenRouter model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
+154
View File
@@ -0,0 +1,154 @@
"""`deepeval set-portkey, unset-portkey` commands."""
import typer
from typing import Optional
from rich import print
from rich.markup import escape
from deepeval.cli.app import app
from deepeval.cli.utils import (
coerce_blank_to_none,
handle_save_result as _handle_save_result,
is_openai_configured,
)
from deepeval.config.settings import get_settings
from deepeval.key_handler import (
ModelKeyValues,
)
@app.command(name="set-portkey")
def set_portkey_model_env(
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier to use for this provider",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for PORTKEY_API_KEY (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
base_url: Optional[str] = typer.Option(
None,
"-u",
"--base-url",
help="Override the API endpoint/base URL used by this provider.",
),
provider: Optional[str] = typer.Option(
None,
"-P",
"--provider",
help="Override the PORTKEY_PROVIDER_NAME.",
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
api_key = None
if prompt_api_key:
api_key = coerce_blank_to_none(
typer.prompt("Portkey API key", hide_input=True)
)
model = coerce_blank_to_none(model)
base_url = coerce_blank_to_none(base_url)
provider = coerce_blank_to_none(provider)
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
edit_ctx.switch_model_provider(ModelKeyValues.USE_PORTKEY_MODEL)
if model is not None:
settings.PORTKEY_MODEL_NAME = model
if api_key is not None:
settings.PORTKEY_API_KEY = api_key
if base_url is not None:
settings.PORTKEY_BASE_URL = base_url
if provider is not None:
settings.PORTKEY_PROVIDER_NAME = provider
handled, path, updates = edit_ctx.result
effective_model = settings.PORTKEY_MODEL_NAME
if not effective_model:
raise typer.BadParameter(
"Portkey model name is not set. Pass --model (or set PORTKEY_MODEL_NAME).",
param_hint="--model",
)
_handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
success_msg=(
f":raising_hands: Congratulations! You're now using Portkey `{escape(effective_model)}` for all evals that require an LLM."
),
)
@app.command(name="unset-portkey")
def unset_portkey_model_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the Portkey related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove PORTKEY_API_KEY from the dotenv store.",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
settings = get_settings()
with settings.edit(save=save) as edit_ctx:
settings.USE_PORTKEY_MODEL = None
settings.PORTKEY_MODEL_NAME = None
settings.PORTKEY_BASE_URL = None
settings.PORTKEY_PROVIDER_NAME = None
if clear_secrets:
settings.PORTKEY_API_KEY = None
handled, path, updates = edit_ctx.result
if _handle_save_result(
handled=handled,
path=path,
updates=updates,
save=save,
quiet=quiet,
updated_msg="Removed Portkey model environment variables from {path}.",
tip_msg=None,
):
if is_openai_configured():
print(
":raised_hands: OpenAI will still be used by default because OPENAI_API_KEY is set."
)
else:
print(
"The Portkey model configuration has been removed. No model is currently configured, but you can set one with the CLI or add credentials to .env[.local]."
)
+3
View File
@@ -0,0 +1,3 @@
"""Speech-to-text provider commands."""
from deepeval.cli.providers.stt import command # noqa: F401
+117
View File
@@ -0,0 +1,117 @@
"""`deepeval set-stt / unset-stt` commands."""
from enum import Enum
from typing import Optional
import typer
from deepeval.cli.app import app
from deepeval.cli.utils import USE_STT_KEYS
from deepeval.cli.providers._speech import (
_set_speech_provider,
_unset_speech_provider,
)
class STTProviders(str, Enum):
OPENAI = "openai"
ELEVENLABS = "elevenlabs"
CARTESIA = "cartesia"
DEEPGRAM = "deepgram"
ASSEMBLYAI = "assemblyai"
@app.command(name="set-stt")
def set_stt_env(
provider: STTProviders = typer.Argument(
...,
help="Speech-to-text provider to use in voice simulations.",
),
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier for this provider (e.g., `nova-3`). Defaults to the provider's own default.",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for the provider's API key (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
"""
Configure the active speech-to-text provider.
What this does:
- Sets the active provider flag (e.g. `USE_DEEPGRAM_STT`), clearing the other STT flags.
- Persists `DEEPEVAL_STT_MODEL` in the JSON store when `--model` is given.
- Leaves the LLM, embedding and TTS selections alone.
Example:
deepeval set-stt assemblyai --model universal-3-5-pro --save dotenv:.env.local
"""
_set_speech_provider(
family="STT",
provider=provider.value,
model=model,
prompt_api_key=prompt_api_key,
save=save,
quiet=quiet,
)
@app.command(name="unset-stt")
def unset_stt_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the STT related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove the speech provider API keys from the dotenv store (OPENAI_API_KEY is left alone).",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
"""
Unset the active speech-to-text provider.
Voice simulations fall back to OpenAI, exactly as they do when no STT
provider was ever configured.
Example:
deepeval unset-stt --save dotenv:.env.local
"""
_unset_speech_provider(
family="STT",
flags=USE_STT_KEYS,
clear_secrets=clear_secrets,
save=save,
quiet=quiet,
)
+3
View File
@@ -0,0 +1,3 @@
"""Text-to-speech provider commands."""
from deepeval.cli.providers.tts import command # noqa: F401
+116
View File
@@ -0,0 +1,116 @@
"""`deepeval set-tts / unset-tts` commands."""
from enum import Enum
from typing import Optional
import typer
from deepeval.cli.app import app
from deepeval.cli.utils import USE_TTS_KEYS
from deepeval.cli.providers._speech import (
_set_speech_provider,
_unset_speech_provider,
)
class TTSProviders(str, Enum):
OPENAI = "openai"
ELEVENLABS = "elevenlabs"
CARTESIA = "cartesia"
DEEPGRAM = "deepgram"
@app.command(name="set-tts")
def set_tts_env(
provider: TTSProviders = typer.Argument(
...,
help="Text-to-speech provider to use in voice simulations.",
),
model: Optional[str] = typer.Option(
None,
"-m",
"--model",
help="Model identifier for this provider (e.g., `aura-2-thalia-en`). Defaults to the provider's own default.",
),
prompt_api_key: bool = typer.Option(
False,
"-k",
"--prompt-api-key",
help=(
"Prompt for the provider's API key (input hidden). Not suitable for CI. "
"If --save (or DEEPEVAL_DEFAULT_SAVE) is used, the key is written to dotenv in plaintext."
),
),
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Persist CLI parameters as environment variables in a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
"""
Configure the active text-to-speech provider.
What this does:
- Sets the active provider flag (e.g. `USE_DEEPGRAM_TTS`), clearing the other TTS flags.
- Persists `DEEPEVAL_TTS_MODEL` in the JSON store when `--model` is given.
- Leaves the LLM, embedding and STT selections alone.
Example:
deepeval set-tts deepgram --model aura-2-thalia-en --save dotenv:.env.local
"""
_set_speech_provider(
family="TTS",
provider=provider.value,
model=model,
prompt_api_key=prompt_api_key,
save=save,
quiet=quiet,
)
@app.command(name="unset-tts")
def unset_tts_env(
save: Optional[str] = typer.Option(
None,
"-s",
"--save",
help="Remove only the TTS related environment variables from a dotenv file. "
"Usage: --save=dotenv[:path] (default: .env.local)",
),
clear_secrets: bool = typer.Option(
False,
"-x",
"--clear-secrets",
help="Also remove the speech provider API keys from the dotenv store (OPENAI_API_KEY is left alone).",
),
quiet: bool = typer.Option(
False,
"-q",
"--quiet",
help="Suppress printing to the terminal (useful for CI).",
),
):
"""
Unset the active text-to-speech provider.
Voice simulations fall back to OpenAI, exactly as they do when no TTS
provider was ever configured.
Example:
deepeval unset-tts --save dotenv:.env.local
"""
_unset_speech_provider(
family="TTS",
flags=USE_TTS_KEYS,
clear_secrets=clear_secrets,
save=save,
quiet=quiet,
)
+41 -7
View File
@@ -7,7 +7,7 @@ import typer
import webbrowser
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from pydantic import ValidationError
from pydantic import SecretStr, ValidationError
from pydantic.fields import FieldInfo
from enum import Enum
from pathlib import Path
@@ -34,6 +34,7 @@ from deepeval.key_handler import (
KEY_FILE_HANDLER,
ModelKeyValues,
EmbeddingKeyValues,
SpeechKeyValues,
)
from deepeval.test_run.test_run import (
global_test_run_manager,
@@ -102,6 +103,31 @@ USE_EMBED_KEYS = [
for key in Settings.model_fields
if key.startswith("USE_") and key in EmbeddingKeyValues.__members__
]
USE_TTS_KEYS = [
key
for key in Settings.model_fields
if key.startswith("USE_") and key.endswith("_TTS")
]
USE_STT_KEYS = [
key
for key in Settings.model_fields
if key.startswith("USE_") and key.endswith("_STT")
]
def is_openai_configured() -> bool:
s = get_settings()
v = s.OPENAI_API_KEY
if isinstance(v, SecretStr):
try:
if v.get_secret_value().strip():
return True
except Exception:
pass
elif v and str(v).strip():
return True
env = os.getenv("OPENAI_API_KEY")
return bool(env and env.strip())
def handle_save_result(
@@ -286,29 +312,37 @@ def unset_environ_in_store(
def _as_legacy_use_key(
k: str,
) -> Union[ModelKeyValues, EmbeddingKeyValues, None]:
) -> Union[ModelKeyValues, EmbeddingKeyValues, SpeechKeyValues, None]:
if k in ModelKeyValues.__members__:
return ModelKeyValues[k]
if k in EmbeddingKeyValues.__members__:
return EmbeddingKeyValues[k]
if k in SpeechKeyValues.__members__:
return SpeechKeyValues[k]
return None
def switch_model_provider(
target: Union[ModelKeyValues, EmbeddingKeyValues],
target: Union[ModelKeyValues, EmbeddingKeyValues, SpeechKeyValues],
save: Optional[str] = None,
) -> Tuple[bool, Optional[str]]:
"""
Ensure exactly one USE_* flag is enabled.
Ensure exactly one USE_* flag is enabled within the target's family.
We *unset* all other USE_* keys (instead of writing explicit "NO") to:
- keep dotenv clean
- preserve Optional[bool] semantics (unset vs explicit false)
"""
keys_to_clear = (
USE_LLM_KEYS if isinstance(target, ModelKeyValues) else USE_EMBED_KEYS
)
target_key = target.name # or _to_str_key(target)
if target_key.endswith("_TTS"):
keys_to_clear = USE_TTS_KEYS
elif target_key.endswith("_STT"):
keys_to_clear = USE_STT_KEYS
elif isinstance(target, ModelKeyValues):
keys_to_clear = USE_LLM_KEYS
else:
keys_to_clear = USE_EMBED_KEYS
if target_key not in keys_to_clear:
raise ValueError(f"{target} is not a recognized USE_* model key")
+96 -9
View File
@@ -74,14 +74,31 @@ _DEPRECATED_TO_OVERRIDE = {
_LEGACY_KEYFILE_SECRET_WARNED: set[str] = set()
def _family_of_use_flag(env_key: str) -> str:
"""The provider family a USE_* flag belongs to.
Families are mutually exclusive within themselves but independent of each
other, so selecting an LLM provider leaves the embedding, TTS and STT
selections alone.
"""
if env_key.endswith("_TTS"):
return "tts"
if env_key.endswith("_STT"):
return "stt"
if "EMBEDDING" in env_key:
return "embedding"
return "llm"
def _find_legacy_enum(env_key: str):
from deepeval.key_handler import (
ModelKeyValues,
EmbeddingKeyValues,
SpeechKeyValues,
KeyValues,
)
enums = (ModelKeyValues, EmbeddingKeyValues, KeyValues)
enums = (ModelKeyValues, EmbeddingKeyValues, SpeechKeyValues, KeyValues)
for enum in enums:
try:
@@ -129,6 +146,7 @@ def _merge_legacy_keyfile_into_env() -> None:
KeyValues,
ModelKeyValues,
EmbeddingKeyValues,
SpeechKeyValues,
)
key_path = Path(HIDDEN_DIR) / KEY_FILE
@@ -149,7 +167,7 @@ def _merge_legacy_keyfile_into_env() -> None:
# Map JSON keys (enum .value) -> env keys (enum .name)
mapping: Dict[str, str] = {}
for enum in (KeyValues, ModelKeyValues, EmbeddingKeyValues):
for enum in (KeyValues, ModelKeyValues, EmbeddingKeyValues, SpeechKeyValues):
for member in enum:
mapping[member.value] = member.name
@@ -711,6 +729,69 @@ class Settings(BaseSettings):
)
VLLM_MODEL_NAME: Optional[str] = Field(None, description="vLLM model name.")
#
# Speech Keys (TTS/STT)
#
# TTS and STT are two independent families: a USE_*_TTS flag and a
# USE_*_STT flag can both be on, and each family's model name comes from
# DEEPEVAL_TTS_MODEL / DEEPEVAL_STT_MODEL. With no flag set, voice mode
# falls back to OpenAI, the same way `initialize_model()` does for LLMs.
# TTS
USE_OPENAI_TTS: Optional[bool] = Field(
None, description="Use OpenAI for text-to-speech."
)
USE_ELEVENLABS_TTS: Optional[bool] = Field(
None, description="Use ElevenLabs for text-to-speech."
)
USE_CARTESIA_TTS: Optional[bool] = Field(
None, description="Use Cartesia for text-to-speech."
)
USE_DEEPGRAM_TTS: Optional[bool] = Field(
None, description="Use Deepgram for text-to-speech."
)
DEEPEVAL_TTS_MODEL: Optional[str] = Field(
None,
description="Model name for the selected TTS provider (defaults to that provider's own default).",
)
# STT
USE_OPENAI_STT: Optional[bool] = Field(
None, description="Use OpenAI for speech-to-text."
)
USE_ELEVENLABS_STT: Optional[bool] = Field(
None, description="Use ElevenLabs for speech-to-text."
)
USE_CARTESIA_STT: Optional[bool] = Field(
None, description="Use Cartesia for speech-to-text."
)
USE_DEEPGRAM_STT: Optional[bool] = Field(
None, description="Use Deepgram for speech-to-text."
)
USE_ASSEMBLYAI_STT: Optional[bool] = Field(
None, description="Use AssemblyAI for speech-to-text."
)
DEEPEVAL_STT_MODEL: Optional[str] = Field(
None,
description="Model name for the selected STT provider (defaults to that provider's own default).",
)
ASSEMBLYAI_API_KEY: Optional[SecretStr] = Field(
None, description="AssemblyAI API key (speech-to-text)."
)
CARTESIA_API_KEY: Optional[SecretStr] = Field(
None,
description="Cartesia API key (text-to-speech and speech-to-text).",
)
DEEPGRAM_API_KEY: Optional[SecretStr] = Field(
None,
description="Deepgram API key (text-to-speech and speech-to-text).",
)
ELEVENLABS_API_KEY: Optional[SecretStr] = Field(
None,
description="ElevenLabs API key (text-to-speech and speech-to-text).",
)
#
# Embedding Keys
#
@@ -1063,6 +1144,15 @@ class Settings(BaseSettings):
"USE_AZURE_OPENAI_EMBEDDING",
"USE_LOCAL_EMBEDDINGS",
"USE_PORTKEY_MODEL",
"USE_OPENAI_TTS",
"USE_ELEVENLABS_TTS",
"USE_CARTESIA_TTS",
"USE_DEEPGRAM_TTS",
"USE_OPENAI_STT",
"USE_ELEVENLABS_STT",
"USE_CARTESIA_STT",
"USE_DEEPGRAM_STT",
"USE_ASSEMBLYAI_STT",
mode="before",
)
@classmethod
@@ -1520,22 +1610,19 @@ class Settings(BaseSettings):
def switch_model_provider(self, target) -> None:
"""
Flip USE_* settings within the target's provider family (LLM vs embeddings).
Flip USE_* settings within the target's provider family
(LLM, embeddings, TTS or STT).
"""
from deepeval.key_handler import KEY_FILE_HANDLER
target_key = getattr(target, "value", str(target))
def _is_embedding_flag(k: str) -> bool:
return "EMBEDDING" in k
target_is_embedding = _is_embedding_flag(target_key)
target_family = _family_of_use_flag(target_key)
use_fields = [
field
for field in type(self._s).model_fields
if field.startswith("USE_")
and _is_embedding_flag(field) == target_is_embedding
and _family_of_use_flag(field) == target_family
]
if target_key not in use_fields:
+6
View File
@@ -42,6 +42,12 @@ class ProviderSlug(str, Enum):
OLLAMA = "ollama"
OPENROUTER = "openrouter"
PORTKEY = "portkey"
# Speech (TTS/STT) providers. They serve no LLMs, so they appear here only
# to carry a retry policy, not as selectable LLM providers.
ASSEMBLYAI = "assemblyai"
CARTESIA = "cartesia"
DEEPGRAM = "deepgram"
ELEVENLABS = "elevenlabs"
def slugify(value: Union[str, ProviderSlug]) -> str:
+31
View File
@@ -1,3 +1,6 @@
from typing import Optional
class DeepEvalError(Exception):
"""Base class for framework-originated errors.
If raised and not handled, it will abort the current operation.
@@ -12,6 +15,34 @@ class UserAppError(Exception):
"""
class SpeechHTTPError(DeepEvalError):
"""A speech (TTS/STT) provider returned a non-2xx response.
Defined here rather than beside the transport that raises it so
`deepeval.models.retry_policy` can classify it without importing
`deepeval.models.speech`, whose providers import the retry policy.
"""
def __init__(
self,
message: str,
*,
status_code: Optional[int] = None,
provider_label: Optional[str] = None,
):
super().__init__(message)
self.status_code = status_code
self.provider_label = provider_label
class SpeechAuthError(SpeechHTTPError):
"""Speech provider credentials were rejected (401/403). Never retryable."""
class SpeechRateLimitError(SpeechHTTPError):
"""A speech provider is rate limiting (429). Usually worth retrying."""
class MissingTestCaseParamsError(DeepEvalError):
"""Required test case fields are missing."""
+27 -3
View File
@@ -180,6 +180,30 @@ class EmbeddingKeyValues(Enum):
LOCAL_EMBEDDING_API_KEY = ("LOCAL_EMBEDDING_API_KEY",)
class SpeechKeyValues(Enum):
# TTS
USE_OPENAI_TTS = "USE_OPENAI_TTS"
USE_ELEVENLABS_TTS = "USE_ELEVENLABS_TTS"
USE_CARTESIA_TTS = "USE_CARTESIA_TTS"
USE_DEEPGRAM_TTS = "USE_DEEPGRAM_TTS"
DEEPEVAL_TTS_MODEL = "DEEPEVAL_TTS_MODEL"
# STT
USE_OPENAI_STT = "USE_OPENAI_STT"
USE_ELEVENLABS_STT = "USE_ELEVENLABS_STT"
USE_CARTESIA_STT = "USE_CARTESIA_STT"
USE_DEEPGRAM_STT = "USE_DEEPGRAM_STT"
USE_ASSEMBLYAI_STT = "USE_ASSEMBLYAI_STT"
DEEPEVAL_STT_MODEL = "DEEPEVAL_STT_MODEL"
# Provider API keys. `write_key` refuses secrets, so these never reach the
# JSON store; they are here so the CLI can name them in one place.
ASSEMBLYAI_API_KEY = "ASSEMBLYAI_API_KEY"
CARTESIA_API_KEY = "CARTESIA_API_KEY"
DEEPGRAM_API_KEY = "DEEPGRAM_API_KEY"
ELEVENLABS_API_KEY = "ELEVENLABS_API_KEY"
class KeyFileHandler:
def __init__(self):
self.data = {}
@@ -188,7 +212,7 @@ class KeyFileHandler:
os.makedirs(HIDDEN_DIR, exist_ok=True)
def write_key(
self, key: Union[KeyValues, ModelKeyValues, EmbeddingKeyValues], value
self, key: Union[KeyValues, ModelKeyValues, EmbeddingKeyValues, SpeechKeyValues], value
):
"""Appends or updates data in the hidden file"""
@@ -222,7 +246,7 @@ class KeyFileHandler:
json.dump(self.data, f)
def fetch_data(
self, key: Union[KeyValues, ModelKeyValues, EmbeddingKeyValues]
self, key: Union[KeyValues, ModelKeyValues, EmbeddingKeyValues, SpeechKeyValues]
):
"""Fetches the data from the hidden file.
NOTE: secrets in this file are deprecated; prefer env/.env."""
@@ -257,7 +281,7 @@ class KeyFileHandler:
return value
def remove_key(
self, key: Union[KeyValues, ModelKeyValues, EmbeddingKeyValues]
self, key: Union[KeyValues, ModelKeyValues, EmbeddingKeyValues, SpeechKeyValues]
):
"""Removes the specified key from the data."""
try:
+8 -2
View File
@@ -3,6 +3,7 @@ import json
import re
import sys
import warnings
from pydantic import SecretStr
from typing import (
Any,
Callable,
@@ -621,8 +622,13 @@ def should_use_local_model():
def should_use_ollama_model():
if SETTINGS.LOCAL_MODEL_API_KEY:
return SETTINGS.LOCAL_MODEL_API_KEY == "ollama"
local_key = SETTINGS.LOCAL_MODEL_API_KEY
if local_key:
# `LOCAL_MODEL_API_KEY` is a `SecretStr`, which never compares equal to
# a plain string, so the sentinel is read out before comparing.
if isinstance(local_key, SecretStr):
local_key = local_key.get_secret_value()
return local_key == "ollama"
value = KEY_FILE_HANDLER.fetch_data(ModelKeyValues.LOCAL_MODEL_API_KEY)
return value == "ollama"
+20 -2
View File
@@ -26,8 +26,19 @@ from deepeval.models.embedding_models import (
LocalEmbeddingModel,
OllamaEmbeddingModel,
)
from deepeval.models.tts_models import OpenAITTSModel
from deepeval.models.stt_models import OpenAISTTModel
from deepeval.models.tts import (
CartesiaTTSModel,
DeepgramTTSModel,
ElevenLabsTTSModel,
OpenAITTSModel,
)
from deepeval.models.stt import (
AssemblyAISTTModel,
CartesiaSTTModel,
DeepgramSTTModel,
ElevenLabsSTTModel,
OpenAISTTModel,
)
__all__ = [
"DeepEvalBaseModel",
@@ -54,6 +65,13 @@ __all__ = [
"OpenRouterModel",
"OpenAITTSModel",
"OpenAISTTModel",
"CartesiaTTSModel",
"DeepgramTTSModel",
"ElevenLabsTTSModel",
"AssemblyAISTTModel",
"CartesiaSTTModel",
"DeepgramSTTModel",
"ElevenLabsSTTModel",
]
+53
View File
@@ -987,6 +987,50 @@ except Exception:
OLLAMA_ERROR_POLICY = None
####################
# Speech providers #
####################
# The speech providers (ElevenLabs, Deepgram, Cartesia, AssemblyAI) are reached
# over plain HTTP rather than through vendor SDKs, so there are no provider
# exception classes to classify. The speech transport raises its own hierarchy
# instead, already split along the lines this policy needs, so one policy serves
# all four. Imported from `deepeval.errors` rather than from the transport that
# raises them, which would import the providers, which import this module.
try:
from deepeval.errors import (
SpeechAuthError,
SpeechHTTPError,
SpeechRateLimitError,
)
def _aiohttp_net_excs() -> tuple[type, ...]:
try:
import aiohttp
except Exception:
return ()
names = (
"ClientConnectionError",
"ClientPayloadError",
"ServerTimeoutError",
"ServerDisconnectedError",
)
return tuple(getattr(aiohttp, n) for n in names if hasattr(aiohttp, n))
SPEECH_ERROR_POLICY = ErrorPolicy(
auth_excs=(SpeechAuthError,),
rate_limit_excs=(SpeechRateLimitError,),
network_excs=_aiohttp_net_excs() + _requests_net_excs(),
# Checked after the two above, so the base class here only ever sees
# statuses that are neither auth nor rate limit.
http_excs=(SpeechHTTPError,),
non_retryable_codes=frozenset(),
message_markers={},
)
except Exception: # pragma: no cover - aiohttp/requests are core deps
SPEECH_ERROR_POLICY = None
# Map provider slugs to their policy objects.
# It is OK if some are None, we'll treat that as no Error Policy / Tenacity
_POLICY_BY_SLUG: dict[str, Optional[ErrorPolicy]] = {
@@ -1003,6 +1047,10 @@ _POLICY_BY_SLUG: dict[str, Optional[ErrorPolicy]] = {
PS.OLLAMA.value: OLLAMA_ERROR_POLICY,
PS.OPENROUTER.value: OPENROUTER_ERROR_POLICY,
PS.PORTKEY.value: PORTKEY_ERROR_POLICY,
PS.ASSEMBLYAI.value: SPEECH_ERROR_POLICY,
PS.CARTESIA.value: SPEECH_ERROR_POLICY,
PS.DEEPGRAM.value: SPEECH_ERROR_POLICY,
PS.ELEVENLABS.value: SPEECH_ERROR_POLICY,
}
@@ -1026,6 +1074,10 @@ _STATIC_PRED_BY_SLUG: dict[str, Optional[Callable[[Exception], bool]]] = {
PS.OLLAMA.value: _opt_pred(OLLAMA_ERROR_POLICY),
PS.OPENROUTER.value: _opt_pred(OPENROUTER_ERROR_POLICY),
PS.PORTKEY.value: _opt_pred(PORTKEY_ERROR_POLICY),
PS.ASSEMBLYAI.value: _opt_pred(SPEECH_ERROR_POLICY),
PS.CARTESIA.value: _opt_pred(SPEECH_ERROR_POLICY),
PS.DEEPGRAM.value: _opt_pred(SPEECH_ERROR_POLICY),
PS.ELEVENLABS.value: _opt_pred(SPEECH_ERROR_POLICY),
}
@@ -1050,4 +1102,5 @@ __all__ = [
"GOOGLE_ERROR_POLICY",
"GROK_ERROR_POLICY",
"LOCAL_ERROR_POLICY",
"SPEECH_ERROR_POLICY",
]
+423
View File
@@ -0,0 +1,423 @@
"""Transport and schema plumbing shared by the TTS and STT providers.
The models live in `deepeval.models.tts` and `deepeval.models.stt`, one
subpackage per provider. Every provider except OpenAI is reached over plain HTTP
rather than through a vendor SDK: each is a handful of REST calls, and `requests`
and `aiohttp` are already core dependencies, so `pip install deepeval` is enough
to reach any of them without a per-provider install and version constraint to
track. Rather than have nine models each grow their own session handling and
error translation, that lives here once.
Two HTTP clients are used rather than one: `requests` for the synchronous
`synthesize`/`transcribe` methods and `aiohttp` for their `a_` counterparts.
Using each in its native mode avoids driving a sync client from an event loop
(which blocks it) or bridging an async one into sync code (which needs a loop
that may already be running).
"""
from __future__ import annotations
import json as jsonlib
from dataclasses import dataclass, field
from typing import (
Any,
AsyncGenerator,
Dict,
List,
Optional,
Tuple,
Type,
TypeVar,
)
import aiohttp
import requests
from pydantic import BaseModel, ValidationError
# Raised from here, defined in `deepeval.errors`, re-exported for callers.
from deepeval.errors import (
SpeechAuthError,
SpeechHTTPError,
SpeechRateLimitError,
)
T = TypeVar("T", bound=BaseModel)
# Long enough that a slow batch transcription of a whole conversation does not
# trip it, short enough that a hung connection does not stall a run forever.
DEFAULT_TIMEOUT_SECONDS = 120.0
# Error bodies go into exception messages, so they are truncated: providers can
# return a wall of JSON, and the useful part is always at the front.
_MAX_ERROR_BODY_CHARS = 500
def _raise_for_status(
status: int, body: bytes, *, provider_label: str, url: str
) -> None:
if 200 <= status < 300:
return
detail = body.decode("utf-8", errors="replace").strip()
if len(detail) > _MAX_ERROR_BODY_CHARS:
detail = detail[:_MAX_ERROR_BODY_CHARS] + "..."
message = f"{provider_label} request to {url} failed with HTTP {status}"
if detail:
message = f"{message}: {detail}"
if status in (401, 403):
raise SpeechAuthError(
f"{message}. Check the API key you configured.",
status_code=status,
provider_label=provider_label,
)
if status == 429:
raise SpeechRateLimitError(
message, status_code=status, provider_label=provider_label
)
raise SpeechHTTPError(
message, status_code=status, provider_label=provider_label
)
@dataclass
class Multipart:
"""A multipart/form-data body: one audio file plus scalar fields.
Every speech endpoint that takes multipart follows this exact shape, so the
two HTTP clients' quite different form APIs can be hidden behind it.
"""
file_field: str
filename: str
content: bytes
content_type: str
fields: Dict[str, Any] = field(default_factory=dict)
def _scalar_fields(self) -> Dict[str, str]:
# Both clients want strings; booleans have to go over the wire in the
# JSON spelling ("true"), not Python's ("True").
out: Dict[str, str] = {}
for key, value in self.fields.items():
if value is None:
continue
if isinstance(value, bool):
out[key] = "true" if value else "false"
elif isinstance(value, (list, tuple, dict)):
out[key] = jsonlib.dumps(value)
else:
out[key] = str(value)
return out
def as_requests_kwargs(self) -> Dict[str, Any]:
return {
"files": {
self.file_field: (
self.filename,
self.content,
self.content_type,
)
},
"data": self._scalar_fields(),
}
def as_aiohttp_form(self) -> aiohttp.FormData:
form = aiohttp.FormData()
for key, value in self._scalar_fields().items():
form.add_field(key, value)
form.add_field(
self.file_field,
self.content,
filename=self.filename,
content_type=self.content_type,
)
return form
class SpeechTransport:
"""Issues HTTP requests to one provider, sync or async.
Each async request opens its own `aiohttp.ClientSession`. Caching one would
save a handshake per call, but an `aiohttp.ClientSession` is bound to the
event loop that created it and has to be closed on that same loop — and a
speech model has no teardown hook to close it from, since `VoiceConfig`
holds the model for the life of the process. A cached session would
therefore outlive its loop and be reported as an unclosed session at
interpreter shutdown. A streaming response still holds one connection for
its whole body, which is where reuse actually matters here.
"""
def __init__(
self,
*,
provider_label: str,
base_url: str,
headers: Dict[str, str],
timeout: float = DEFAULT_TIMEOUT_SECONDS,
):
self.provider_label = provider_label
self.base_url = base_url.rstrip("/")
self.headers = {k: v for k, v in headers.items() if v is not None}
self.timeout = timeout
self._sync_session: Optional[requests.Session] = None
def url(self, path: str) -> str:
if path.startswith(("http://", "https://")):
return path
return f"{self.base_url}/{path.lstrip('/')}"
def _merged_headers(
self, extra: Optional[Dict[str, str]] = None
) -> Dict[str, str]:
headers = dict(self.headers)
if extra:
headers.update({k: v for k, v in extra.items() if v is not None})
return headers
#
# Synchronous
#
def _requests_session(self) -> requests.Session:
if self._sync_session is None:
self._sync_session = requests.Session()
return self._sync_session
def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json: Optional[Dict[str, Any]] = None,
content: Optional[bytes] = None,
multipart: Optional[Multipart] = None,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[int, bytes]:
url = self.url(path)
kwargs: Dict[str, Any] = {
"params": _clean_params(params),
"headers": self._merged_headers(headers),
"timeout": self.timeout,
}
if json is not None:
kwargs["json"] = json
if content is not None:
kwargs["data"] = content
if multipart is not None:
kwargs.update(multipart.as_requests_kwargs())
response = self._requests_session().request(method, url, **kwargs)
body = response.content
_raise_for_status(
response.status_code,
body,
provider_label=self.provider_label,
url=url,
)
return response.status_code, body
def request_bytes(self, method: str, path: str, **kwargs) -> bytes:
_, body = self._request(method, path, **kwargs)
return body
def request_json(self, method: str, path: str, **kwargs) -> Any:
_, body = self._request(method, path, **kwargs)
return _decode_json(body, provider_label=self.provider_label)
#
# Asynchronous
#
def _session(self) -> aiohttp.ClientSession:
return aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=self.timeout)
)
def _a_request_kwargs(
self,
*,
params: Optional[Dict[str, Any]],
json: Optional[Dict[str, Any]],
content: Optional[bytes],
multipart: Optional[Multipart],
headers: Optional[Dict[str, str]],
) -> Dict[str, Any]:
kwargs: Dict[str, Any] = {
"params": _clean_params(params),
"headers": self._merged_headers(headers),
}
if json is not None:
kwargs["json"] = json
if content is not None:
kwargs["data"] = content
if multipart is not None:
kwargs["data"] = multipart.as_aiohttp_form()
return kwargs
async def _a_request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json: Optional[Dict[str, Any]] = None,
content: Optional[bytes] = None,
multipart: Optional[Multipart] = None,
headers: Optional[Dict[str, str]] = None,
) -> bytes:
url = self.url(path)
kwargs = self._a_request_kwargs(
params=params,
json=json,
content=content,
multipart=multipart,
headers=headers,
)
async with self._session() as session:
async with session.request(method, url, **kwargs) as response:
body = await response.read()
_raise_for_status(
response.status,
body,
provider_label=self.provider_label,
url=url,
)
return body
async def a_request_bytes(self, method: str, path: str, **kwargs) -> bytes:
return await self._a_request(method, path, **kwargs)
async def a_request_json(self, method: str, path: str, **kwargs) -> Any:
body = await self._a_request(method, path, **kwargs)
return _decode_json(body, provider_label=self.provider_label)
async def a_stream_bytes(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json: Optional[Dict[str, Any]] = None,
content: Optional[bytes] = None,
multipart: Optional[Multipart] = None,
headers: Optional[Dict[str, str]] = None,
chunk_size: int = 4096,
) -> AsyncGenerator[bytes, None]:
"""Yield the response body as it arrives.
An error response is read in full before raising, since the body is the
only place the provider explains what went wrong.
"""
url = self.url(path)
kwargs = self._a_request_kwargs(
params=params,
json=json,
content=content,
multipart=multipart,
headers=headers,
)
async with self._session() as session:
async with session.request(method, url, **kwargs) as response:
if response.status < 200 or response.status >= 300:
_raise_for_status(
response.status,
await response.read(),
provider_label=self.provider_label,
url=url,
)
async for data in response.content.iter_chunked(chunk_size):
if data:
yield data
def close(self) -> None:
if self._sync_session is not None:
self._sync_session.close()
self._sync_session = None
def _clean_params(
params: Optional[Dict[str, Any]],
) -> Optional[List[Tuple[str, str]]]:
"""Drop unset params and spell values the way query strings expect.
Returns pairs rather than a dict for two reasons: providers spell a
repeated param (Deepgram's `keyterm`) as the same key several times, which a
dict cannot hold, and `aiohttp` rejects a dict whose values are lists while
accepting pairs. Booleans are normalized here too, since `?smart_format=True`
is not the `true` these APIs look for.
"""
if not params:
return None
out: List[Tuple[str, str]] = []
for key, value in params.items():
if value is None:
continue
if isinstance(value, (list, tuple)):
out.extend(
(key, _param_value(item)) for item in value if item is not None
)
else:
out.append((key, _param_value(value)))
return out or None
def _param_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _decode_json(body: bytes, *, provider_label: str) -> Any:
try:
return jsonlib.loads(body)
except ValueError as exc:
snippet = body.decode("utf-8", errors="replace")[:200]
raise SpeechHTTPError(
f"{provider_label} returned a response that is not valid JSON: "
f"{snippet}",
provider_label=provider_label,
) from exc
#
# Schema
#
# Each provider's `schema.py` declares the request and response shapes deepeval
# relies on. These two helpers are the only places those models cross the wire,
# so serialization rules and validation failures are handled the same way
# everywhere.
#
def dump_request(model: BaseModel) -> Dict[str, Any]:
"""Serialize a request model for the wire.
`exclude_none` is what lets a schema declare every optional parameter a
provider accepts without sending the ones nobody set — an explicit `null`
is not the same as an absent field to these APIs, and several read it as an
error. Extras survive, which is how `generation_kwargs` reaches a provider
parameter that has no field of its own yet.
"""
return model.model_dump(exclude_none=True, by_alias=True)
def parse_response(schema: Type[T], payload: Any, *, provider_label: str) -> T:
"""Validate a provider response, or fail with something readable.
Response models allow unknown fields, so this only rejects a payload whose
declared fields are the wrong shape entirely. When that happens the useful
thing to report is that the provider's response no longer matches what
deepeval expects — a raw `ValidationError` names pydantic models the reader
has never heard of and says nothing about which provider broke.
"""
try:
return schema.model_validate(payload)
except ValidationError as exc:
raise SpeechHTTPError(
f"{provider_label} returned a response deepeval could not read. "
f"This usually means the provider changed its response format: "
f"{exc.error_count()} field(s) did not match "
f"{schema.__name__} ({exc.errors(include_url=False)}).",
provider_label=provider_label,
) from exc
+111
View File
@@ -0,0 +1,111 @@
"""Provider selection for speech models.
TTS and STT mirror the LLM mechanism in `deepeval.metrics.utils`: a `USE_*`
flag picks the provider, and the model name comes from the family-level
`DEEPEVAL_TTS_MODEL` / `DEEPEVAL_STT_MODEL` unless one is passed in code. With
no flag set, both families fall back to OpenAI.
"""
from __future__ import annotations
from typing import Optional, Tuple, Union
from deepeval.config.settings import get_settings
from deepeval.key_handler import KEY_FILE_HANDLER, SpeechKeyValues
from deepeval.models.base_model import DeepEvalBaseSTT, DeepEvalBaseTTS
# Flag -> class name, in the order they are checked. Named rather than
# imported because `deepeval.models.tts` reaches back into `deepeval.voice`.
_TTS_PROVIDERS: Tuple[Tuple[str, str], ...] = (
("USE_OPENAI_TTS", "OpenAITTSModel"),
("USE_ELEVENLABS_TTS", "ElevenLabsTTSModel"),
("USE_CARTESIA_TTS", "CartesiaTTSModel"),
("USE_DEEPGRAM_TTS", "DeepgramTTSModel"),
)
_STT_PROVIDERS: Tuple[Tuple[str, str], ...] = (
("USE_OPENAI_STT", "OpenAISTTModel"),
("USE_ELEVENLABS_STT", "ElevenLabsSTTModel"),
("USE_CARTESIA_STT", "CartesiaSTTModel"),
("USE_DEEPGRAM_STT", "DeepgramSTTModel"),
("USE_ASSEMBLYAI_STT", "AssemblyAISTTModel"),
)
def _from_keystore(field: str) -> Optional[str]:
member = SpeechKeyValues.__members__.get(field)
if member is None:
return None
return KEY_FILE_HANDLER.fetch_data(member)
def _flag_enabled(field: str) -> bool:
if getattr(get_settings(), field, None):
return True
value = _from_keystore(field)
return value.lower() == "yes" if value is not None else False
def _model_name(field: str, override: Optional[str]) -> Optional[str]:
if override is not None:
return override
return getattr(get_settings(), field, None) or _from_keystore(field)
def _selected(providers: Tuple[Tuple[str, str], ...]) -> str:
for flag, class_name in providers:
if _flag_enabled(flag):
return class_name
# No flag set: OpenAI, matching `initialize_model()`'s LLM fallback.
return providers[0][1]
def describe_tts_selection() -> Tuple[str, Optional[str]]:
"""The TTS class name and model name selected by the environment.
Reported without constructing anything, so it stays answerable on a
machine with no speech API key set.
"""
return _selected(_TTS_PROVIDERS), _model_name("DEEPEVAL_TTS_MODEL", None)
def describe_stt_selection() -> Tuple[str, Optional[str]]:
"""The STT class name and model name selected by the environment."""
return _selected(_STT_PROVIDERS), _model_name("DEEPEVAL_STT_MODEL", None)
def initialize_tts_model(
model: Optional[Union[str, DeepEvalBaseTTS]] = None,
) -> DeepEvalBaseTTS:
"""The TTS model for this run.
A model object is used as-is, a string is the model name for the
flag-selected provider, and `None` falls back to `DEEPEVAL_TTS_MODEL`.
"""
if model is not None and not isinstance(model, str):
return model
from deepeval.models import tts
class_name = _selected(_TTS_PROVIDERS)
return getattr(tts, class_name)(
model=_model_name("DEEPEVAL_TTS_MODEL", model)
)
def initialize_stt_model(
model: Optional[Union[str, DeepEvalBaseSTT]] = None,
) -> DeepEvalBaseSTT:
"""The STT model for this run.
A model object is used as-is, a string is the model name for the
flag-selected provider, and `None` falls back to `DEEPEVAL_STT_MODEL`.
"""
if model is not None and not isinstance(model, str):
return model
from deepeval.models import stt
class_name = _selected(_STT_PROVIDERS)
return getattr(stt, class_name)(
model=_model_name("DEEPEVAL_STT_MODEL", model)
)
+13
View File
@@ -0,0 +1,13 @@
from deepeval.models.stt.assemblyai import AssemblyAISTTModel
from deepeval.models.stt.cartesia import CartesiaSTTModel
from deepeval.models.stt.deepgram import DeepgramSTTModel
from deepeval.models.stt.elevenlabs import ElevenLabsSTTModel
from deepeval.models.stt.openai import OpenAISTTModel
__all__ = [
"AssemblyAISTTModel",
"CartesiaSTTModel",
"DeepgramSTTModel",
"ElevenLabsSTTModel",
"OpenAISTTModel",
]
+31
View File
@@ -0,0 +1,31 @@
"""Audio inspection shared by the STT models."""
from __future__ import annotations
import io
import wave
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from deepeval.test_case import Audio
def audio_duration_seconds(audio: "Audio") -> Optional[float]:
"""How long `audio` runs, or `None` when it cannot be determined.
Needed because several providers price transcription by the minute but
report usage only sometimes, leaving the duration to be measured locally.
Anything that is not a readable WAV yields `None` rather than a guess, so a
cost is never invented from a number we do not have.
"""
if audio.duration is not None:
return audio.duration
try:
with wave.open(io.BytesIO(audio.get_bytes()), "rb") as wf:
frames = wf.getnframes()
rate = wf.getframerate()
if rate:
return frames / float(rate)
except (wave.Error, EOFError, ValueError):
pass
return None
+75
View File
@@ -0,0 +1,75 @@
"""Partial transcripts for providers whose batch API is all we use."""
from __future__ import annotations
from typing import AsyncGenerator, AsyncIterable, Optional
from deepeval.test_case import Audio, AudioChunk
from deepeval.voice.connectors import audio_utils
DEFAULT_STREAM_SAMPLE_RATE = 24000
class BufferedTranscribeMixin:
"""Approximates streaming transcription on top of a batch endpoint.
`DeepEvalBaseSTT.a_transcribe_stream` raises by default, so a model without
it cannot follow an agent as it speaks — a caller wanting a transcript
before the utterance ends has nothing to consume. Every provider here has a
realtime WebSocket API, but the models talk to the batch endpoint, so this
buffers the incoming PCM and re-transcribes the accumulated audio whenever
roughly `partial_every_seconds` of new audio has arrived, yielding the
transcript each time it changes.
Re-transcribing from the top is wasteful, and deliberately so: it keeps a
provider's streaming protocol out of the models, and the audio involved is a
single conversational turn. Mixing this in gives a model working partials
from `a_transcribe` alone; a provider whose realtime API is worth the
complexity can override `a_transcribe_stream` instead.
Must be mixed in ahead of `DeepEvalBaseSTT` so it takes precedence over the
base's `NotImplementedError`.
"""
async def a_transcribe_stream(
self,
audio_stream: AsyncIterable[AudioChunk],
*args,
language: Optional[str] = None,
partial_every_seconds: float = 1.0,
**kwargs,
) -> AsyncGenerator[str, None]:
pcm = bytearray()
sample_rate = DEFAULT_STREAM_SAMPLE_RATE
bytes_since_partial = 0
last_text = ""
async for chunk in audio_stream:
if chunk.sampleRate:
sample_rate = chunk.sampleRate
frame = chunk.get_bytes()
pcm.extend(frame)
bytes_since_partial += len(frame)
threshold = int(sample_rate * 2 * partial_every_seconds)
if bytes_since_partial < threshold and not chunk.final:
continue
audio = Audio.from_bytes(
audio_utils.pcm16_to_wav_bytes(bytes(pcm), sample_rate, 1),
"audio/wav",
sampleRate=sample_rate,
encoding="wav",
)
text, _ = await self.a_transcribe(
audio, language=language, **kwargs
)
if text and text != last_text:
last_text = text
yield text
bytes_since_partial = 0
if chunk.final:
break
def supports_streaming(self) -> bool:
return True
@@ -0,0 +1,3 @@
from deepeval.models.stt.assemblyai.assemblyai import AssemblyAISTTModel
__all__ = ["AssemblyAISTTModel"]
@@ -0,0 +1,330 @@
import asyncio
import json as jsonlib
import time
from typing import Any, Dict, Optional, Tuple
from pydantic import SecretStr
from deepeval.config.settings import get_settings
from deepeval.constants import ProviderSlug as PS
from deepeval.models.base_model import DeepEvalBaseSTT
from deepeval.models.retry_policy import create_retry_decorator
from deepeval.models.speech import (
DEFAULT_TIMEOUT_SECONDS,
Multipart,
SpeechHTTPError,
SpeechTransport,
dump_request,
parse_response,
)
from deepeval.models.stt._audio import audio_duration_seconds
from deepeval.models.stt._stream import BufferedTranscribeMixin
from deepeval.models.stt.assemblyai.schema import (
AssemblyAISyncConfig,
AssemblyAITranscriptRequest,
AssemblyAITranscriptResponse,
AssemblyAIUploadResponse,
)
from deepeval.models.utils import parse_model_name, require_secret_api_key
from deepeval.test_case import Audio
retry_assemblyai = create_retry_decorator(PS.ASSEMBLYAI)
DEFAULT_BASE_URL = "https://api.assemblyai.com"
# The synchronous endpoint lives on its own host and answers in one round trip.
SYNC_URL = "https://sync.assemblyai.com/transcribe"
# `slam-1` is deprecated in favour of this.
DEFAULT_STT_MODEL = "universal-3-5-pro"
# The synchronous endpoint's ceiling. Conversation turns sit far below it, so it
# is the normal path; anything longer falls back to upload-and-poll.
SYNC_MAX_SECONDS = 120.0
_SYNC_PRICE_PER_HOUR = 0.45
_ASYNC_PRICE_PER_HOUR = {
"universal-3-5-pro": 0.21,
"universal-2": 0.15,
}
DEFAULT_POLL_INTERVAL_SECONDS = 1.0
class AssemblyAISTTModel(BufferedTranscribeMixin, DeepEvalBaseSTT):
"""AssemblyAI speech-to-text.
Prefers the synchronous endpoint, which returns a transcript in a single
request (~134 ms p50) instead of the three-step upload, create and poll
flow. That endpoint caps clips at 120 seconds, which no simulated
conversation turn approaches, so the slower flow is used only when the audio
really is longer.
"""
# Universal does not complete a word clipped by a barge-in, so the audio is
# transcribed exactly as it was heard.
truncated_audio_pad_seconds: float = 0.0
def __init__(
self,
model: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
language: Optional[str] = None,
cost_per_hour: Optional[float] = None,
transcription_kwargs: Optional[Dict] = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
):
settings = get_settings()
self.name = parse_model_name(model or DEFAULT_STT_MODEL)
self.language = language
self.transcription_kwargs = transcription_kwargs or {}
self.cost_per_hour = cost_per_hour
# Only consulted on the upload-and-poll path, which a conversation turn
# never takes.
self.poll_interval_seconds = poll_interval_seconds
self.api_key = (
SecretStr(api_key)
if api_key is not None
else settings.ASSEMBLYAI_API_KEY
)
self.base_url = (
str(base_url).rstrip("/")
if base_url is not None
else DEFAULT_BASE_URL
)
self.timeout = timeout
self.model = self.load_model()
def load_model(self) -> SpeechTransport:
api_key = require_secret_api_key(
self.api_key,
provider_label="AssemblyAI",
env_var_name="ASSEMBLYAI_API_KEY",
param_hint="`api_key` to AssemblyAISTTModel(...)",
)
return SpeechTransport(
provider_label="AssemblyAI",
base_url=self.base_url,
# Note the bare key: AssemblyAI does not use a `Bearer` prefix.
headers={"Authorization": api_key},
timeout=self.timeout,
)
#
# Routing
#
def _use_sync_endpoint(self, audio: Audio) -> bool:
seconds = audio_duration_seconds(audio)
# An unmeasurable duration takes the slower path: it always works,
# where the fast one would reject audio that turns out to be too long.
if seconds is None:
return False
return seconds <= SYNC_MAX_SECONDS
def _cost(self, audio: Audio, *, used_sync: bool) -> Optional[float]:
rate = self.cost_per_hour
if rate is None:
rate = (
_SYNC_PRICE_PER_HOUR
if used_sync
else _ASYNC_PRICE_PER_HOUR.get(self.name)
)
if rate is None:
return None
seconds = audio_duration_seconds(audio)
if seconds is None:
return None
return seconds / 3600.0 * rate
#
# Request building
#
def _sync_multipart(
self, audio: Audio, language: Optional[str], kwargs: Dict[str, Any]
) -> Multipart:
lang = language or self.language
config = AssemblyAISyncConfig(
sample_rate=audio.sampleRate or None,
language_code=None if lang == "auto" else lang,
**{**self.transcription_kwargs, **kwargs},
)
dumped = dump_request(config)
fields: Dict[str, Any] = {}
if dumped:
# The whole config rides as one JSON-encoded form field.
fields["config"] = jsonlib.dumps(dumped)
return Multipart(
file_field="audio",
filename=audio.filename or "audio.wav",
content=audio.get_bytes(),
content_type=audio.mimeType or "audio/wav",
fields=fields,
)
def _create_body(
self,
upload_url: str,
language: Optional[str],
kwargs: Dict[str, Any],
) -> Dict[str, Any]:
lang = language or self.language
auto = lang == "auto"
return dump_request(
AssemblyAITranscriptRequest(
audio_url=upload_url,
speech_models=[self.name],
language_code=None if auto else lang,
language_detection=True if auto else None,
**{**self.transcription_kwargs, **kwargs},
)
)
#
# Response handling
#
def _parse(self, payload: Any) -> AssemblyAITranscriptResponse:
return parse_response(
AssemblyAITranscriptResponse,
payload,
provider_label="AssemblyAI",
)
def _raise_if_failed(self, payload: AssemblyAITranscriptResponse) -> None:
if payload.failed:
raise SpeechHTTPError(
"AssemblyAI failed to transcribe the audio: "
f"{payload.error or 'no reason given'}",
provider_label="AssemblyAI",
)
def _raise_if_timed_out(self, deadline: float, transcript_id: str) -> None:
if time.monotonic() >= deadline:
raise SpeechHTTPError(
"AssemblyAI did not finish transcribing within "
f"{self.timeout:g}s (transcript {transcript_id}).",
provider_label="AssemblyAI",
)
#
# DeepEvalBaseSTT
#
@retry_assemblyai
def transcribe(
self, audio: Audio, *, language: Optional[str] = None, **kwargs
) -> Tuple[str, Optional[float]]:
used_sync = self._use_sync_endpoint(audio)
if used_sync:
payload = self._parse(
self.model.request_json(
"POST",
SYNC_URL,
multipart=self._sync_multipart(audio, language, kwargs),
headers={"X-AAI-Model": self.name},
)
)
else:
payload = self._transcribe_by_upload(audio, language, kwargs)
return payload.transcript() or "", self._cost(
audio, used_sync=used_sync
)
def _transcribe_by_upload(
self, audio: Audio, language: Optional[str], kwargs: Dict[str, Any]
) -> AssemblyAITranscriptResponse:
uploaded = parse_response(
AssemblyAIUploadResponse,
self.model.request_json(
"POST",
"/v2/upload",
content=audio.get_bytes(),
headers={"Content-Type": "application/octet-stream"},
),
provider_label="AssemblyAI",
)
payload = self._parse(
self.model.request_json(
"POST",
"/v2/transcript",
json=self._create_body(uploaded.upload_url, language, kwargs),
)
)
transcript_id = payload.id or ""
deadline = time.monotonic() + self.timeout
while not payload.finished:
self._raise_if_timed_out(deadline, transcript_id)
time.sleep(self.poll_interval_seconds)
payload = self._parse(
self.model.request_json(
"GET", f"/v2/transcript/{transcript_id}"
)
)
self._raise_if_failed(payload)
return payload
@retry_assemblyai
async def a_transcribe(
self, audio: Audio, *, language: Optional[str] = None, **kwargs
) -> Tuple[str, Optional[float]]:
used_sync = self._use_sync_endpoint(audio)
if used_sync:
payload = self._parse(
await self.model.a_request_json(
"POST",
SYNC_URL,
multipart=self._sync_multipart(audio, language, kwargs),
headers={"X-AAI-Model": self.name},
)
)
else:
payload = await self._a_transcribe_by_upload(
audio, language, kwargs
)
return payload.transcript() or "", self._cost(
audio, used_sync=used_sync
)
async def _a_transcribe_by_upload(
self, audio: Audio, language: Optional[str], kwargs: Dict[str, Any]
) -> AssemblyAITranscriptResponse:
uploaded = parse_response(
AssemblyAIUploadResponse,
await self.model.a_request_json(
"POST",
"/v2/upload",
content=audio.get_bytes(),
headers={"Content-Type": "application/octet-stream"},
),
provider_label="AssemblyAI",
)
payload = self._parse(
await self.model.a_request_json(
"POST",
"/v2/transcript",
json=self._create_body(uploaded.upload_url, language, kwargs),
)
)
transcript_id = payload.id or ""
deadline = time.monotonic() + self.timeout
while not payload.finished:
self._raise_if_timed_out(deadline, transcript_id)
await asyncio.sleep(self.poll_interval_seconds)
payload = self._parse(
await self.model.a_request_json(
"GET", f"/v2/transcript/{transcript_id}"
)
)
self._raise_if_failed(payload)
return payload
def get_model_name(self) -> str:
return self.name
+87
View File
@@ -0,0 +1,87 @@
"""AssemblyAI request and response shapes.
AssemblyAI has two transcription paths with different request shapes, so both
are modelled: a synchronous endpoint that takes its settings as one
JSON-encoded multipart field, and the upload-create-poll flow that takes a JSON
body.
"""
from typing import List, Optional
from pydantic import BaseModel, ConfigDict
# Requests allow extras so `transcription_kwargs` can set a provider parameter
# deepeval has no field for. Responses allow them so a provider adding a field
# never breaks a run.
_REQUEST = ConfigDict(extra="allow", protected_namespaces=())
_RESPONSE = ConfigDict(extra="allow", protected_namespaces=())
# Statuses that end the poll loop.
TERMINAL_STATUSES = ("completed", "error")
class AssemblyAISyncConfig(BaseModel):
"""The `config` multipart field on `POST sync.assemblyai.com/transcribe`.
Unlike the async flow, this endpoint takes the model in an `X-AAI-Model`
header rather than in the config, so there is no model field here.
"""
model_config = _REQUEST
sample_rate: Optional[int] = None
language_code: Optional[str] = None
class AssemblyAITranscriptRequest(BaseModel):
"""Body for `POST /v2/transcript`.
`speech_models` is an ordered fallback list; the older singular
`speech_model` field is deprecated.
"""
model_config = _REQUEST
audio_url: str
speech_models: List[str]
language_code: Optional[str] = None
language_detection: Optional[bool] = None
class AssemblyAIUploadResponse(BaseModel):
"""Response from `POST /v2/upload`."""
model_config = _RESPONSE
upload_url: str
class AssemblyAITranscriptResponse(BaseModel):
"""Response from the sync endpoint and from `/v2/transcript` (create + poll).
`status` is absent on the synchronous endpoint's response, which returns a
finished transcript directly and has no queue to report on.
"""
model_config = _RESPONSE
id: Optional[str] = None
status: Optional[str] = None
text: Optional[str] = None
error: Optional[str] = None
@property
def finished(self) -> bool:
return self.status is None or self.status in TERMINAL_STATUSES
@property
def failed(self) -> bool:
return self.status == "error"
def transcript(self) -> Optional[str]:
"""The spoken text, or `None` when the response carries no `text`.
`None` and `""` mean different things: `""` is silence, which is a
perfectly good answer, while `None` means the field was absent.
"""
return self.text
+3
View File
@@ -0,0 +1,3 @@
from deepeval.models.stt.cartesia.cartesia import CartesiaSTTModel
__all__ = ["CartesiaSTTModel"]
+154
View File
@@ -0,0 +1,154 @@
from typing import Any, Dict, Optional, Tuple
from pydantic import SecretStr
from deepeval.config.settings import get_settings
from deepeval.constants import ProviderSlug as PS
from deepeval.models.base_model import DeepEvalBaseSTT
from deepeval.models.retry_policy import create_retry_decorator
from deepeval.models.speech import (
DEFAULT_TIMEOUT_SECONDS,
Multipart,
SpeechTransport,
dump_request,
parse_response,
)
from deepeval.models.stt._audio import audio_duration_seconds
from deepeval.models.stt._stream import BufferedTranscribeMixin
from deepeval.models.stt.cartesia.schema import (
CartesiaSTTRequest,
CartesiaSTTResponse,
)
from deepeval.models.utils import parse_model_name, require_secret_api_key
from deepeval.test_case import Audio
retry_cartesia = create_retry_decorator(PS.CARTESIA)
DEFAULT_BASE_URL = "https://api.cartesia.ai"
DEFAULT_API_VERSION = "2026-08-14"
# The batch endpoint only serves the ink-whisper family; `ink-2` is realtime
# only, so it is not a valid default here.
DEFAULT_STT_MODEL = "ink-whisper"
class CartesiaSTTModel(BufferedTranscribeMixin, DeepEvalBaseSTT):
"""Cartesia Ink speech-to-text."""
# Whisper-derived, so it finishes a word the barge-in cut off unless the
# clip is presented as a complete utterance.
truncated_audio_pad_seconds: float = 0.3
def __init__(
self,
model: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
language: Optional[str] = None,
api_version: str = DEFAULT_API_VERSION,
cost_per_minute: Optional[float] = None,
transcription_kwargs: Optional[Dict] = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
):
settings = get_settings()
self.name = parse_model_name(model or DEFAULT_STT_MODEL)
self.language = language
self.api_version = api_version
self.transcription_kwargs = transcription_kwargs or {}
# Cartesia meters in credits with no published dollar rate, so there is
# nothing to default to. Pass `cost_per_minute` to account for spend.
self.cost_per_minute = cost_per_minute
self.api_key = (
SecretStr(api_key)
if api_key is not None
else settings.CARTESIA_API_KEY
)
self.base_url = (
str(base_url).rstrip("/")
if base_url is not None
else DEFAULT_BASE_URL
)
self.timeout = timeout
self.model = self.load_model()
def load_model(self) -> SpeechTransport:
api_key = require_secret_api_key(
self.api_key,
provider_label="Cartesia",
env_var_name="CARTESIA_API_KEY",
param_hint="`api_key` to CartesiaSTTModel(...)",
)
return SpeechTransport(
provider_label="Cartesia",
base_url=self.base_url,
headers={
"X-Api-Key": api_key,
"Cartesia-Version": self.api_version,
},
timeout=self.timeout,
)
def _multipart(
self, audio: Audio, language: Optional[str], kwargs: Dict[str, Any]
) -> Multipart:
lang = language or self.language
request = CartesiaSTTRequest(
model=self.name,
# Cartesia defaults to English when `language` is absent rather
# than detecting, so "auto" has to be dropped instead of forwarded.
language=None if lang == "auto" else lang,
**{**self.transcription_kwargs, **kwargs},
)
return Multipart(
file_field="file",
filename=audio.filename or "audio.wav",
content=audio.get_bytes(),
content_type=audio.mimeType or "audio/wav",
fields=dump_request(request),
)
def _cost(
self, audio: Audio, payload: CartesiaSTTResponse
) -> Optional[float]:
if self.cost_per_minute is None:
return None
seconds = payload.duration
if seconds is None:
seconds = audio_duration_seconds(audio)
if seconds is None:
return None
return seconds / 60.0 * self.cost_per_minute
def _parse(self, payload: Any) -> CartesiaSTTResponse:
return parse_response(
CartesiaSTTResponse, payload, provider_label="Cartesia"
)
@retry_cartesia
def transcribe(
self, audio: Audio, *, language: Optional[str] = None, **kwargs
) -> Tuple[str, Optional[float]]:
payload = self._parse(
self.model.request_json(
"POST",
"/stt",
multipart=self._multipart(audio, language, kwargs),
)
)
return payload.transcript() or "", self._cost(audio, payload)
@retry_cartesia
async def a_transcribe(
self, audio: Audio, *, language: Optional[str] = None, **kwargs
) -> Tuple[str, Optional[float]]:
payload = self._parse(
await self.model.a_request_json(
"POST",
"/stt",
multipart=self._multipart(audio, language, kwargs),
)
)
return payload.transcript() or "", self._cost(audio, payload)
def get_model_name(self) -> str:
return self.name
+43
View File
@@ -0,0 +1,43 @@
"""Cartesia speech-to-text request and response shapes."""
from typing import Optional
from pydantic import BaseModel, ConfigDict
# Requests allow extras so `transcription_kwargs` can set a provider parameter
# deepeval has no field for. Responses allow them so a provider adding a field
# never breaks a run. `protected_namespaces` is cleared because pydantic
# reserves the `model_` prefix and Cartesia's field is called `model`.
_REQUEST = ConfigDict(extra="allow", protected_namespaces=())
_RESPONSE = ConfigDict(extra="allow", protected_namespaces=())
class CartesiaSTTRequest(BaseModel):
"""Scalar multipart fields for `POST /stt`.
Cartesia defaults to English when `language` is absent rather than
detecting, so there is no way to ask for detection here.
"""
model_config = _REQUEST
model: str
language: Optional[str] = None
class CartesiaSTTResponse(BaseModel):
"""Response from `POST /stt`."""
model_config = _RESPONSE
text: Optional[str] = None
duration: Optional[float] = None
language: Optional[str] = None
def transcript(self) -> Optional[str]:
"""The spoken text, or `None` when the response carries no `text`.
`None` and `""` mean different things: `""` is silence, which is a
perfectly good answer, while `None` means the field was absent.
"""
return self.text
+3
View File
@@ -0,0 +1,3 @@
from deepeval.models.stt.deepgram.deepgram import DeepgramSTTModel
__all__ = ["DeepgramSTTModel"]
+151
View File
@@ -0,0 +1,151 @@
from typing import Any, Dict, Optional, Tuple
from pydantic import SecretStr
from deepeval.config.settings import get_settings
from deepeval.constants import ProviderSlug as PS
from deepeval.models.base_model import DeepEvalBaseSTT
from deepeval.models.retry_policy import create_retry_decorator
from deepeval.models.speech import (
DEFAULT_TIMEOUT_SECONDS,
SpeechTransport,
dump_request,
parse_response,
)
from deepeval.models.stt._audio import audio_duration_seconds
from deepeval.models.stt._stream import BufferedTranscribeMixin
from deepeval.models.stt.deepgram.schema import (
DeepgramListenParams,
DeepgramListenResponse,
)
from deepeval.models.utils import parse_model_name, require_secret_api_key
from deepeval.test_case import Audio
retry_deepgram = create_retry_decorator(PS.DEEPGRAM)
DEFAULT_BASE_URL = "https://api.deepgram.com"
DEFAULT_STT_MODEL = "nova-3"
_STT_PRICE_PER_MINUTE = {
"nova-3": 0.0043,
"nova-3-general": 0.0043,
}
class DeepgramSTTModel(BufferedTranscribeMixin, DeepEvalBaseSTT):
"""Deepgram Nova speech-to-text."""
# Nova is not autoregressive, so it transcribes what it hears and stops
# rather than completing a word the barge-in cut off. No padding needed.
truncated_audio_pad_seconds: float = 0.0
def __init__(
self,
model: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
language: Optional[str] = None,
smart_format: bool = True,
cost_per_minute: Optional[float] = None,
transcription_kwargs: Optional[Dict] = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
):
settings = get_settings()
self.name = parse_model_name(model or DEFAULT_STT_MODEL)
self.language = language
self.smart_format = smart_format
self.transcription_kwargs = transcription_kwargs or {}
self.cost_per_minute = (
cost_per_minute
if cost_per_minute is not None
else _STT_PRICE_PER_MINUTE.get(self.name)
)
self.api_key = (
SecretStr(api_key)
if api_key is not None
else settings.DEEPGRAM_API_KEY
)
self.base_url = (
str(base_url).rstrip("/")
if base_url is not None
else DEFAULT_BASE_URL
)
self.timeout = timeout
self.model = self.load_model()
def load_model(self) -> SpeechTransport:
api_key = require_secret_api_key(
self.api_key,
provider_label="Deepgram",
env_var_name="DEEPGRAM_API_KEY",
param_hint="`api_key` to DeepgramSTTModel(...)",
)
return SpeechTransport(
provider_label="Deepgram",
base_url=self.base_url,
headers={"Authorization": f"Token {api_key}"},
timeout=self.timeout,
)
def _params(
self, language: Optional[str], kwargs: Dict[str, Any]
) -> Dict[str, Any]:
lang = language or self.language
auto = lang == "auto"
return dump_request(
DeepgramListenParams(
model=self.name,
smart_format=self.smart_format,
# Deepgram detects only when asked to, so "auto" turns
# detection on instead of omitting the field.
language=None if auto else lang,
detect_language=True if auto else None,
**{**self.transcription_kwargs, **kwargs},
)
)
def _cost(self, audio: Audio) -> Optional[float]:
if self.cost_per_minute is None:
return None
seconds = audio_duration_seconds(audio)
if seconds is None:
return None
return seconds / 60.0 * self.cost_per_minute
def _parse(self, payload: Any) -> DeepgramListenResponse:
return parse_response(
DeepgramListenResponse, payload, provider_label="Deepgram"
)
@retry_deepgram
def transcribe(
self, audio: Audio, *, language: Optional[str] = None, **kwargs
) -> Tuple[str, Optional[float]]:
payload = self._parse(
self.model.request_json(
"POST",
"/v1/listen",
params=self._params(language, kwargs),
content=audio.get_bytes(),
headers={"Content-Type": audio.mimeType or "audio/wav"},
)
)
return payload.transcript() or "", self._cost(audio)
@retry_deepgram
async def a_transcribe(
self, audio: Audio, *, language: Optional[str] = None, **kwargs
) -> Tuple[str, Optional[float]]:
payload = self._parse(
await self.model.a_request_json(
"POST",
"/v1/listen",
params=self._params(language, kwargs),
content=audio.get_bytes(),
headers={"Content-Type": audio.mimeType or "audio/wav"},
)
)
return payload.transcript() or "", self._cost(audio)
def get_model_name(self) -> str:
return self.name
+73
View File
@@ -0,0 +1,73 @@
"""Deepgram speech-to-text request and response shapes.
Deepgram takes its transcription parameters in the query string rather than a
body, so the request side is a params model.
"""
from typing import List, Optional
from pydantic import BaseModel, ConfigDict
# Requests allow extras so `transcription_kwargs` can set a provider parameter
# deepeval has no field for. Responses allow them so a provider adding a field
# never breaks a run. `protected_namespaces` is cleared because pydantic
# reserves the `model_` prefix and Deepgram's parameter is called `model`.
_REQUEST = ConfigDict(extra="allow", protected_namespaces=())
_RESPONSE = ConfigDict(extra="allow", protected_namespaces=())
class DeepgramListenParams(BaseModel):
"""Query string for `POST /v1/listen`.
Deepgram detects the language only when asked to. An absent `language`
means English rather than detection, so `detect_language` has to be set
explicitly.
"""
model_config = _REQUEST
model: str
smart_format: bool = True
language: Optional[str] = None
detect_language: Optional[bool] = None
class DeepgramAlternative(BaseModel):
model_config = _RESPONSE
transcript: Optional[str] = None
class DeepgramChannel(BaseModel):
model_config = _RESPONSE
alternatives: List[DeepgramAlternative] = []
class DeepgramResults(BaseModel):
model_config = _RESPONSE
channels: List[DeepgramChannel] = []
class DeepgramListenResponse(BaseModel):
"""Response from `POST /v1/listen`."""
model_config = _RESPONSE
results: Optional[DeepgramResults] = None
def transcript(self) -> Optional[str]:
"""The spoken text, or `None` when the response carries no transcript.
`None` and `""` mean different things: silence comes back as a present
but empty `transcript`, where `None` means the nesting this reads
through was not there at all.
"""
if self.results is None:
return None
for channel in self.results.channels:
for alternative in channel.alternatives:
if alternative.transcript is not None:
return alternative.transcript
return None
@@ -0,0 +1,3 @@
from deepeval.models.stt.elevenlabs.elevenlabs import ElevenLabsSTTModel
__all__ = ["ElevenLabsSTTModel"]
@@ -0,0 +1,154 @@
from typing import Any, Dict, Optional, Tuple
from pydantic import SecretStr
from deepeval.config.settings import get_settings
from deepeval.constants import ProviderSlug as PS
from deepeval.models.base_model import DeepEvalBaseSTT
from deepeval.models.retry_policy import create_retry_decorator
from deepeval.models.speech import (
DEFAULT_TIMEOUT_SECONDS,
Multipart,
SpeechTransport,
dump_request,
parse_response,
)
from deepeval.models.stt._audio import audio_duration_seconds
from deepeval.models.stt._stream import BufferedTranscribeMixin
from deepeval.models.stt.elevenlabs.schema import (
ElevenLabsSTTRequest,
ElevenLabsSTTResponse,
)
from deepeval.models.utils import parse_model_name, require_secret_api_key
from deepeval.test_case import Audio
retry_elevenlabs = create_retry_decorator(PS.ELEVENLABS)
DEFAULT_BASE_URL = "https://api.elevenlabs.io"
# `scribe_v1` is deprecated in favour of v2.
DEFAULT_STT_MODEL = "scribe_v2"
_STT_PRICE_PER_HOUR = {
"scribe_v2": 0.22,
"scribe_v1": 0.22,
}
class ElevenLabsSTTModel(BufferedTranscribeMixin, DeepEvalBaseSTT):
"""ElevenLabs Scribe speech-to-text."""
# Scribe completes a word clipped mid-syllable, inventing speech the caller
# never heard, so interrupted audio gets a tail of silence to mark the end.
truncated_audio_pad_seconds: float = 0.3
def __init__(
self,
model: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
language: Optional[str] = None,
cost_per_hour: Optional[float] = None,
transcription_kwargs: Optional[Dict] = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
):
settings = get_settings()
self.name = parse_model_name(model or DEFAULT_STT_MODEL)
self.language = language
self.transcription_kwargs = transcription_kwargs or {}
self.cost_per_hour = (
cost_per_hour
if cost_per_hour is not None
else _STT_PRICE_PER_HOUR.get(self.name)
)
self.api_key = (
SecretStr(api_key)
if api_key is not None
else settings.ELEVENLABS_API_KEY
)
self.base_url = (
str(base_url).rstrip("/")
if base_url is not None
else DEFAULT_BASE_URL
)
self.timeout = timeout
self.model = self.load_model()
def load_model(self) -> SpeechTransport:
api_key = require_secret_api_key(
self.api_key,
provider_label="ElevenLabs",
env_var_name="ELEVENLABS_API_KEY",
param_hint="`api_key` to ElevenLabsSTTModel(...)",
)
return SpeechTransport(
provider_label="ElevenLabs",
base_url=self.base_url,
headers={"xi-api-key": api_key},
timeout=self.timeout,
)
def _multipart(
self, audio: Audio, language: Optional[str], kwargs: Dict[str, Any]
) -> Multipart:
lang = language or self.language
request = ElevenLabsSTTRequest(
model_id=self.name,
# Scribe detects the language whenever `language_code` is absent,
# so "auto" means leaving it out.
language_code=None if lang == "auto" else lang,
**{**self.transcription_kwargs, **kwargs},
)
return Multipart(
file_field="file",
filename=audio.filename or "audio.wav",
content=audio.get_bytes(),
content_type=audio.mimeType or "audio/wav",
fields=dump_request(request),
)
def _cost(
self, audio: Audio, payload: ElevenLabsSTTResponse
) -> Optional[float]:
if self.cost_per_hour is None:
return None
seconds = payload.audio_duration_secs
if seconds is None:
seconds = audio_duration_seconds(audio)
if seconds is None:
return None
return seconds / 3600.0 * self.cost_per_hour
def _parse(self, payload: Any) -> ElevenLabsSTTResponse:
return parse_response(
ElevenLabsSTTResponse, payload, provider_label="ElevenLabs"
)
@retry_elevenlabs
def transcribe(
self, audio: Audio, *, language: Optional[str] = None, **kwargs
) -> Tuple[str, Optional[float]]:
payload = self._parse(
self.model.request_json(
"POST",
"/v1/speech-to-text",
multipart=self._multipart(audio, language, kwargs),
)
)
return payload.transcript() or "", self._cost(audio, payload)
@retry_elevenlabs
async def a_transcribe(
self, audio: Audio, *, language: Optional[str] = None, **kwargs
) -> Tuple[str, Optional[float]]:
payload = self._parse(
await self.model.a_request_json(
"POST",
"/v1/speech-to-text",
multipart=self._multipart(audio, language, kwargs),
)
)
return payload.transcript() or "", self._cost(audio, payload)
def get_model_name(self) -> str:
return self.name
+64
View File
@@ -0,0 +1,64 @@
"""ElevenLabs speech-to-text request and response shapes.
`protected_namespaces=()` is cleared because ElevenLabs names its field
`model_id` and pydantic reserves the `model_` prefix for its own methods, so
without it every import warns about a conflict that does not matter here.
"""
from typing import List, Optional
from pydantic import BaseModel, ConfigDict
# Requests allow extras so `transcription_kwargs` can set a provider parameter
# deepeval has no field for. Responses allow them so a provider adding a field
# never breaks a run.
_REQUEST = ConfigDict(extra="allow", protected_namespaces=())
_RESPONSE = ConfigDict(extra="allow", protected_namespaces=())
class ElevenLabsSTTRequest(BaseModel):
"""Scalar multipart fields for `POST /v1/speech-to-text`.
Scribe detects the language whenever `language_code` is absent, so omitting
it is how automatic detection is requested.
"""
model_config = _REQUEST
model_id: str
language_code: Optional[str] = None
class ElevenLabsTranscriptChannel(BaseModel):
model_config = _RESPONSE
text: Optional[str] = None
class ElevenLabsSTTResponse(BaseModel):
"""Response from `POST /v1/speech-to-text`.
A single-channel request answers with `text`; a multi-channel one answers
with one entry per channel in `transcripts` and no top-level `text`.
"""
model_config = _RESPONSE
text: Optional[str] = None
audio_duration_secs: Optional[float] = None
transcripts: Optional[List[ElevenLabsTranscriptChannel]] = None
def transcript(self) -> Optional[str]:
"""The spoken text, or `None` when the response carries no transcript.
`None` and `""` mean different things: `""` is silence, which is a
perfectly good answer, while `None` means neither field was present.
"""
if self.text is not None:
return self.text
if self.transcripts is not None:
parts = [
channel.text for channel in self.transcripts if channel.text
]
return " ".join(parts).strip()
return None
+3
View File
@@ -0,0 +1,3 @@
from deepeval.models.stt.openai.openai import OpenAISTTModel
__all__ = ["OpenAISTTModel"]
@@ -1,8 +1,4 @@
import io
import wave
from typing import (
AsyncGenerator,
AsyncIterable,
Optional,
Dict,
Tuple,
@@ -10,13 +6,14 @@ from typing import (
from pydantic import SecretStr
from openai import OpenAI, AsyncOpenAI
from deepeval.test_case import Audio, AudioChunk
from deepeval.test_case import Audio
from deepeval.models.base_model import DeepEvalBaseSTT
from deepeval.models.stt._audio import audio_duration_seconds
from deepeval.models.stt._stream import BufferedTranscribeMixin
from deepeval.models.utils import parse_model_name, require_secret_api_key
from deepeval.models.retry_policy import create_retry_decorator
from deepeval.constants import ProviderSlug as PS
from deepeval.config.settings import get_settings
from deepeval.voice.connectors import audio_utils
retry_openai = create_retry_decorator(PS.OPENAI)
@@ -32,7 +29,7 @@ _STT_PRICE_PER_MINUTE = {
}
class OpenAISTTModel(DeepEvalBaseSTT):
class OpenAISTTModel(BufferedTranscribeMixin, DeepEvalBaseSTT):
# Whisper and the gpt-4o transcribe family both finish clipped words, so
# they need a tail of silence to be told the utterance ended where it did.
truncated_audio_pad_seconds: float = 0.3
@@ -115,20 +112,6 @@ class OpenAISTTModel(DeepEvalBaseSTT):
merged["language"] = lang
return merged
@staticmethod
def _audio_duration_seconds(audio: Audio) -> Optional[float]:
if audio.duration is not None:
return audio.duration
try:
with wave.open(io.BytesIO(audio.get_bytes()), "rb") as wf:
frames = wf.getnframes()
rate = wf.getframerate()
if rate:
return frames / float(rate)
except (wave.Error, EOFError, ValueError):
pass
return None
def _calculate_cost(self, audio: Audio, response) -> Optional[float]:
usage = getattr(response, "usage", None)
usage_type = getattr(usage, "type", None)
@@ -152,7 +135,7 @@ class OpenAISTTModel(DeepEvalBaseSTT):
else None
)
if seconds is None:
seconds = self._audio_duration_seconds(audio)
seconds = audio_duration_seconds(audio)
if seconds is not None:
return seconds / 60.0 * self.cost_per_minute
@@ -180,52 +163,5 @@ class OpenAISTTModel(DeepEvalBaseSTT):
)
return response.text, self._calculate_cost(audio, response)
async def a_transcribe_stream(
self,
audio_stream: AsyncIterable[AudioChunk],
*args,
language: Optional[str] = None,
partial_every_seconds: float = 1.0,
**kwargs,
) -> AsyncGenerator[str, None]:
"""Yield progressively updated transcripts from a live PCM stream.
OpenAI's batch transcription API has no true partial streaming, so
this buffers chunks and re-transcribes the accumulated WAV whenever
roughly `partial_every_seconds` of new audio arrives. Used as a
duplex barge-in fallback when the transport has no text partials
(e.g. LiveKit).
"""
pcm = bytearray()
sample_rate = 24000
bytes_since_partial = 0
last_text = ""
async for chunk in audio_stream:
if chunk.sampleRate:
sample_rate = chunk.sampleRate
frame = chunk.get_bytes()
pcm.extend(frame)
bytes_since_partial += len(frame)
threshold = int(sample_rate * 2 * partial_every_seconds)
if bytes_since_partial >= threshold or chunk.final:
audio = Audio.from_bytes(
audio_utils.pcm16_to_wav_bytes(bytes(pcm), sample_rate, 1),
"audio/wav",
sampleRate=sample_rate,
encoding="wav",
)
text, _ = await self.a_transcribe(
audio, language=language, **kwargs
)
if text and text != last_text:
last_text = text
yield text
bytes_since_partial = 0
if chunk.final:
break
def supports_streaming(self) -> bool:
return True
def get_model_name(self) -> str:
return self.name
-3
View File
@@ -1,3 +0,0 @@
from deepeval.models.stt_models.openai_stt import OpenAISTTModel
__all__ = ["OpenAISTTModel"]
+11
View File
@@ -0,0 +1,11 @@
from deepeval.models.tts.cartesia import CartesiaTTSModel
from deepeval.models.tts.deepgram import DeepgramTTSModel
from deepeval.models.tts.elevenlabs import ElevenLabsTTSModel
from deepeval.models.tts.openai import OpenAITTSModel
__all__ = [
"CartesiaTTSModel",
"DeepgramTTSModel",
"ElevenLabsTTSModel",
"OpenAITTSModel",
]
+72
View File
@@ -0,0 +1,72 @@
"""Turning a stream of PCM bytes into `AudioChunk`s."""
from __future__ import annotations
from typing import AsyncGenerator, AsyncIterable, Optional
from deepeval.test_case import AudioChunk
# A frame has to be playable on its own, which rules out every container format
# (their headers declare a length the first frame does not know yet), so a
# stream is always raw 16-bit mono PCM.
STREAM_MIME = "audio/pcm"
STREAM_ENCODING = "pcm"
DEFAULT_FRAME_SECONDS = 0.1
def pcm_chunk(
data: bytes, *, sample_rate: int, final: bool = False
) -> AudioChunk:
return AudioChunk.from_bytes(
data,
STREAM_MIME,
sampleRate=sample_rate,
encoding=STREAM_ENCODING,
duration=len(data) / 2 / sample_rate,
final=final,
)
def frame_size_bytes(
sample_rate: int, frame_seconds: float = DEFAULT_FRAME_SECONDS
) -> int:
"""Bytes in one frame, always even so a frame never splits a sample."""
samples = max(1, int(sample_rate * frame_seconds))
return samples * 2
async def frame_pcm_stream(
source: AsyncIterable[bytes],
*,
sample_rate: int,
frame_seconds: float = DEFAULT_FRAME_SECONDS,
) -> AsyncGenerator[AudioChunk, None]:
"""Re-cut arbitrary byte reads from an HTTP body into fixed audio frames.
A provider's chunked response boundaries follow its network writes, not the
audio, so reads arrive at unhelpful sizes and can split a sample in half.
The last frame is held back so it can be flagged `final`, which is how a
consumer knows the utterance is complete without also tracking the stream.
"""
frame_bytes = frame_size_bytes(sample_rate, frame_seconds)
buffer = bytearray()
held: Optional[bytes] = None
async for data in source:
if not data:
continue
buffer.extend(data)
while len(buffer) >= frame_bytes:
frame = bytes(buffer[:frame_bytes])
del buffer[:frame_bytes]
if held is not None:
yield pcm_chunk(held, sample_rate=sample_rate)
held = frame
if buffer:
if held is not None:
yield pcm_chunk(held, sample_rate=sample_rate)
held = bytes(buffer)
if held is not None:
yield pcm_chunk(held, sample_rate=sample_rate, final=True)
+3
View File
@@ -0,0 +1,3 @@
from deepeval.models.tts.cartesia.cartesia import CartesiaTTSModel
__all__ = ["CartesiaTTSModel"]
+195
View File
@@ -0,0 +1,195 @@
from typing import Any, AsyncGenerator, Dict, Optional, Tuple
from pydantic import SecretStr
from deepeval.config.settings import get_settings
from deepeval.constants import ProviderSlug as PS
from deepeval.errors import DeepEvalError
from deepeval.models.base_model import DeepEvalBaseTTS
from deepeval.models.retry_policy import create_retry_decorator
from deepeval.models.speech import (
DEFAULT_TIMEOUT_SECONDS,
SpeechTransport,
dump_request,
)
from deepeval.models.tts._frames import frame_pcm_stream
from deepeval.models.tts.cartesia.schema import (
CartesiaOutputFormat,
CartesiaTTSRequest,
)
from deepeval.models.utils import parse_model_name, require_secret_api_key
from deepeval.test_case import Audio, AudioChunk
retry_cartesia = create_retry_decorator(PS.CARTESIA)
DEFAULT_BASE_URL = "https://api.cartesia.ai"
# Cartesia dates its API and requires the version on every request. Pinning it
# here rather than tracking "latest" means a server-side release cannot change
# the response shape underneath us.
DEFAULT_API_VERSION = "2026-08-14"
# `sonic-2` and `sonic-turbo` are gone from the current model enum and now fail
# with model_not_found, so neither is a safe default.
DEFAULT_TTS_MODEL = "sonic-3.6"
DEFAULT_SAMPLE_RATE = 24000
_SAMPLE_RATES = frozenset({8000, 16000, 22050, 24000, 44100, 48000})
class CartesiaTTSModel(DeepEvalBaseTTS):
"""Cartesia Sonic text-to-speech.
Cartesia has no default voice, so `voice` is required: it is a voice ID from
your Cartesia account rather than a name from a fixed cast.
"""
def __init__(
self,
model: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
voice: Optional[str] = None,
sample_rate: int = DEFAULT_SAMPLE_RATE,
language: Optional[str] = None,
api_version: str = DEFAULT_API_VERSION,
cost_per_1m_chars: Optional[float] = None,
generation_kwargs: Optional[Dict] = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
):
settings = get_settings()
self.name = parse_model_name(model or DEFAULT_TTS_MODEL)
self.voice = voice
self.language = language
self.api_version = api_version
self.generation_kwargs = generation_kwargs or {}
# Cartesia meters in credits and publishes no credit-to-dollar rate, so
# there is no price table to default to. Pass `cost_per_1m_chars` to
# have synthesis costs accounted for.
self.cost_per_1m_chars = cost_per_1m_chars
if sample_rate not in _SAMPLE_RATES:
raise ValueError(
f"Cartesia cannot synthesize at {sample_rate} Hz. "
f"Supported rates: {sorted(_SAMPLE_RATES)}."
)
# Instance attribute, not the class default: connectors read
# `tts.sample_rate` off the instance to configure uplink resampling.
self.sample_rate = sample_rate
self.api_key = (
SecretStr(api_key)
if api_key is not None
else settings.CARTESIA_API_KEY
)
self.base_url = (
str(base_url).rstrip("/")
if base_url is not None
else DEFAULT_BASE_URL
)
self.timeout = timeout
self.model = self.load_model()
def load_model(self) -> SpeechTransport:
api_key = require_secret_api_key(
self.api_key,
provider_label="Cartesia",
env_var_name="CARTESIA_API_KEY",
param_hint="`api_key` to CartesiaTTSModel(...)",
)
return SpeechTransport(
provider_label="Cartesia",
base_url=self.base_url,
headers={
"X-Api-Key": api_key,
"Cartesia-Version": self.api_version,
},
timeout=self.timeout,
)
def _resolve_voice(self, voice: Optional[str]) -> str:
resolved = voice or self.voice
if not resolved:
raise DeepEvalError(
"Cartesia has no default voice. Pass `voice` to "
"CartesiaTTSModel(...) with a voice ID from your Cartesia "
"account, or set it on the golden's `Persona`."
)
return resolved
def _body(
self,
text: str,
voice: Optional[str],
*,
container: str,
extra: Dict[str, Any],
) -> Dict[str, Any]:
return dump_request(
CartesiaTTSRequest(
model_id=self.name,
transcript=text,
voice=self._resolve_voice(voice),
output_format=CartesiaOutputFormat(
container=container, sample_rate=self.sample_rate
),
language=self.language,
**{**self.generation_kwargs, **extra},
)
)
def synthesis_cost(self, text: str) -> Optional[float]:
if self.cost_per_1m_chars is None:
return None
return len(text) / 1e6 * self.cost_per_1m_chars
@retry_cartesia
def synthesize(
self, text: str, *, voice: Optional[str] = None, **kwargs
) -> Tuple[Audio, Optional[float]]:
data = self.model.request_bytes(
"POST",
"/tts/bytes",
json=self._body(text, voice, container="wav", extra=kwargs),
)
return self._to_audio(data), self.synthesis_cost(text)
@retry_cartesia
async def a_synthesize(
self, text: str, *, voice: Optional[str] = None, **kwargs
) -> Tuple[Audio, Optional[float]]:
data = await self.model.a_request_bytes(
"POST",
"/tts/bytes",
json=self._body(text, voice, container="wav", extra=kwargs),
)
return self._to_audio(data), self.synthesis_cost(text)
def _to_audio(self, data: bytes) -> Audio:
return Audio.from_bytes(
data,
"audio/wav",
sampleRate=self.sample_rate,
encoding="wav",
)
async def a_synthesize_stream(
self, text: str, *args, voice: Optional[str] = None, **kwargs
) -> AsyncGenerator[AudioChunk, None]:
source = self.model.a_stream_bytes(
"POST",
"/tts/bytes",
# A raw container is headerless PCM, which is what makes each frame
# playable the moment it arrives.
json=self._body(text, voice, container="raw", extra=kwargs),
)
async for chunk in frame_pcm_stream(
source, sample_rate=self.sample_rate
):
yield chunk
def supports_streaming(self) -> bool:
return True
def get_model_name(self) -> str:
return self.name
+45
View File
@@ -0,0 +1,45 @@
"""Cartesia text-to-speech request shapes.
Cartesia is the only provider here that nests its audio settings, in an
`output_format` object rather than flat fields, so that nesting gets a model of
its own.
"""
from typing import Optional
from pydantic import BaseModel, ConfigDict
# Requests allow extras so `generation_kwargs` can set a provider parameter
# deepeval has no field for. `protected_namespaces` is cleared because pydantic
# reserves the `model_` prefix and Cartesia's field is called `model_id`.
_REQUEST = ConfigDict(extra="allow", protected_namespaces=())
class CartesiaOutputFormat(BaseModel):
"""The `output_format` object on a TTS request.
`container` is `wav` for a batch call and `raw` for a streamed one: a WAV
header declares a length the first frame of a stream does not know yet.
"""
model_config = _REQUEST
container: str
sample_rate: int
encoding: str = "pcm_s16le"
class CartesiaTTSRequest(BaseModel):
"""Body for `POST /tts/bytes`.
`voice` is a voice ID from your Cartesia account. There is no default, which
is why the model requires one rather than falling back.
"""
model_config = _REQUEST
model_id: str
transcript: str
voice: str
output_format: CartesiaOutputFormat
language: Optional[str] = None
+3
View File
@@ -0,0 +1,3 @@
from deepeval.models.tts.deepgram.deepgram import DeepgramTTSModel
__all__ = ["DeepgramTTSModel"]
+202
View File
@@ -0,0 +1,202 @@
from typing import Any, AsyncGenerator, Dict, Optional, Tuple
from pydantic import SecretStr
from deepeval.config.settings import get_settings
from deepeval.constants import ProviderSlug as PS
from deepeval.models.base_model import DeepEvalBaseTTS
from deepeval.models.retry_policy import create_retry_decorator
from deepeval.models.speech import (
DEFAULT_TIMEOUT_SECONDS,
SpeechTransport,
dump_request,
)
from deepeval.models.tts._frames import frame_pcm_stream
from deepeval.models.tts.deepgram.schema import (
DeepgramSpeakParams,
DeepgramSpeakRequest,
)
from deepeval.models.utils import parse_model_name, require_secret_api_key
from deepeval.test_case import Audio, AudioChunk
retry_deepgram = create_retry_decorator(PS.DEEPGRAM)
DEFAULT_BASE_URL = "https://api.deepgram.com"
DEFAULT_TTS_MODEL = "aura-2-thalia-en"
DEFAULT_SAMPLE_RATE = 24000
# Rates Deepgram accepts for linear16.
_PCM_SAMPLE_RATES = frozenset({8000, 16000, 24000, 32000, 48000})
_AURA_2_PRICE_PER_1M_CHARS = 30.0
_AURA_1_PRICE_PER_1M_CHARS = 15.0
class DeepgramTTSModel(DeepEvalBaseTTS):
"""Deepgram Aura text-to-speech.
Aura model names carry the voice and the language in the identifier
(`aura-2-thalia-en`), so the `voice` argument rewrites that middle segment
rather than being sent as a separate field.
"""
def __init__(
self,
model: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
voice: Optional[str] = None,
sample_rate: int = DEFAULT_SAMPLE_RATE,
cost_per_1m_chars: Optional[float] = None,
generation_kwargs: Optional[Dict] = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
):
settings = get_settings()
self.name = parse_model_name(model or DEFAULT_TTS_MODEL)
# A voice given here is folded into the model name up front, so
# `get_model_name()` reports what will actually be synthesized.
if voice:
self.name = apply_voice(self.name, voice)
self.generation_kwargs = generation_kwargs or {}
self.cost_per_1m_chars = (
cost_per_1m_chars
if cost_per_1m_chars is not None
else _default_price(self.name)
)
if sample_rate not in _PCM_SAMPLE_RATES:
raise ValueError(
f"Deepgram cannot synthesize linear16 at {sample_rate} Hz. "
f"Supported rates: {sorted(_PCM_SAMPLE_RATES)}."
)
# Instance attribute, not the class default: connectors read
# `tts.sample_rate` off the instance to configure uplink resampling.
self.sample_rate = sample_rate
self.api_key = (
SecretStr(api_key)
if api_key is not None
else settings.DEEPGRAM_API_KEY
)
self.base_url = (
str(base_url).rstrip("/")
if base_url is not None
else DEFAULT_BASE_URL
)
self.timeout = timeout
self.model = self.load_model()
def load_model(self) -> SpeechTransport:
api_key = require_secret_api_key(
self.api_key,
provider_label="Deepgram",
env_var_name="DEEPGRAM_API_KEY",
param_hint="`api_key` to DeepgramTTSModel(...)",
)
return SpeechTransport(
provider_label="Deepgram",
base_url=self.base_url,
headers={"Authorization": f"Token {api_key}"},
timeout=self.timeout,
)
def _params(
self, voice: Optional[str], *, container: str, extra: Dict[str, Any]
) -> Dict[str, Any]:
return dump_request(
DeepgramSpeakParams(
model=apply_voice(self.name, voice) if voice else self.name,
container=container,
sample_rate=self.sample_rate,
**{**self.generation_kwargs, **extra},
)
)
def _body(self, text: str) -> Dict[str, Any]:
return dump_request(DeepgramSpeakRequest(text=text))
def synthesis_cost(self, text: str) -> Optional[float]:
if self.cost_per_1m_chars is None:
return None
return len(text) / 1e6 * self.cost_per_1m_chars
@retry_deepgram
def synthesize(
self, text: str, *, voice: Optional[str] = None, **kwargs
) -> Tuple[Audio, Optional[float]]:
data = self.model.request_bytes(
"POST",
"/v1/speak",
params=self._params(voice, container="wav", extra=kwargs),
json=self._body(text),
)
return self._to_audio(data), self.synthesis_cost(text)
@retry_deepgram
async def a_synthesize(
self, text: str, *, voice: Optional[str] = None, **kwargs
) -> Tuple[Audio, Optional[float]]:
data = await self.model.a_request_bytes(
"POST",
"/v1/speak",
params=self._params(voice, container="wav", extra=kwargs),
json=self._body(text),
)
return self._to_audio(data), self.synthesis_cost(text)
def _to_audio(self, data: bytes) -> Audio:
return Audio.from_bytes(
data,
"audio/wav",
sampleRate=self.sample_rate,
encoding="wav",
)
async def a_synthesize_stream(
self, text: str, *args, voice: Optional[str] = None, **kwargs
) -> AsyncGenerator[AudioChunk, None]:
source = self.model.a_stream_bytes(
"POST",
"/v1/speak",
# `container=none` is what makes this headerless PCM; a WAV header
# would arrive declaring a length the first frame cannot know.
params=self._params(voice, container="none", extra=kwargs),
json=self._body(text),
)
async for chunk in frame_pcm_stream(
source, sample_rate=self.sample_rate
):
yield chunk
def supports_streaming(self) -> bool:
return True
def get_model_name(self) -> str:
return self.name
def apply_voice(model_name: str, voice: str) -> str:
"""Swap the voice segment of an Aura model name.
Accepts either a bare voice (`zeus`) or a full model name
(`aura-2-zeus-en`), since a `Persona.voice` written for another provider is
just a name while one written for Deepgram may well be the whole thing.
"""
if voice.startswith("aura"):
return voice
parts = model_name.split("-")
# aura-2-thalia-en -> [aura, 2, thalia, en]; aura-asteria-en -> [aura, asteria, en]
if len(parts) >= 4:
return "-".join([parts[0], parts[1], voice, *parts[3:]])
if len(parts) == 3:
return "-".join([parts[0], voice, parts[2]])
return voice
def _default_price(model_name: str) -> Optional[float]:
if model_name.startswith("aura-2"):
return _AURA_2_PRICE_PER_1M_CHARS
if model_name.startswith("aura"):
return _AURA_1_PRICE_PER_1M_CHARS
return None
+36
View File
@@ -0,0 +1,36 @@
"""Deepgram text-to-speech request shapes.
Deepgram is the odd one here: the text goes in the body and every other
parameter goes in the query string, which is the reverse of the other providers.
Hence a params model alongside the request body model.
"""
from pydantic import BaseModel, ConfigDict
# Requests allow extras so `generation_kwargs` can set a provider parameter
# deepeval has no field for. `protected_namespaces` is cleared because pydantic
# reserves the `model_` prefix and Deepgram's parameter is called `model`.
_REQUEST = ConfigDict(extra="allow", protected_namespaces=())
class DeepgramSpeakParams(BaseModel):
"""Query string for `POST /v1/speak`.
`container` is `wav` for a batch call and `none` for a streamed one: a WAV
header declares a length the first frame of a stream does not know yet.
"""
model_config = _REQUEST
model: str
container: str
sample_rate: int
encoding: str = "linear16"
class DeepgramSpeakRequest(BaseModel):
"""Body for `POST /v1/speak`."""
model_config = _REQUEST
text: str
@@ -0,0 +1,3 @@
from deepeval.models.tts.elevenlabs.elevenlabs import ElevenLabsTTSModel
__all__ = ["ElevenLabsTTSModel"]
@@ -0,0 +1,200 @@
from typing import Any, AsyncGenerator, Dict, Optional, Tuple
from pydantic import SecretStr
from deepeval.config.settings import get_settings
from deepeval.constants import ProviderSlug as PS
from deepeval.models.base_model import DeepEvalBaseTTS
from deepeval.models.retry_policy import create_retry_decorator
from deepeval.models.speech import (
DEFAULT_TIMEOUT_SECONDS,
SpeechTransport,
dump_request,
)
from deepeval.models.tts._frames import frame_pcm_stream
from deepeval.models.tts.elevenlabs.schema import (
ElevenLabsTTSParams,
ElevenLabsTTSRequest,
)
from deepeval.models.utils import parse_model_name, require_secret_api_key
from deepeval.test_case import Audio, AudioChunk
retry_elevenlabs = create_retry_decorator(PS.ELEVENLABS)
DEFAULT_BASE_URL = "https://api.elevenlabs.io"
# Flash is the model built for realtime agents (~75 ms). The Turbo family is
# deprecated in favour of it, and the expressive v3 models cost twice as much
# and are slower, which is the wrong trade for a simulated caller.
DEFAULT_TTS_MODEL = "eleven_flash_v2_5"
# Deliberately not one of the legacy "Default" voices (Rachel, Adam, Bella...):
# those are unavailable to accounts created after March 2026 and stop resolving
# entirely on 2027-01-01. This is George, from the Voice Library.
DEFAULT_VOICE = "JBFqnCBsd6RMkjVDRZzb"
DEFAULT_SAMPLE_RATE = 24000
# Rates ElevenLabs will emit as PCM. Anything else has to be requested in a
# container format, which cannot be streamed frame by frame.
_PCM_SAMPLE_RATES = frozenset({8000, 16000, 22050, 24000, 32000, 44100, 48000})
_TTS_PRICE_PER_1M_CHARS = {
"eleven_flash_v2_5": 50.0,
"eleven_flash_v2": 50.0,
"eleven_v3": 100.0,
"eleven_v3_conversational": 100.0,
"eleven_multilingual_v2": 100.0,
"eleven_turbo_v2_5": 50.0,
"eleven_turbo_v2": 50.0,
}
class ElevenLabsTTSModel(DeepEvalBaseTTS):
"""ElevenLabs text-to-speech.
Note this is unrelated to `ElevenLabsConnector`, which bridges to a voice
agent hosted on ElevenLabs. This is the model that speaks the simulated
user's side of the conversation, and it can be pointed at an agent on any
platform.
"""
def __init__(
self,
model: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
voice: Optional[str] = None,
sample_rate: int = DEFAULT_SAMPLE_RATE,
language_code: Optional[str] = None,
voice_settings: Optional[Dict[str, Any]] = None,
cost_per_1m_chars: Optional[float] = None,
generation_kwargs: Optional[Dict] = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
):
settings = get_settings()
self.name = parse_model_name(model or DEFAULT_TTS_MODEL)
self.voice = voice or DEFAULT_VOICE
self.language_code = language_code
self.voice_settings = voice_settings
self.generation_kwargs = generation_kwargs or {}
self.cost_per_1m_chars = (
cost_per_1m_chars
if cost_per_1m_chars is not None
else _TTS_PRICE_PER_1M_CHARS.get(self.name)
)
if sample_rate not in _PCM_SAMPLE_RATES:
raise ValueError(
f"ElevenLabs cannot synthesize at {sample_rate} Hz. "
f"Supported rates: {sorted(_PCM_SAMPLE_RATES)}."
)
# Instance attribute, not the class default: connectors read
# `tts.sample_rate` off the instance to configure uplink resampling.
self.sample_rate = sample_rate
self.api_key = (
SecretStr(api_key)
if api_key is not None
else settings.ELEVENLABS_API_KEY
)
self.base_url = (
str(base_url).rstrip("/")
if base_url is not None
else DEFAULT_BASE_URL
)
self.timeout = timeout
self.model = self.load_model()
def load_model(self) -> SpeechTransport:
api_key = require_secret_api_key(
self.api_key,
provider_label="ElevenLabs",
env_var_name="ELEVENLABS_API_KEY",
param_hint="`api_key` to ElevenLabsTTSModel(...)",
)
return SpeechTransport(
provider_label="ElevenLabs",
base_url=self.base_url,
headers={"xi-api-key": api_key},
timeout=self.timeout,
)
def _body(self, text: str, extra: Dict[str, Any]) -> Dict[str, Any]:
return dump_request(
ElevenLabsTTSRequest(
text=text,
model_id=self.name,
language_code=self.language_code,
voice_settings=self.voice_settings,
**{**self.generation_kwargs, **extra},
)
)
def _params(self, *, stream: bool) -> Dict[str, Any]:
prefix = "pcm" if stream else "wav"
return dump_request(
ElevenLabsTTSParams(output_format=f"{prefix}_{self.sample_rate}")
)
def _path(self, voice: Optional[str], *, stream: bool) -> str:
voice_id = voice or self.voice
suffix = "/stream" if stream else ""
return f"/v1/text-to-speech/{voice_id}{suffix}"
def synthesis_cost(self, text: str) -> Optional[float]:
if self.cost_per_1m_chars is None:
return None
return len(text) / 1e6 * self.cost_per_1m_chars
@retry_elevenlabs
def synthesize(
self, text: str, *, voice: Optional[str] = None, **kwargs
) -> Tuple[Audio, Optional[float]]:
data = self.model.request_bytes(
"POST",
self._path(voice, stream=False),
params=self._params(stream=False),
json=self._body(text, kwargs),
)
return self._to_audio(data), self.synthesis_cost(text)
@retry_elevenlabs
async def a_synthesize(
self, text: str, *, voice: Optional[str] = None, **kwargs
) -> Tuple[Audio, Optional[float]]:
data = await self.model.a_request_bytes(
"POST",
self._path(voice, stream=False),
params=self._params(stream=False),
json=self._body(text, kwargs),
)
return self._to_audio(data), self.synthesis_cost(text)
def _to_audio(self, data: bytes) -> Audio:
return Audio.from_bytes(
data,
"audio/wav",
sampleRate=self.sample_rate,
encoding="wav",
)
async def a_synthesize_stream(
self, text: str, *args, voice: Optional[str] = None, **kwargs
) -> AsyncGenerator[AudioChunk, None]:
source = self.model.a_stream_bytes(
"POST",
self._path(voice, stream=True),
params=self._params(stream=True),
json=self._body(text, kwargs),
)
async for chunk in frame_pcm_stream(
source, sample_rate=self.sample_rate
):
yield chunk
def supports_streaming(self) -> bool:
return True
def get_model_name(self) -> str:
return self.name
+38
View File
@@ -0,0 +1,38 @@
"""ElevenLabs text-to-speech request shapes.
`protected_namespaces=()` is cleared because ElevenLabs names its field
`model_id` and pydantic reserves the `model_` prefix for its own methods, so
without it every import warns about a conflict that does not matter here.
"""
from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict
# Requests allow extras so `generation_kwargs` can set a provider parameter
# deepeval has no field for.
_REQUEST = ConfigDict(extra="allow", protected_namespaces=())
class ElevenLabsTTSParams(BaseModel):
"""Query string for `POST /v1/text-to-speech/{voice_id}`.
`output_format` is `wav_{rate}` for a batch call and `pcm_{rate}` for a
streamed one: a WAV header declares a length the first frame of a stream
does not know yet.
"""
model_config = _REQUEST
output_format: str
class ElevenLabsTTSRequest(BaseModel):
"""Body for `POST /v1/text-to-speech/{voice_id}`."""
model_config = _REQUEST
text: str
model_id: str
language_code: Optional[str] = None
voice_settings: Optional[Dict[str, Any]] = None
+3
View File
@@ -0,0 +1,3 @@
from deepeval.models.tts.openai.openai import OpenAITTSModel
__all__ = ["OpenAITTSModel"]
@@ -4,6 +4,7 @@ from openai import OpenAI, AsyncOpenAI
from deepeval.test_case import Audio, AudioChunk
from deepeval.models.base_model import DeepEvalBaseTTS
from deepeval.models.tts._frames import frame_pcm_stream, frame_size_bytes
from deepeval.models.utils import parse_model_name, require_secret_api_key
from deepeval.models.retry_policy import create_retry_decorator
from deepeval.constants import ProviderSlug as PS
@@ -27,8 +28,7 @@ _FORMAT_MIME = {
# format above: their headers declare a length the first chunk does not have
# yet. Streaming is therefore always raw 16-bit mono PCM at 24 kHz.
_STREAM_FORMAT = "pcm"
_STREAM_FRAME_SECONDS = 0.1
_STREAM_FRAME_BYTES = int(_OPENAI_TTS_SAMPLE_RATE * 2 * _STREAM_FRAME_SECONDS)
_STREAM_FRAME_BYTES = frame_size_bytes(_OPENAI_TTS_SAMPLE_RATE)
_TTS_PRICE_PER_1M_CHARS = {
"tts-1": 15.0,
"tts-1-hd": 30.0,
@@ -138,16 +138,6 @@ class OpenAITTSModel(DeepEvalBaseTTS):
)
return self._to_audio(response.content, fmt), self.synthesis_cost(text)
def _to_chunk(self, data: bytes, *, final: bool) -> AudioChunk:
return AudioChunk.from_bytes(
data,
_FORMAT_MIME[_STREAM_FORMAT],
sampleRate=_OPENAI_TTS_SAMPLE_RATE,
encoding=_STREAM_FORMAT,
duration=len(data) / 2 / _OPENAI_TTS_SAMPLE_RATE,
final=final,
)
async def a_synthesize_stream(
self,
text: str,
@@ -161,34 +151,25 @@ class OpenAITTSModel(DeepEvalBaseTTS):
entire synthesis time in front of the first word. Streaming lets a
caller start speaking after the first frame, so what is left to
synthesize is spoken over.
The last frame is held back so it can be flagged `final`, which is how
a consumer knows the utterance is complete without also tracking the
stream itself.
"""
buffer = bytearray()
held: Optional[bytes] = None
async with self._async().audio.speech.with_streaming_response.create(
model=self.name,
voice=voice or self.voice,
input=text,
response_format=_STREAM_FORMAT,
**{**self.generation_kwargs, **kwargs},
) as response:
async for data in response.iter_bytes():
buffer.extend(data)
while len(buffer) >= _STREAM_FRAME_BYTES:
frame = bytes(buffer[:_STREAM_FRAME_BYTES])
del buffer[:_STREAM_FRAME_BYTES]
if held is not None:
yield self._to_chunk(held, final=False)
held = frame
if buffer:
if held is not None:
yield self._to_chunk(held, final=False)
held = bytes(buffer)
if held is not None:
yield self._to_chunk(held, final=True)
async def _reads():
async with (
self._async().audio.speech.with_streaming_response.create(
model=self.name,
voice=voice or self.voice,
input=text,
response_format=_STREAM_FORMAT,
**{**self.generation_kwargs, **kwargs},
)
) as response:
async for data in response.iter_bytes():
yield data
async for chunk in frame_pcm_stream(
_reads(), sample_rate=_OPENAI_TTS_SAMPLE_RATE
):
yield chunk
def supports_streaming(self) -> bool:
return True
-3
View File
@@ -1,3 +0,0 @@
from deepeval.models.tts_models.openai_tts import OpenAITTSModel
__all__ = ["OpenAITTSModel"]
+2 -5
View File
@@ -196,14 +196,11 @@ class ConversationSimulator:
# A single connector holds a single live call; concurrent
# conversations would interleave audio on the same session.
max_concurrent = 1
from deepeval.models.tts_models import OpenAITTSModel
from deepeval.models.stt_models import OpenAISTTModel
self._voice = _VoiceRun(
config=voice_config,
connector=voice_config.connector,
tts_model=voice_config.tts_model or OpenAITTSModel(),
stt_model=voice_config.stt_model or OpenAISTTModel(),
tts_model=voice_config.tts_model,
stt_model=voice_config.stt_model,
)
model_callback = self._voice_model_callback
if voice_config.interruption_settings is not None:
+4
View File
@@ -12,6 +12,8 @@ from deepeval.voice.connectors import (
BaseWebSocketConnector,
ElevenLabsConnector,
WebSocketConnector,
PipecatConnector,
VapiConnector,
)
__all__ = [
@@ -31,6 +33,8 @@ __all__ = [
"BaseWebSocketConnector",
"ElevenLabsConnector",
"WebSocketConnector",
"PipecatConnector",
"VapiConnector",
]
# Resolved lazily: these live with `Persona` in `deepeval.dataset`, which
+41 -3
View File
@@ -12,6 +12,36 @@ if TYPE_CHECKING:
from deepeval.dataset.golden import InterruptionBehavior
def resolve_tts_model(
model: Optional[Union[str, DeepEvalBaseTTS]],
) -> DeepEvalBaseTTS:
"""The TTS model to speak with.
A model name is the name for the provider selected by `USE_*_TTS`, and
`None` leaves both the provider and the name to the environment. Model
objects are left alone, including ones that only duck-type the base class.
"""
# Imported here because `deepeval.models.tts` reaches back into
# `deepeval.voice`, so importing it at module scope would cycle.
from deepeval.models.speech_selection import initialize_tts_model
return initialize_tts_model(model)
def resolve_stt_model(
model: Optional[Union[str, DeepEvalBaseSTT]],
) -> DeepEvalBaseSTT:
"""The STT model to transcribe with.
A model name is the name for the provider selected by `USE_*_STT`, and
`None` leaves both the provider and the name to the environment. Model
objects are left alone, including ones that only duck-type the base class.
"""
from deepeval.models.speech_selection import initialize_stt_model
return initialize_stt_model(model)
@dataclass
class VoiceConfig:
"""Voice-mode settings for `ConversationSimulator`.
@@ -19,7 +49,13 @@ class VoiceConfig:
Passing a `VoiceConfig` puts the simulator in voice mode: simulated user
turns are spoken (TTS), sent to the agent over `connector`, and the
agent's spoken replies are transcribed (STT). `tts_model` / `stt_model`
default to the OpenAI implementations when left as None.
come from the environment when left as None — the `USE_*_TTS` /
`USE_*_STT` flag picks the provider and `DEEPEVAL_TTS_MODEL` /
`DEEPEVAL_STT_MODEL` picks the model, both defaulting to OpenAI. A bare
model name is the name for that same selected provider, so
`stt_model="whisper-1"` is shorthand for `OpenAISTTModel(model="whisper-1")`
unless another `USE_*_STT` flag is set. Pass the model object itself to
reach a specific provider or set anything beyond the name.
`connector` is the connector the session runs over — `ElevenLabsConnector`,
`LiveKitConnector`, `WebSocketConnector`, `CallbackVoiceConnector`, or your
@@ -34,8 +70,8 @@ class VoiceConfig:
"""
connector: Union[BaseVoiceConnector, Callable[[], BaseVoiceConnector]]
tts_model: Optional[DeepEvalBaseTTS] = None
stt_model: Optional[DeepEvalBaseSTT] = None
tts_model: Optional[Union[str, DeepEvalBaseTTS]] = None
stt_model: Optional[Union[str, DeepEvalBaseSTT]] = None
# Directory to write per-turn and combined audio files into. Left alone,
# this resolves through `DEEPEVAL_VOICE_FOLDER` to a default folder; pass
# `None` to skip writing audio to disk. Resolved once, here, so that by the
@@ -53,6 +89,8 @@ class VoiceConfig:
self.connector, BaseVoiceConnector
):
self.connector = validate_connector(self.connector)
self.tts_model = resolve_tts_model(self.tts_model)
self.stt_model = resolve_stt_model(self.stt_model)
self.output_dir = resolve_output_dir(self.output_dir)
def make_connector(self) -> BaseVoiceConnector:
+4
View File
@@ -8,6 +8,8 @@ from deepeval.voice.connectors.transports import (
from deepeval.voice.connectors.providers import (
ElevenLabsConnector,
LiveKitConnector,
PipecatConnector,
VapiConnector,
)
__all__ = [
@@ -19,4 +21,6 @@ __all__ = [
"BaseWebSocketConnector",
"ElevenLabsConnector",
"WebSocketConnector",
"PipecatConnector",
"VapiConnector",
]
@@ -1,7 +1,11 @@
from deepeval.voice.connectors.providers.elevenlabs import ElevenLabsConnector
from deepeval.voice.connectors.providers.livekit import LiveKitConnector
from deepeval.voice.connectors.providers.pipecat import PipecatConnector
from deepeval.voice.connectors.providers.vapi import VapiConnector
__all__ = [
"ElevenLabsConnector",
"LiveKitConnector",
"PipecatConnector",
"VapiConnector",
]
@@ -0,0 +1,92 @@
# Pipecat
Contributor reference and TODO list for the Pipecat integration. This file
tracks which of Pipecat's entry points deepeval can reach; it deliberately does
not restate their parameters, because those are defined by Pipecat and go stale
here. Each row links to the page that owns the answer.
## One connector, no dependency on `pipecat-ai`
`PipecatConnector` (`connector.py`) connects to a pipeline running behind one of
Pipecat's [WebSocket transports](https://docs.pipecat.ai/server/services/transport/websocket-server)
the way any other client would: it is given a URL, and the pipeline is the
user's to host.
`protobuf.py` is our own encoder for the five messages in Pipecat's
[`frames.proto`](https://github.com/pipecat-ai/pipecat/blob/main/src/pipecat/serializers/protobuf.py),
which is what `ProtobufFrameSerializer` — the default on those transports —
puts on the wire. Encoding them directly is about 150 lines and keeps
`pipecat-ai`, a server framework with a large dependency tree, out of the
requirements of testing something built with it.
The schema is `oneof { text, audio, transcription, message, interruption }`.
Only `audio` and `message` are written; `message` carries a JSON string, which
is how RTVI rides the same socket.
## What carries the turn-taking
Pipecat has no end-of-turn frame in the protobuf schema, so the turn-taking
comes from [RTVI](https://docs.pipecat.ai/server/frameworks/rtvi/introduction)
messages, which a pipeline only sends if it runs an `RTVIProcessor` and its
observer:
| Message | Used for |
| --- | --- |
| `bot-stopped-speaking` | Ending the agent's turn outright, instead of inferring it from silence |
| `bot-transcription` | The agent's own words, which skip STT for that turn |
| `bot-interrupted` | Recording that a barge landed |
| `client-ready` (sent by us) | Pipelines that hold their greeting, or their audio input, until the client is up |
`signals_turn_complete` is therefore not a constant: it starts False and flips
once a `bot-stopped-speaking` has actually been seen. Claiming it up front
would leave a non-RTVI pipeline's turns waiting for a signal that is never
coming; claiming it never would truncate replies that pause mid-sentence.
RTVI emits one `bot-transcription` per sentence, so `_on_transcription`
accumulates them and `bot-stopped-speaking` clears the buffer.
## Entry point coverage
| Entry point | Status | Reference |
| --- | --- | --- |
| `WebsocketServerTransport` | Covered | [WebSocket server](https://docs.pipecat.ai/server/services/transport/websocket-server) |
| `FastAPIWebsocketTransport` | Covered | [FastAPI WebSocket](https://docs.pipecat.ai/server/services/transport/fastapi-websocket) |
| `ProtobufFrameSerializer` | Covered | [Serializers](https://docs.pipecat.ai/server/utilities/serializers/introduction) |
| `add_wav_header=True` on the transport | Covered | [WebSocket server](https://docs.pipecat.ai/server/services/transport/websocket-server) |
| RTVI turn signals and bot transcripts | Covered | [RTVI](https://docs.pipecat.ai/server/frameworks/rtvi/introduction) |
| `client-ready` handshake | Covered | [RTVI](https://docs.pipecat.ai/server/frameworks/rtvi/introduction) |
| `bot-output` (the successor to `bot-transcription`) | Not covered | [RTVI messages](https://docs.pipecat.ai/client/js/api-reference/callbacks) |
| Client-side function calls over `client-message` | Not covered | [RTVI](https://docs.pipecat.ai/server/frameworks/rtvi/introduction) |
| `TwilioFrameSerializer`, `ExotelFrameSerializer`, and the other telephony dialects | Not covered | [Serializers](https://docs.pipecat.ai/server/utilities/serializers/introduction) |
| Daily, LiveKit, and the other WebRTC transports | Not covered here | [Transports](https://docs.pipecat.ai/server/services/transport/daily) |
| SmallWebRTC transport | Not covered | [SmallWebRTC](https://docs.pipecat.ai/server/services/transport/small-webrtc) |
A pipeline on a WebRTC transport is reached through the connector for that
service — a Pipecat pipeline on LiveKit is a LiveKit room, and
`LiveKitConnector` joins it. The telephony serializers are the agent taking a
real phone call; reaching such an agent means running the same pipeline behind
a WebSocket transport instead.
## TODO
**Smoke test against a live pipeline.** The frame encoding is checked against
Pipecat's own generated `frames_pb2` in both directions, but three things could
only be confirmed against a running pipeline: that a pipeline gated on
`on_client_ready` does open on our `client-ready`, that `bot-stopped-speaking`
arrives after the last audio frame rather than before it, and how a pipeline
whose `audio_in_sample_rate` differs from `agent_sample_rate` degrades (Pipecat
does not resample what it is handed).
**`bot-output`.** RTVI is deprecating `bot-transcription` in favour of
`bot-output`, which carries progress updates for the same text and would double
up the transcript if both were read. When `bot-transcription` goes, the
accumulation has to move to `bot-output` and de-duplicate by segment id.
**What the agent heard.** `transcription` frames carry Pipecat's STT of
deepeval's synthesized audio, and are dropped. As with the other connectors, it
is the signal that separates "the agent answered badly" from "the agent misheard
the question", and there is nowhere on `ConnectorTurn` to put it today.
**Ending the pipeline.** `disconnect()` closes the socket and nothing more.
RTVI's `disconnect-bot` would end the pipeline task outright, which is more
than a client should decide for a server it does not own.
@@ -0,0 +1,5 @@
from deepeval.voice.connectors.providers.pipecat.connector import (
PipecatConnector,
)
__all__ = ["PipecatConnector"]
@@ -0,0 +1,205 @@
import json
import logging
import uuid
from typing import List, Optional, Union
from deepeval.voice.connectors import audio_utils
from deepeval.voice.connectors.providers.pipecat import protobuf
from deepeval.voice.connectors.transports.websocket import (
BaseWebSocketConnector,
InboundEvent,
)
logger = logging.getLogger(__name__)
# Pipecat's default input rate; its output default is 24000, which is also the
# rate deepeval records at, so only the uplink needs stating.
DEFAULT_AGENT_SAMPLE_RATE = 16000
RTVI_LABEL = "rtvi-ai"
RTVI_PROTOCOL_VERSION = "2.1.0"
class PipecatConnector(BaseWebSocketConnector):
"""Connect to a Pipecat pipeline over its WebSocket transport.
The pipeline is yours and self-hosted, so the connector takes a URL rather
than credentials, and speaks the protobuf frames Pipecat's WebSocket
transports serialize by default.
"""
def __init__(
self,
url: str,
*,
headers: Optional[dict] = None,
agent_sample_rate: int = DEFAULT_AGENT_SAMPLE_RATE,
client_ready: bool = True,
**base_kwargs,
):
super().__init__(**base_kwargs)
self.url = url
self.headers = headers
# Pipecat does not resample what it is handed: the frames have to
# arrive at the rate the pipeline's VAD and STT were started with.
self.agent_sample_rate = agent_sample_rate
self._send_rate = agent_sample_rate
self.client_ready = client_ready
self._transcript_parts: List[str] = []
self._seen_turn_signal = False
self._warned_non_protobuf = False
@property
def signals_turn_complete(self) -> bool:
"""True once the pipeline has been seen to announce an end of turn.
Whether it does is a property of the pipeline rather than of Pipecat:
the announcement is an RTVI `bot-stopped-speaking` message, and only a
pipeline running an `RTVIProcessor` sends one. Reading it off the wire
rather than taking it on trust means a pipeline without RTVI falls back
to ending turns on silence instead of waiting for a signal that is
never coming.
"""
return self._seen_turn_signal
async def connect(self) -> None:
self._transcript_parts = []
self._seen_turn_signal = False
await super().connect()
async def _open_session(self) -> str:
return self.url
def _connect_headers(self) -> Optional[dict]:
return self.headers
def _ready_on_connect(self) -> bool:
# A Pipecat server accepts audio as soon as the socket is up; nothing
# is sent back to acknowledge the client.
return True
def _initial_messages(self) -> List[Union[str, bytes]]:
"""Announce the client, which some pipelines wait for before greeting.
Pipelines commonly open on `on_client_ready` — the greeting, and
sometimes the audio input itself, is gated on it. A pipeline without an
`RTVIProcessor` has nothing listening for the message and ignores it.
"""
if not self.client_ready:
return []
return [
protobuf.encode_message_frame(
json.dumps(
{
"label": RTVI_LABEL,
"type": "client-ready",
"id": uuid.uuid4().hex,
"data": {
"version": RTVI_PROTOCOL_VERSION,
"about": {"library": "deepeval"},
},
}
)
)
]
def _encode_outbound(self, pcm: bytes) -> Union[str, bytes]:
return protobuf.encode_audio_frame(pcm, self._send_rate, 1)
def _decode_inbound(self, raw: Union[str, bytes]) -> Optional[InboundEvent]:
if isinstance(raw, str):
self._warn_non_protobuf("a text message")
return None
try:
frame = protobuf.decode_frame(bytes(raw))
except ValueError:
self._warn_non_protobuf("bytes that are not protobuf frames")
return None
if frame is None:
return None
if frame.kind == "audio":
return self._on_audio(frame)
if frame.kind == "message":
return self._on_transport_message(frame.data)
if frame.kind == "interruption":
self._interrupted = True
# A `transcription` frame is Pipecat's STT of deepeval's own audio, and
# a `text` frame is unattributed pipeline text; neither is the agent's
# reply.
return None
def _warn_non_protobuf(self, what: str) -> None:
if self._warned_non_protobuf:
return
self._warned_non_protobuf = True
logger.warning(
"PipecatConnector received %s. It speaks the protobuf frames of "
"`ProtobufFrameSerializer`, which is what Pipecat's WebSocket "
"transports use by default — a pipeline serializing something "
"else needs a `WebSocketConnector` describing that dialect "
"instead.",
what,
)
def _on_audio(self, frame: protobuf.PipecatFrame) -> Optional[InboundEvent]:
pcm = frame.audio or b""
if not pcm:
return None
sample_rate, channels = frame.sample_rate, frame.num_channels or 1
if pcm[:4] == b"RIFF":
# `add_wav_header=True` on the transport wraps every frame.
pcm, sample_rate, channels = audio_utils.wav_bytes_to_pcm16(pcm)
if sample_rate and sample_rate != self._recv_rate:
logger.debug(
"Pipecat is sending %dHz audio; reading the downlink at that "
"rate instead of %dHz.",
sample_rate,
self._recv_rate,
)
self._recv_rate = sample_rate
return InboundEvent(audio=audio_utils.downmix_to_mono(pcm, channels))
def _on_transport_message(
self, data: Optional[str]
) -> Optional[InboundEvent]:
try:
message = json.loads(data or "")
except ValueError:
return None
if not isinstance(message, dict) or message.get("label") != RTVI_LABEL:
return None
message_type = message.get("type")
if message_type == "bot-stopped-speaking":
self._seen_turn_signal = True
self._transcript_parts = []
return InboundEvent(turn_complete=True)
if message_type == "bot-interrupted":
self._interrupted = True
return None
if message_type == "bot-transcription":
return self._on_transcription(message)
return None
def _on_transcription(self, message: dict) -> Optional[InboundEvent]:
"""Collect the agent's transcript for the turn in progress.
RTVI emits one `bot-transcription` per sentence, so the last one alone
would record the end of a multi-sentence reply and nothing before it.
"""
data = message.get("data")
text = (data or {}).get("text") if isinstance(data, dict) else None
text = (text or "").strip()
if not text:
return None
self._transcript_parts.append(text)
return InboundEvent(transcript=" ".join(self._transcript_parts))
def drain_downlink(self) -> None:
self._transcript_parts = []
super().drain_downlink()
@@ -0,0 +1,178 @@
"""The part of Pipecat's `frames.proto` that a caller has to speak.
Pipecat's WebSocket transports serialize with `ProtobufFrameSerializer`, whose
schema is five messages wide and carries only what a client sends or reads:
audio, text, a transcription, a JSON transport message, and an interruption.
Encoding those directly keeps `pipecat-ai` — a server-side framework with a
large dependency tree — out of the requirements of running a simulation
against it.
https://github.com/pipecat-ai/pipecat/blob/main/src/pipecat/serializers/protobuf.py
"""
from dataclasses import dataclass
from typing import Iterator, Optional, Tuple
_WIRE_VARINT = 0
_WIRE_64BIT = 1
_WIRE_LEN = 2
_WIRE_32BIT = 5
# Fields of the `Frame` oneof.
_FRAME_TEXT = 1
_FRAME_AUDIO = 2
_FRAME_TRANSCRIPTION = 3
_FRAME_MESSAGE = 4
_FRAME_INTERRUPTION = 5
# Fields within each of those messages.
_TEXT_TEXT = 3
_AUDIO_AUDIO = 3
_AUDIO_SAMPLE_RATE = 4
_AUDIO_NUM_CHANNELS = 5
_TRANSCRIPTION_TEXT = 3
_MESSAGE_DATA = 1
@dataclass
class PipecatFrame:
"""One decoded frame, named by which arm of the oneof it arrived on."""
kind: str # text | audio | transcription | message | interruption
audio: Optional[bytes] = None
sample_rate: Optional[int] = None
num_channels: Optional[int] = None
text: Optional[str] = None
data: Optional[str] = None # JSON, for `message`
def _varint(value: int) -> bytes:
out = bytearray()
while True:
byte = value & 0x7F
value >>= 7
out.append(byte | (0x80 if value else 0))
if not value:
return bytes(out)
def _tag(field: int, wire: int) -> bytes:
return _varint(field << 3 | wire)
def _len_field(field: int, payload: bytes) -> bytes:
return _tag(field, _WIRE_LEN) + _varint(len(payload)) + payload
def _varint_field(field: int, value: int) -> bytes:
return _tag(field, _WIRE_VARINT) + _varint(value)
def encode_audio_frame(
pcm: bytes, sample_rate: int, num_channels: int = 1
) -> bytes:
"""A `Frame` carrying PCM16, which Pipecat reads as an `InputAudioRawFrame`."""
audio = (
_len_field(_AUDIO_AUDIO, pcm)
+ _varint_field(_AUDIO_SAMPLE_RATE, sample_rate)
+ _varint_field(_AUDIO_NUM_CHANNELS, num_channels)
)
return _len_field(_FRAME_AUDIO, audio)
def encode_message_frame(data: str) -> bytes:
"""A `Frame` carrying a JSON transport message, such as RTVI's."""
return _len_field(
_FRAME_MESSAGE, _len_field(_MESSAGE_DATA, data.encode("utf-8"))
)
def _read_varint(buf: bytes, i: int) -> Tuple[int, int]:
value = shift = 0
while True:
if i >= len(buf):
raise ValueError("truncated varint")
byte = buf[i]
i += 1
value |= (byte & 0x7F) << shift
if not byte & 0x80:
return value, i
shift += 7
def _iter_fields(buf: bytes) -> Iterator[Tuple[int, int, object]]:
i = 0
while i < len(buf):
key, i = _read_varint(buf, i)
field, wire = key >> 3, key & 0x07
if wire == _WIRE_VARINT:
value, i = _read_varint(buf, i)
elif wire == _WIRE_LEN:
length, i = _read_varint(buf, i)
value, i = buf[i : i + length], i + length
if len(value) != length:
raise ValueError("truncated length-delimited field")
elif wire in (_WIRE_64BIT, _WIRE_32BIT):
width = 8 if wire == _WIRE_64BIT else 4
value, i = buf[i : i + width], i + width
else:
raise ValueError(f"unsupported protobuf wire type {wire}")
yield field, wire, value
def _string(value: bytes) -> str:
return value.decode("utf-8", "replace")
def _audio_frame(payload: bytes) -> PipecatFrame:
frame = PipecatFrame(kind="audio", audio=b"")
for field, _, value in _iter_fields(payload):
if field == _AUDIO_AUDIO:
frame.audio = bytes(value)
elif field == _AUDIO_SAMPLE_RATE:
frame.sample_rate = value
elif field == _AUDIO_NUM_CHANNELS:
frame.num_channels = value
return frame
def _one_string_frame(
payload: bytes, kind: str, field_number: int
) -> PipecatFrame:
frame = PipecatFrame(kind=kind, text="")
for field, _, value in _iter_fields(payload):
if field == field_number:
frame.text = _string(value)
return frame
def _message_frame(payload: bytes) -> PipecatFrame:
frame = PipecatFrame(kind="message", data="")
for field, _, value in _iter_fields(payload):
if field == _MESSAGE_DATA:
frame.data = _string(value)
return frame
def decode_frame(raw: bytes) -> Optional[PipecatFrame]:
"""Read a serialized `Frame`, or None if it carries nothing we know.
Raises `ValueError` on bytes that are not protobuf at all, which is how a
pipeline serializing something else announces itself.
"""
for field, wire, value in _iter_fields(raw):
if wire != _WIRE_LEN:
continue
if field == _FRAME_AUDIO:
return _audio_frame(value)
if field == _FRAME_MESSAGE:
return _message_frame(value)
if field == _FRAME_TRANSCRIPTION:
return _one_string_frame(
value, "transcription", _TRANSCRIPTION_TEXT
)
if field == _FRAME_TEXT:
return _one_string_frame(value, "text", _TEXT_TEXT)
if field == _FRAME_INTERRUPTION:
return PipecatFrame(kind="interruption")
return None
@@ -0,0 +1,85 @@
# Vapi
Contributor reference and TODO list for the Vapi integration. This file tracks
which of Vapi's entry points deepeval can reach; it deliberately does not
restate their parameters, because those are defined by Vapi and go stale here.
Each row links to the page that owns the answer.
## One connector
`VapiConnector` (`connector.py`) is our own client for Vapi's
[WebSocket transport](https://docs.vapi.ai/calls/websocket-transport). The
caller passes an `assistant_id` plus an API key, deepeval creates the call and
owns the socket, and there is no dependency on any Vapi SDK.
The call is minted over REST (`POST /call` with
`transport.provider = "vapi.websocket"`), which returns
`transport.websocketCallUrl`. Phone parameters are rejected on that transport,
so this path never touches telephony — the simulator is the only caller.
## Client messages deepeval depends on
Two of Vapi's [client messages](https://docs.vapi.ai/api-reference/webhooks/client-message)
carry the turn-taking:
| Message | Shape deepeval reads | Used for |
| --- | --- | --- |
| `speech-update` | `status` (`started`/`stopped`), `role` | Ending the agent's turn outright, instead of inferring it from silence |
| `transcript` | `role`, `transcriptType` (`partial`/`final`), `transcript` | The agent's own words, which skip STT for that turn |
Both are in Vapi's default `clientMessages` set, so nothing is sent unless the
caller narrows the set themselves — `_assistant_overrides` then adds the two
back, because losing them costs end-of-turn detection and free transcripts
silently rather than loudly.
Vapi emits one `final` transcript per utterance rather than one per turn, so
`_on_transcript` accumulates them and `speech-update: stopped` clears the
buffer. Taking only the last one would record half of a two-sentence reply.
## Entry point coverage
| Entry point | Status | Reference |
| --- | --- | --- |
| Assistant over WebSocket transport | Covered | [WebSocket transport](https://docs.vapi.ai/calls/websocket-transport) |
| Per-call `assistantOverrides` (incl. `variableValues`) | Covered | [Assistant overrides](https://docs.vapi.ai/assistants/dynamic-variables) |
| Explicit end of turn (`speech-update`) | Covered | [Client messages](https://docs.vapi.ai/api-reference/webhooks/client-message) |
| Agent transcript (`transcript`, final) | Covered | [Client messages](https://docs.vapi.ai/api-reference/webhooks/client-message) |
| `user-interrupted` | Covered | [Client messages](https://docs.vapi.ai/api-reference/webhooks/client-message) |
| Ending the call (`end-call`) | Covered | [WebSocket transport](https://docs.vapi.ai/calls/websocket-transport) |
| mu-law audio format | Not covered | [WebSocket transport](https://docs.vapi.ai/calls/websocket-transport) |
| Inline assistant config instead of `assistantId` | Not covered | [Create call](https://docs.vapi.ai/api-reference/calls/create) |
| Squads | Not covered | [Squads](https://docs.vapi.ai/squads) |
| `tool-calls` / client-side tools | Not covered | [Client messages](https://docs.vapi.ai/api-reference/webhooks/client-message) |
| `conversation-update`, `model-output` | Not covered | [Client messages](https://docs.vapi.ai/api-reference/webhooks/client-message) |
| Call Listen (`monitor.listenUrl`) | Not covered, by choice | [WebSocket transport](https://docs.vapi.ai/calls/websocket-transport) |
| Phone numbers, campaigns, SIP | Out of scope | [Calls](https://docs.vapi.ai/calls) |
Call Listen is listen-only and supplements a call that already exists, so it
cannot carry a simulation. "Out of scope" means the entry point is the agent
placing or receiving a real phone call; reaching such an agent means pointing
deepeval at the same assistant over the WebSocket transport instead.
## TODO
**Smoke test against a live assistant.** Everything here is written against
Vapi's documented schemas. Three things could only be confirmed on a real call:
whether the socket wraps client messages in a `message` envelope the way
webhooks do (`_decode_inbound` accepts both shapes, so either works), whether a
non-default `sampleRate` is honoured or quietly converted
(`_apply_audio_format` adopts whatever comes back), and how the socket behaves
before the first audio frame (`_ready_on_connect` assumes no handshake).
`vapi_capture.py` at the repo root records one conversation for this.
**What the agent heard.** `transcript` messages with `role: "user"` carry
Vapi's transcription of deepeval's synthesized audio, and are dropped. As with
ElevenLabs, it is the signal that separates "the agent answered badly" from
"the agent misheard the question", and there is nowhere on `ConnectorTurn` to
put it today.
**Interruption detail.** `user-interrupted` sets `_interrupted` for the turn,
but Vapi's own barge-in handling and deepeval's floor control both act on the
same moment, and the interaction has not been exercised.
**Call id.** `call_id` is kept from the create-call response but never
surfaced, so a simulated conversation cannot be cross-referenced with the same
call in Vapi's dashboard.
@@ -0,0 +1,3 @@
from deepeval.voice.connectors.providers.vapi.connector import VapiConnector
__all__ = ["VapiConnector"]
@@ -0,0 +1,213 @@
import os
import json
import logging
from typing import Any, Dict, List, Optional, Union
from deepeval.errors import DeepEvalError
from deepeval.voice.connectors.transports.websocket import (
BaseWebSocketConnector,
InboundEvent,
)
logger = logging.getLogger(__name__)
DEFAULT_BASE_URL = "https://api.vapi.ai"
# The client messages deepeval reads off the socket. Both are in Vapi's default
# set, so they are only ever re-stated when a caller narrows the set themselves.
# https://docs.vapi.ai/api-reference/webhooks/client-message
REQUIRED_CLIENT_MESSAGES = ("speech-update", "transcript")
class VapiConnector(BaseWebSocketConnector):
def __init__(
self,
assistant_id: str,
api_key: Optional[str] = None,
assistant_overrides: Optional[dict] = None,
base_url: str = DEFAULT_BASE_URL,
**base_kwargs,
):
super().__init__(**base_kwargs)
self.assistant_id = assistant_id
self.api_key = api_key or os.getenv("VAPI_API_KEY")
self.assistant_overrides = assistant_overrides
self.base_url = base_url.rstrip("/")
self.call_id: Optional[str] = None
self._transcript_parts: List[str] = []
@property
def signals_turn_complete(self) -> bool:
# `speech-update` closes every turn.
return True
def _ready_on_connect(self) -> bool:
# The socket is minted per call and carries no handshake; audio may go
# out as soon as it opens.
return True
def _assistant_overrides(self) -> Optional[dict]:
"""The caller's overrides, plus the client messages deepeval needs.
Narrowing `clientMessages` is how a Vapi assistant turns events off, so
a caller who sets it without `speech-update` would silently remove
end-of-turn detection, and one without `transcript` would pay for STT
on every turn.
"""
overrides = dict(self.assistant_overrides or {})
requested = overrides.get("clientMessages")
if requested is None:
return overrides or None
missing = [
message
for message in REQUIRED_CLIENT_MESSAGES
if message not in requested
]
if missing:
overrides["clientMessages"] = list(requested) + missing
return overrides
def _call_payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"assistantId": self.assistant_id,
"transport": {
"provider": "vapi.websocket",
"audioFormat": {
"format": "pcm_s16le",
"container": "raw",
"sampleRate": self.sample_rate,
},
},
}
overrides = self._assistant_overrides()
if overrides:
payload["assistantOverrides"] = overrides
return payload
async def _open_session(self) -> str:
if not self.api_key:
raise DeepEvalError(
"VapiConnector requires a Vapi API key (pass `api_key=...` or "
"set VAPI_API_KEY). The key mints the call the WebSocket "
"carries."
)
async with self._session.post(
f"{self.base_url}/call",
headers={"authorization": f"Bearer {self.api_key}"},
json=self._call_payload(),
) as resp:
body = await resp.text()
if resp.status >= 300:
raise DeepEvalError(
f"Vapi call creation failed ({resp.status}): {body}"
)
try:
data = json.loads(body)
except ValueError:
raise DeepEvalError(
f"Vapi call creation returned a non-JSON body: {body[:200]}"
)
transport = data.get("transport") or {}
url = transport.get("websocketCallUrl")
if not url:
raise DeepEvalError(
"Vapi call response had no `transport.websocketCallUrl`. Check "
"that the assistant exists and the key may create calls."
)
self.call_id = data.get("id")
self._apply_audio_format(transport.get("audioFormat"))
return url
def _apply_audio_format(self, audio_format: Optional[dict]) -> None:
"""Adopt the format Vapi settled on, which need not be the one asked for."""
if not isinstance(audio_format, dict):
return
encoding = audio_format.get("format")
if encoding and encoding != "pcm_s16le":
logger.warning(
"Vapi negotiated audio format %r; deepeval sends and reads "
"PCM16, so the audio will be garbled. Request pcm_s16le.",
encoding,
)
rate = audio_format.get("sampleRate")
if isinstance(rate, int) and rate > 0:
self._send_rate = rate
self._recv_rate = rate
def _encode_outbound(self, pcm: bytes) -> Union[str, bytes]:
return pcm
def _decode_inbound(self, raw: Union[str, bytes]) -> Optional[InboundEvent]:
if isinstance(raw, (bytes, bytearray)):
return InboundEvent(audio=bytes(raw))
try:
payload = json.loads(raw)
except (ValueError, TypeError):
return None
if not isinstance(payload, dict):
return None
# Vapi's webhook deliveries wrap the event in `message`; the socket
# sends the same events, so accept either shape.
message = payload.get("message")
if not isinstance(message, dict):
message = payload
message_type = message.get("type")
if message_type == "transcript":
return self._on_transcript(message)
if message_type == "speech-update":
if (
message.get("role") == "assistant"
and message.get("status") == "stopped"
):
self._transcript_parts = []
return InboundEvent(turn_complete=True)
return None
if message_type == "user-interrupted":
self._interrupted = True
return None
return None
def _on_transcript(self, message: dict) -> Optional[InboundEvent]:
"""Collect the agent's final transcripts for the turn in progress.
Vapi emits one per utterance rather than one per turn, so a two
sentence reply arrives as two finals and the last one on its own would
record half of what the agent said.
"""
if message.get("role") != "assistant":
return None
if message.get("transcriptType") != "final":
return None
text = (message.get("transcript") or "").strip()
if not text:
return None
self._transcript_parts.append(text)
return InboundEvent(transcript=" ".join(self._transcript_parts))
def drain_downlink(self) -> None:
self._transcript_parts = []
super().drain_downlink()
async def disconnect(self) -> None:
"""End the call before dropping the socket.
Closing the socket alone leaves the call live on Vapi's side until it
times out, still billing and still holding a concurrency slot.
"""
if self._ws is not None and not self._ws.closed:
try:
await self._send(json.dumps({"type": "end-call"}))
except Exception:
logger.debug(
"Failed to send Vapi end-call; closing the socket anyway.",
exc_info=True,
)
await super().disconnect()
+53 -4
View File
@@ -16,6 +16,20 @@ class LiveKitIdentity(Enum):
ROOM = "Room"
class VapiIdentity(Enum):
CLIENT = "Vapi"
ASYNC_CLIENT = "AsyncVapi"
class PipecatIdentity(Enum):
PIPELINE = "Pipeline"
TASK = "PipelineTask"
WORKER = "PipelineWorker"
SERVER_TRANSPORT = "WebsocketServerTransport"
SINGLE_CLIENT_TRANSPORT = "SingleClientWebsocketServerTransport"
FASTAPI_TRANSPORT = "FastAPIWebsocketTransport"
# What to tell someone who passed the vendor object instead of a connector.
# Every voice session runs through a connector, so these are redirections
# rather than alternative entry points.
@@ -36,8 +50,33 @@ _REDIRECTS = {
"`LiveKitConnector(room=your_room)` instead, which joins the room you "
"already have."
),
VapiIdentity.CLIENT: (
"VoiceConfig received a Vapi client. Pass "
"`VapiConnector(assistant_id=...)` instead — it creates the call "
"itself, and takes `assistant_overrides` for anything you would "
"configure per call."
),
VapiIdentity.ASYNC_CLIENT: (
"VoiceConfig received a Vapi client. Pass "
"`VapiConnector(assistant_id=...)` instead."
),
}
# Pipecat's pipeline objects and its WebSocket transports all mean the same
# thing here: the agent is reached over the socket its server is listening on,
# never in-process.
_REDIRECTS.update(
{
identity: (
f"VoiceConfig received a Pipecat `{identity.value}`. Run the "
"pipeline behind its WebSocket transport and pass "
"`PipecatConnector(url=...)` instead, which connects to it the "
"way any other client would."
)
for identity in PipecatIdentity
}
)
def _root_module(cls: type) -> str:
return (getattr(cls, "__module__", "") or "").split(".")[0]
@@ -45,7 +84,9 @@ def _root_module(cls: type) -> str:
def _identify(
obj: Any,
) -> Optional[Union[ElevenLabsIdentity, LiveKitIdentity]]:
) -> Optional[
Union[ElevenLabsIdentity, LiveKitIdentity, PipecatIdentity, VapiIdentity]
]:
"""Name the vendor class `obj` is, without importing the vendor package.
Matching walks the MRO by `(root module, class name)` rather than using
@@ -63,6 +104,14 @@ def _identify(
for identity in LiveKitIdentity:
if name == identity.value:
return identity
if root in ("vapi", "vapi_server_sdk"):
for identity in VapiIdentity:
if name == identity.value:
return identity
if root == "pipecat":
for identity in PipecatIdentity:
if name == identity.value:
return identity
return None
@@ -82,7 +131,7 @@ def validate_connector(connector: Any) -> BaseVoiceConnector:
raise DeepEvalError(
f"VoiceConfig cannot use {type(connector).__name__!r} as a connector. "
"Pass an `ElevenLabsConnector`, `LiveKitConnector`, "
"`WebSocketConnector`, `CallbackVoiceConnector`, or your own "
"`BaseVoiceConnector` subclass."
"Pass an `ElevenLabsConnector`, `LiveKitConnector`, `VapiConnector`, "
"`PipecatConnector`, `WebSocketConnector`, `CallbackVoiceConnector`, "
"or your own `BaseVoiceConnector` subclass."
)
+33 -6
View File
@@ -372,7 +372,7 @@ npx deepeval diagnose
The report shows:
- **Versions**: deepeval and the runtime it's running on.
- **Evaluation model**: the model deepeval will use when a metric<Only id="python"> (or synthesizer, simulator, etc.)</Only> is constructed without an explicit model, plus the reason it won.<Only id="python"> The default embedding model is listed alongside it, with what each is used by.</Only>
- **Evaluation model**: the model deepeval will use when a metric<Only id="python"> (or synthesizer, simulator, etc.)</Only> is constructed without an explicit model, plus the reason it won.<Only id="python"> The default embedding model is listed alongside it, and the selected text-to-speech and speech-to-text models below those, with what each is used by.</Only>
- **Configuration sources**: the precedence order every variable is resolved against, independently (process environment → `.env.local` → `.env.<APP_ENV>` → `.env` → JSON keystore → built-in defaults), which dotenv files were loaded, and where the keystore lives.
- **Configured settings**: every deepeval setting that is explicitly set, with secrets masked (only the last 6 characters shown) and the **winning source** for each — the report uses the same resolvers as the runtime, so what you see is what your evals get.
- **Confident AI**: login status, masked API key, data region, and the API endpoint.<Only id="python"> The OTEL endpoint is shown too, with warnings if either endpoint looks like it belongs to a different region than your API key.</Only>
@@ -680,12 +680,24 @@ All provider commands come in pairs:
</Switch>
This switches the active provider:
This switches the active provider **within its family**:
- It sets `USE_<PROVIDER>_MODEL = True` for the chosen provider, and
- Turns all other `USE_*` flags off so that only one provider is enabled at a time.
- It sets that provider's `USE_*` flag, such as `USE_ANTHROPIC_MODEL`, and
- Turns off the other `USE_*` flags in the same family, so only one provider per family is enabled at a time.
When you **set** a provider, the CLI enables that provider’s `USE_<PROVIDER>_MODEL` flag and disables all other `USE_*` flags. When you **unset** a provider, it disables only that provider’s `USE_*` flag and leaves all others untouched. If you manually set env vars (or edit dotenv files) it’s possible to end up with multiple `USE_*` flags enabled.
<Only id="python">
The four families are the LLM judge, embeddings, text-to-speech and speech-to-text. They are independent, so `deepeval set-openai` leaves a configured embedding or speech provider exactly as it was, and each family falls back to OpenAI when none of its flags are set.
</Only>
<Only id="typescript">
Every `USE_*` flag here belongs to the LLM family, so setting one provider turns off all the others. With none set, `deepeval` falls back to OpenAI.
</Only>
When you **unset** a provider, it disables only that provider’s `USE_*` flag and leaves all others untouched. If you manually set env vars (or edit dotenv files) it’s possible to end up with multiple `USE_*` flags enabled within one family.
:::caution
Because of how `deepeval` manages your model related environment variables, **using the CLI is 100% the recommended way to configure evaluation models in `deepeval`.** It handles all the necessary environment variables for you, ensuring consistent and correct setup across different providers.
@@ -723,6 +735,21 @@ If you want to see what environment variables `deepeval` manages under the hood,
| Local (HTTP) | `set-local-embeddings` | `unset-local-embeddings` |
| Ollama | `set-ollama-embeddings` | `unset-ollama-embeddings` |
**Speech (voice simulation):**
Speech takes the provider as an argument instead of a command per provider, and text-to-speech and speech-to-text are selected independently of each other and of the LLM:
| Family | Set | Unset |
| -------------- | -------------------------------------------------------------- | ------------ |
| Text-to-speech | `set-tts <openai\|elevenlabs\|cartesia\|deepgram>` | `unset-tts` |
| Speech-to-text | `set-stt <openai\|elevenlabs\|cartesia\|deepgram\|assemblyai>` | `unset-stt` |
```bash
deepeval set-stt deepgram --model nova-3
```
The model name is optional and defaults to that provider's own default. `unset-tts` and `unset-stt` clear their whole family rather than one provider, since the provider is an argument rather than part of the command name.
</Case>
<Case id="typescript">
@@ -755,7 +782,7 @@ For provider-specific flags, run any of the `set-*` commands above with `--help`
## Common Issues
- **Nothing printed?** For `set-*` / `unset-*` / `set-debug`, a clean exit with no output often means you are passing the `--quiet` / `-q` flag.
- **Provider still active after unsetting?** Unsetting turns off that provider's `USE_*` flag only; if another provider's flag is still on, it becomes the active provider. With no `USE_*` flag enabled, deepeval falls back to OpenAI. To force a provider, run the corresponding `set-<provider>` command.
- **Provider still active after unsetting?** Unsetting turns off that provider's `USE_*` flag only; if another provider in the same family still has its flag on, it becomes the active one for that family. With no flag enabled in a family, deepeval falls back to OpenAI for it. To force a provider, run the corresponding `set-<provider>` command.
- **Dotenv edits not picked up?** deepeval loads dotenv files from the current working directory by default, or `ENV_DIR_PATH` if set. Ensure your process runs in that context.
If you’re still stuck, the dedicated [Troubleshooting](/docs/troubleshooting) page covers deeper debugging (TLS errors, logging, timeouts, dotenv loading, and config caching).
@@ -12,8 +12,10 @@ You won't need to write one. Pick whichever matches where your agent runs, tell
| Connector | Transport | Use it for |
| ------------------------ | ---------- | -------------------------------------------------------------------- |
| `VapiConnector` | WebSocket | Vapi assistants, addressed by `assistant_id`. |
| `ElevenLabsConnector` | WebSocket | ElevenLabs agents, addressed by `agent_id`. |
| `LiveKitConnector` | WebRTC | Agents dispatched into LiveKit rooms. |
| `PipecatConnector` | WebSocket | Self-hosted Pipecat pipelines, addressed by URL. |
| `WebSocketConnector` | WebSocket | Custom agents that speak raw audio over a WebSocket. |
| `CallbackVoiceConnector` | In-process | A Python callable, no network — handy for testing your own pipeline. |
@@ -25,133 +27,12 @@ See [Voice concepts](/docs/evaluation-voice#transports-and-connectors) for where
Provider not listed? If your agent exposes a raw-audio WebSocket, `WebSocketConnector` needs only the URL and the message shape. If it's callable in the same Python process, use `CallbackVoiceConnector`. Subclass `BaseVoiceConnector` only for a transport the others can't express.
::::
## ElevenLabs
Your agent lives in the ElevenLabs dashboard, so the connector only needs its `agent_id`. It speaks ElevenLabs' WebSocket API directly, so the `elevenlabs` package isn't required. Private agents also need an API key.
```python
from deepeval.voice import ElevenLabsConnector
connector = ElevenLabsConnector(agent_id="your-agent-id")
```
There are **ONE** mandatory and **SEVEN** optional parameters when creating an `ElevenLabsConnector`:
- `agent_id`: a string identifying the ElevenLabs conversational agent.
- [Optional] `api_key`: a string API key. When omitted, reads `ELEVENLABS_API_KEY` from the environment. Defaulted to `None`.
- [Optional] `region`: a string naming an ElevenLabs [data residency](https://elevenlabs.io/docs/overview/administration/data-residency) region — one of `"us"`, `"eu"`, `"in"`, or `"sg"`. Any other value raises. Defaulted to `None` (global host).
- [Optional] `client_tools`: a dictionary mapping [client tool](https://elevenlabs.io/docs/eleven-agents/customization/tools/client-tools) names to the functions that run them. Defaulted to `None`.
- [Optional] `dynamic_variables`: a dictionary of [dynamic variables](https://elevenlabs.io/docs/eleven-agents/customization/personalization/dynamic-variables) for this conversation. Defaulted to `None`.
- [Optional] `conversation_config_override`: a dictionary of [overrides](https://elevenlabs.io/docs/eleven-agents/customization/personalization/overrides) for this conversation, such as prompt, first message, language, or voice. Defaulted to `None`.
- [Optional] `client_tool_timeout_s`: how long to wait for one of your tool functions before answering the agent with an error. Defaulted to `30.0`.
- [Optional] `turn_detection`: how long to wait before deciding your agent has finished speaking — `"eager"`, `"balanced"`, or `"patient"`. See [Turn Detection](#turn-detection). Defaulted to `"balanced"`.
The connector reads ElevenLabs' [`agent_response_complete`](https://elevenlabs.io/docs/eleven-agents/api-reference/eleven-agents/websocket) message off the wire, so it knows exactly when a turn ended instead of inferring it from silence.
### Client tools
If your agent calls client tools, give the connector a function for each one. Declare the tool in the ElevenLabs dashboard as usual; the name here has to match.
```python
async def get_customer_details(parameters):
return {"name": "Alice", "subscription": "Pro"}
connector = ElevenLabsConnector(
agent_id="your-agent-id",
client_tools={"getCustomerDetails": get_customer_details},
)
```
Handlers can be `async def` or plain functions — a plain one runs in a thread so it can block without stalling the call. Each receives one dictionary of the tool's parameters, plus `tool_call_id`, and whatever it returns is given to the agent as context. Returning realistic data is what keeps the rest of the conversation realistic.
Your agent is waiting on that return value, so a handler is never allowed to leave it hanging. An unregistered name, a handler that raises, and one that outlives `client_tool_timeout_s` are all answered with an error result, and time spent inside your handler is excluded from the reported latency and from end-of-turn detection — otherwise a slow tool would read as a slow agent, or cut its turn short.
### Personalizing a run
`dynamic_variables` and `conversation_config_override` are sent per conversation, so one dashboard agent can be simulated as many different callers — a different customer, tier, or language per golden.
```python
connector = ElevenLabsConnector(
agent_id="your-agent-id",
dynamic_variables={"customer_name": "Alice", "tier": "enterprise"},
conversation_config_override={"agent": {"language": "fr"}},
)
```
::::note
Overrides only take effect if you've enabled the matching fields on the agent in ElevenLabs first. Left disabled, they're ignored rather than rejected, which looks like the override silently doing nothing.
::::
## LiveKit
`deepeval` joins your agent's room as another participant and trades audio over WebRTC, so the agent needs to be deployed and able to join. Install LiveKit's SDKs:
```bash
pip install livekit livekit-api
```
A LiveKit agent has no id you can dial — it's a worker [dispatched](https://docs.livekit.io/agents/server/agent-dispatch/) into a [room](https://docs.livekit.io/intro/basics/connect/). So instead of naming the agent, you tell the connector how to reach the room. Give it credentials and it mints a token and creates one:
```python
from deepeval.voice import LiveKitConnector
connector = LiveKitConnector(
url="wss://your-project.livekit.cloud",
api_key="your-api-key",
api_secret="your-api-secret",
agent_name="restaurant-agent",
)
```
There are **ZERO** mandatory and **ELEVEN** optional parameters when creating a `LiveKitConnector`. None is mandatory on its own, but the connector needs some way to reach the room — either the three connection values, or a `token`, or a connected `room`:
- [Optional] `url`: the LiveKit server URL. Required unless `LIVEKIT_URL` is set, or `room` is already connected. Defaulted to `None`.
- [Optional] `api_key`: the LiveKit API key. Required to mint a token, unless `LIVEKIT_API_KEY` is set. Defaulted to `None`.
- [Optional] `api_secret`: the LiveKit API secret. Required to mint a token, unless `LIVEKIT_API_SECRET` is set. Defaulted to `None`.
- [Optional] `room`: an [`rtc.Room`](https://docs.livekit.io/reference/python/livekit/rtc/room.html) to use instead of creating one. Connected or not. Defaulted to `None`.
- [Optional] `token`: a LiveKit [access token](https://docs.livekit.io/frontends/reference/tokens-grants/) you minted yourself, used instead of `api_key` / `api_secret`. Defaulted to `None`.
- [Optional] `room_name`: the room to join. When omitted, a room is created for the session. Defaulted to `None`.
- [Optional] `identity`: the participant identity for the simulator. Defaulted to `"deepeval-test"`.
- [Optional] `agent_name`: the name of the agent to [dispatch](https://docs.livekit.io/agents/server/agent-dispatch/) into the room. Defaulted to `None`.
- [Optional] `turn_detection`: how long to wait before deciding your agent has finished speaking — `"eager"`, `"balanced"`, or `"patient"`. See [Turn Detection](#turn-detection). Defaulted to `"balanced"`.
- [Optional] `connect_timeout_s`: timeout (seconds) for establishing the room connection. Defaulted to `15.0`.
- [Optional] `transcript_grace_s`: how long to wait after your agent stops speaking for its transcript to arrive. Defaulted to `1.5`.
`room_name` and `agent_name` are what actually find your agent. Omit `room_name` and the connector creates a room. Set `agent_name` and LiveKit dispatches that agent into it; leave it unset and your existing routing has to put the agent there.
If your test setup already joined a room, hand that over instead and skip the credentials:
```python
from livekit import rtc
from deepeval.voice import LiveKitConnector
room = rtc.Room()
await room.connect("wss://your-project.livekit.cloud", your_token)
connector = LiveKitConnector(room=room)
```
`deepeval` publishes a microphone track, listens for the agent, and leaves the room as it found it — a room you connected stays connected afterwards, with just the simulated microphone removed. An unconnected room gets connected using the parameters above.
::::note
`deepeval` reads your agent's own transcript from LiveKit's [`lk.transcription`](https://docs.livekit.io/agents/multimodality/text/) text stream, so STT is usually unnecessary here — the words the agent actually spoke become the assistant `Turn.content`. STT is the fallback when your agent publishes no transcript.
::::
<include cwd>snippets/voice/connector-tabs.mdx</include>
## Generic WebSocket
For custom agents that accept and emit raw audio over a WebSocket, `WebSocketConnector` lets you describe your agent's message shape — which JSON keys carry audio in and out, whether frames are binary, and what marks the end of a turn.
```python
from deepeval.voice import WebSocketConnector
connector = WebSocketConnector(
url="wss://your-agent.example.com/audio",
send_key="audio",
receive_audio_key="audio",
turn_complete_type="turn_end",
)
```
There are **ONE** mandatory and **TWELVE** optional parameters when creating a `WebSocketConnector`:
- `url`: a string WebSocket URL for your agent (e.g. `"wss://your-agent.example.com/audio"`).
@@ -172,12 +53,6 @@ There are **ONE** mandatory and **TWELVE** optional parameters when creating a `
Wraps an in-process Python callable, so you can exercise the full TTS → agent → STT pipeline without any network transport — useful for testing your simulation setup before pointing it at a deployed agent.
```python
from deepeval.voice import CallbackVoiceConnector
connector = CallbackVoiceConnector(my_agent)
```
There are **ONE** mandatory and **THREE** optional parameters when creating a `CallbackVoiceConnector`:
- `agent`: a sync or async callable that accepts user `Audio` and returns a `ConnectorTurn` or plain `Audio`.
@@ -247,9 +122,9 @@ connector = CallbackVoiceConnector(my_agent, turn_detection="patient")
Pick by listening to your agent's longest natural pause. Too eager and its turn ends at that pause, cutting the reply short and discarding the rest of it; too patient and the simulator sits through dead air before responding, which inflates the gaps in the recording and slows the run.
When your transport closes each turn explicitly, silence is only the fallback: `ElevenLabsConnector`, `CallbackVoiceConnector`, and a `WebSocketConnector` given a `turn_complete_type` are all believed over a pause, so the reply is heard out in full however long your agent stops for. Only the ceiling still applies.
When your transport closes each turn explicitly, silence is only the fallback: `ElevenLabsConnector`, `VapiConnector`, `CallbackVoiceConnector`, and a `WebSocketConnector` given a `turn_complete_type` are all believed over a pause, so the reply is heard out in full however long your agent stops for. Only the ceiling still applies.
The preset matters more everywhere else, because silence is the only evidence there is. `LiveKitConnector` gets a bare audio track with nothing to say when a turn is over, and so does a `WebSocketConnector` whose agent sends no end-of-turn message to point at.
The preset matters more everywhere else, because silence is the only evidence there is. `LiveKitConnector` gets a bare audio track with nothing to say when a turn is over, and so does a `WebSocketConnector` whose agent sends no end-of-turn message to point at. `PipecatConnector` falls on either side depending on your pipeline: it's believed over a pause once your pipeline has been seen to announce an end of turn, and reads silence until then.
::::note
The underlying millisecond values are chosen as a coordinated preset rather than exposed individually, for the same reason [interruption timing](/docs/conversation-simulator-voice-interruptions#when-does-it-interrupt) is: they are interdependent, and picking them apart is guesswork without knowing how the turn-taking loop uses them.
@@ -437,4 +437,29 @@ Azure reuses `OPENAI_COST_PER_INPUT_TOKEN` and `OPENAI_COST_PER_OUTPUT_TOKEN` fo
</details>
<details>
<summary>Speech models (TTS/STT)</summary>
Text-to-speech and speech-to-text are two independent families used by [voice simulation](/docs/multi-turn-conversation-simulation). One `USE_*_TTS` flag and one `USE_*_STT` flag can be on at the same time, and each family falls back to OpenAI when no flag is set. Set them with [`deepeval set-tts`](/docs/command-line-interface#model-provider-configs) and `deepeval set-stt`.
| Variable | Values | Effect |
| ---------------------- | ----------------- | -------------------------------------------------------------------------------------------- |
| `USE_OPENAI_TTS` | `1` / `0` / unset | Prefer OpenAI for text-to-speech. |
| `USE_ELEVENLABS_TTS` | `1` / `0` / unset | Prefer ElevenLabs for text-to-speech. |
| `USE_CARTESIA_TTS` | `1` / `0` / unset | Prefer Cartesia for text-to-speech. |
| `USE_DEEPGRAM_TTS` | `1` / `0` / unset | Prefer Deepgram for text-to-speech. |
| `DEEPEVAL_TTS_MODEL` | `string` / unset | Model name for the selected TTS provider (defaults to that provider's own default model). |
| `USE_OPENAI_STT` | `1` / `0` / unset | Prefer OpenAI for speech-to-text. |
| `USE_ELEVENLABS_STT` | `1` / `0` / unset | Prefer ElevenLabs for speech-to-text. |
| `USE_CARTESIA_STT` | `1` / `0` / unset | Prefer Cartesia for speech-to-text. |
| `USE_DEEPGRAM_STT` | `1` / `0` / unset | Prefer Deepgram for speech-to-text. |
| `USE_ASSEMBLYAI_STT` | `1` / `0` / unset | Prefer AssemblyAI for speech-to-text. |
| `DEEPEVAL_STT_MODEL` | `string` / unset | Model name for the selected STT provider (defaults to that provider's own default model). |
| `ELEVENLABS_API_KEY` | `string` / unset | ElevenLabs API key. |
| `CARTESIA_API_KEY` | `string` / unset | Cartesia API key. |
| `DEEPGRAM_API_KEY` | `string` / unset | Deepgram API key. |
| `ASSEMBLYAI_API_KEY` | `string` / unset | AssemblyAI API key. |
</details>
</Only>
+64
View File
@@ -238,6 +238,70 @@ Evaluation model integrations configure the LLM provider DeepEval uses for LLM-a
<Only id="python">
## Speech Models
Speech model integrations configure the text-to-speech and speech-to-text providers DeepEval uses for [voice simulation](/docs/conversation-simulator-voice-mode). Every provider apart from OpenAI is reached over plain HTTP, so none of them need a package beyond `deepeval` itself.
### Speech-To-Text (STT)
STT transcribes your agent's spoken replies into the `Turn.content` your metrics judge, so its accuracy bounds how faithfully every multi-turn metric sees the conversation.
<Cards className="md:grid-cols-3">
<Card title="OpenAI" href="/integrations/stt/openai" />
<Card title="Deepgram" href="/integrations/stt/deepgram" />
<Card title="AssemblyAI" href="/integrations/stt/assemblyai" />
<Card title="ElevenLabs" href="/integrations/stt/elevenlabs" />
<Card title="Cartesia" href="/integrations/stt/cartesia" />
</Cards>
### Text-To-Speech (TTS)
TTS speaks the simulated user's messages to your agent, so its clarity is what your agent's own speech recognition has to work with.
<Cards className="md:grid-cols-3">
<Card title="OpenAI" href="/integrations/tts/openai" />
<Card title="ElevenLabs" href="/integrations/tts/elevenlabs" />
<Card title="Cartesia" href="/integrations/tts/cartesia" />
<Card title="Deepgram" href="/integrations/tts/deepgram" />
</Cards>
</Only>
<Only id="python">
## Voice Agent Connectors
Voice agent connectors are how DeepEval reaches the agent under test during a [voice simulation](/docs/conversation-simulator-voice-mode): they open a live session, play the simulated user's audio, and capture the spoken reply. Pick the one matching where your agent runs.
<Cards className="md:grid-cols-3">
<Card
title="Vapi"
href="/integrations/voice-agent-connectors/vapi"
description="Call a Vapi assistant over their WebSocket transport."
/>
<Card
title="ElevenLabs"
href="/integrations/voice-agent-connectors/elevenlabs"
description="Simulate against a conversational agent in your ElevenLabs dashboard."
/>
<Card
title="LiveKit"
href="/integrations/voice-agent-connectors/livekit"
description="Join your agent's LiveKit room and trade audio over WebRTC."
/>
<Card
title="Pipecat"
href="/integrations/voice-agent-connectors/pipecat"
description="Connect to your self-hosted Pipecat pipeline over its WebSocket transport."
/>
</Cards>
Custom agents are covered by `WebSocketConnector` and `CallbackVoiceConnector` — see [Voice Connectors](/docs/conversation-simulator-voice-connectors).
</Only>
<Only id="python">
## Vector DBs
Vector database integrations show how to connect retrieval systems to DeepEval so RAG metrics can evaluate the context your application actually retrieves. Use these examples to benchmark retrieval quality and end-to-end RAG behavior.
+3
View File
@@ -4,6 +4,9 @@
"index",
"frameworks",
"models",
"stt",
"tts",
"voice-agent-connectors",
"vector-databases",
"others"
]
@@ -67,24 +67,6 @@ model = AmazonBedrockModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
</Tab>
<Tab value="ENV">
To use any Amazon Bedrock model directly in `deepeval`, set the `USE_AWS_BEDROCK_MODEL=1` in your `env` and simply pass the name of your desired model in your metric initialization:
```python
from deepeval.metrics import AnswerRelevancyMetric
answer_relevancy = AnswerRelevancyMetric(
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
)
```
You should also set the other necessary vars like `AWS_ACCESS_KEY_ID`, `AWS_SESSION_TOKEN`, ..etc. to be able to use the Amazon Bedrock models as shown above.
</Tab>
</Tabs>
There are **ZERO** mandatory and **SEVEN** optional parameters when creating an `AmazonBedrockModel`:
- [Optional] `model`: A string specifying the bedrock model identifier to call (e.g. `anthropic.claude-sonnet-4-5-20250929-v1:0`). Defaults to `AWS_BEDROCK_MODEL_NAME` if not passed; raises an error at runtime if unset.
@@ -103,6 +85,24 @@ Pass generation parameters like `temperature`, `topP`, or `maxTokens` via `gener
Extra `**kwargs` passed to `AmazonBedrockModel(...)` are forwarded to the underlying Bedrock client (aiobotocore/botocore) and are **not** treated as generation parameters.
:::
</Tab>
<Tab value="ENV">
To use any Amazon Bedrock model directly in `deepeval`, set the `USE_AWS_BEDROCK_MODEL=1` in your `env` and simply pass the name of your desired model in your metric initialization:
```python
from deepeval.metrics import AnswerRelevancyMetric
answer_relevancy = AnswerRelevancyMetric(
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
)
```
You should also set the other necessary vars like `AWS_ACCESS_KEY_ID`, `AWS_SESSION_TOKEN`, ..etc. to be able to use the Amazon Bedrock models as shown above.
</Tab>
</Tabs>
</Case>
<Case id="typescript">
@@ -124,23 +124,6 @@ const model = new AmazonBedrockModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
</Tab>
<Tab value="ENV">
Every option is backed by an environment variable, so you can set `AWS_BEDROCK_MODEL_NAME`, `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` in your `env` and construct the model with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { AmazonBedrockModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new AmazonBedrockModel(),
});
```
</Tab>
</Tabs>
There are **ZERO** mandatory and **EIGHT** optional parameters when creating an `AmazonBedrockModel`:
- [Optional] `model`: A string specifying the bedrock model identifier to call (e.g. `anthropic.claude-sonnet-4-5-20250929-v1:0`). Defaults to `AWS_BEDROCK_MODEL_NAME` if not passed; raises an error at runtime if unset.
@@ -164,6 +147,23 @@ const model = new AmazonBedrockModel({
Available keys vary by the Bedrock model you choose — see the [AWS Bedrock inference parameters docs](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-parameters.html).
</Tab>
<Tab value="ENV">
Every option is backed by an environment variable, so you can set `AWS_BEDROCK_MODEL_NAME`, `AWS_BEDROCK_REGION`, `AWS_ACCESS_KEY_ID`, and `AWS_SECRET_ACCESS_KEY` in your `env` and construct the model with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { AmazonBedrockModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new AmazonBedrockModel(),
});
```
</Tab>
</Tabs>
</Case>
</Switch>
+34 -34
View File
@@ -44,6 +44,23 @@ model = AnthropicModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
There are **ZERO** mandatory and **SIX** optional parameters when creating an `AnthropicModel`:
- [Optional] `model`: A string specifying which Claude model to use. Defaults to `ANTHROPIC_MODEL_NAME` if not passed; falls back to <DefaultLLMModel provider="anthropic" /> if unset.
- [Optional] `api_key`: A string specifying your Anthropic API key. Defaults to `ANTHROPIC_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset and raises if < 0.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `ANTHROPIC_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `ANTHROPIC_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the Anthropic `messages.create(...)` call.
Parameters may be explicitly passed to the model at initialization time, or configured with optional settings. The **mandatory** parameters are required at runtime, but you can provide them either explicitly as constructor arguments, **or** via `deepeval` settings / environment variables (constructor args take precedence). See [Environment variables and settings](/docs/evaluation-flags-and-configs#model-settings-anthropic) for the Anthropic-related environment variables.
:::tip
Pass generation parameters, such as `max_tokens`, via `generation_kwargs` (they are forwarded to `messages.create(...)`).
Extra `**kwargs` passed to `AnthropicModel(...)` are forwarded to the underlying Anthropic client and are **not** treated as generation parameters.
:::
</Tab>
<Tab value="ENV">
@@ -62,23 +79,6 @@ You should also set the other necessary vars like `ANTHROPIC_API_KEY` to be able
</Tab>
</Tabs>
There are **ZERO** mandatory and **SIX** optional parameters when creating an `AnthropicModel`:
- [Optional] `model`: A string specifying which Claude model to use. Defaults to `ANTHROPIC_MODEL_NAME` if not passed; falls back to <DefaultLLMModel provider="anthropic" /> if unset.
- [Optional] `api_key`: A string specifying your Anthropic API key. Defaults to `ANTHROPIC_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset and raises if < 0.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `ANTHROPIC_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `ANTHROPIC_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the Anthropic `messages.create(...)` call.
Parameters may be explicitly passed to the model at initialization time, or configured with optional settings. The **mandatory** parameters are required at runtime, but you can provide them either explicitly as constructor arguments, **or** via `deepeval` settings / environment variables (constructor args take precedence). See [Environment variables and settings](/docs/evaluation-flags-and-configs#model-settings-anthropic) for the Anthropic-related environment variables.
:::tip
Pass generation parameters, such as `max_tokens`, via `generation_kwargs` (they are forwarded to `messages.create(...)`).
Extra `**kwargs` passed to `AnthropicModel(...)` are forwarded to the underlying Anthropic client and are **not** treated as generation parameters.
:::
</Case>
<Case id="typescript">
@@ -99,23 +99,6 @@ const model = new AnthropicModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
</Tab>
<Tab value="ENV">
Every option is backed by an environment variable, so you can set `ANTHROPIC_MODEL_NAME` and `ANTHROPIC_API_KEY` in your `env` and construct the model with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { AnthropicModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new AnthropicModel(),
});
```
</Tab>
</Tabs>
There are **ZERO** mandatory and **SIX** optional parameters when creating an `AnthropicModel`:
- [Optional] `model`: A string specifying which Claude model to use. Defaults to `ANTHROPIC_MODEL_NAME` if not passed; falls back to <DefaultLLMModel provider="anthropic" /> if unset.
@@ -135,6 +118,23 @@ const model = new AnthropicModel({
});
```
</Tab>
<Tab value="ENV">
Every option is backed by an environment variable, so you can set `ANTHROPIC_MODEL_NAME` and `ANTHROPIC_API_KEY` in your `env` and construct the model with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { AnthropicModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new AnthropicModel(),
});
```
</Tab>
</Tabs>
</Case>
</Switch>
@@ -64,6 +64,22 @@ model = AzureOpenAIModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
There are **ZERO** mandatory and **NINE** optional parameters when creating an `AzureOpenAIModel`:
- [Optional] `model`: A string specifying the name of the Azure OpenAI model to use. Defaults to `AZURE_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying your Azure OpenAI API key. Defaults to `AZURE_OPENAI_API_KEY` if not passed; raises an error at runtime if `azure_ad_token` and `azure_ad_token_provider` are also unset.
- [Optional] `azure_ad_token`: A string specifying your Azure Ad Token. Defaults to `AZURE_OPENAI_AD_TOKEN` if not passed; raises an error at runtime if `api_key` and `azure_ad_token_provider` are also unset.
- [Optional] `azure_ad_token_provider`: A callback of either `AsyncAzureADTokenProvider` or `AzureADTokenProvider` that can be used for credentials [(see example usage)](https://github.com/openai/openai-python/blob/main/examples/azure_ad.py#L20). Raises an error at runtime if `api_key` and `azure_ad_token` are also unset.
- [Optional] `base_url`: A string specifying your Azure OpenAI endpoint URL. Defaults to `AZURE_OPENAI_ENDPOINT` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `OPENAI_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `OPENAI_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `deployment_name`: A string specifying the name of your Azure OpenAI deployment. Defaults to `AZURE_DEPLOYMENT_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_version`: A string specifying the OpenAI API version used in your deployment. Defaults to `OPENAI_API_VERSION` if not passed; raises an error at runtime if unset.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the Azure OpenAI `chat.completions.create(...)` and `beta.chat.completions.parse(...)` calls.
Parameters may be explicitly passed to the model at initialization time, or configured with optional settings. The **mandatory** parameters are required at runtime, but you can provide them either explicitly as constructor arguments, **or** via `deepeval` settings / environment variables (constructor args take precedence). See [Environment variables and settings](/docs/evaluation-flags-and-configs#model-settings-azure-openai) for the Azure OpenAI-related environment variables.
</Tab>
<Tab value="ENV">
@@ -82,22 +98,6 @@ You should also set the other necessary vars like `AZURE_OPENAI_API_KEY` to be a
</Tab>
</Tabs>
There are **ZERO** mandatory and **NINE** optional parameters when creating an `AzureOpenAIModel`:
- [Optional] `model`: A string specifying the name of the Azure OpenAI model to use. Defaults to `AZURE_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying your Azure OpenAI API key. Defaults to `AZURE_OPENAI_API_KEY` if not passed; raises an error at runtime if `azure_ad_token` and `azure_ad_token_provider` are also unset.
- [Optional] `azure_ad_token`: A string specifying your Azure Ad Token. Defaults to `AZURE_OPENAI_AD_TOKEN` if not passed; raises an error at runtime if `api_key` and `azure_ad_token_provider` are also unset.
- [Optional] `azure_ad_token_provider`: A callback of either `AsyncAzureADTokenProvider` or `AzureADTokenProvider` that can be used for credentials [(see example usage)](https://github.com/openai/openai-python/blob/main/examples/azure_ad.py#L20). Raises an error at runtime if `api_key` and `azure_ad_token` are also unset.
- [Optional] `base_url`: A string specifying your Azure OpenAI endpoint URL. Defaults to `AZURE_OPENAI_ENDPOINT` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `OPENAI_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `OPENAI_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `deployment_name`: A string specifying the name of your Azure OpenAI deployment. Defaults to `AZURE_DEPLOYMENT_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_version`: A string specifying the OpenAI API version used in your deployment. Defaults to `OPENAI_API_VERSION` if not passed; raises an error at runtime if unset.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the Azure OpenAI `chat.completions.create(...)` and `beta.chat.completions.parse(...)` calls.
Parameters may be explicitly passed to the model at initialization time, or configured with optional settings. The **mandatory** parameters are required at runtime, but you can provide them either explicitly as constructor arguments, **or** via `deepeval` settings / environment variables (constructor args take precedence). See [Environment variables and settings](/docs/evaluation-flags-and-configs#model-settings-azure-openai) for the Azure OpenAI-related environment variables.
</Case>
<Case id="typescript">
@@ -121,6 +121,27 @@ const model = new AzureOpenAIModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
There are **ZERO** mandatory and **EIGHT** optional parameters when creating an `AzureOpenAIModel`:
- [Optional] `model`: A string specifying the name of the Azure OpenAI model your deployment serves. Used to look up pricing and capabilities in `deepeval`'s model registry, and used as the deployment name when `deployment` is not passed.
- [Optional] `deployment`: A string specifying the name of your Azure OpenAI deployment, which requests are routed by. Defaults to `model`, then `AZURE_DEPLOYMENT_NAME`, then `AZURE_MODEL_NAME` if not passed; raises an error if unset.
- [Optional] `endpoint`: A string specifying your Azure OpenAI endpoint URL. Defaults to `AZURE_OPENAI_ENDPOINT` if not passed; raises an error if unset.
- [Optional] `apiKey`: A string specifying your Azure OpenAI API key. Defaults to `AZURE_OPENAI_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `apiVersion`: A string specifying the OpenAI API version used in your deployment. Defaults to `OPENAI_API_VERSION` if not passed.
- [Optional] `temperature`: A number specifying the model temperature. Defaults to `0`. Pass `null` to omit it from the request entirely, which is required by reasoning models that reject it.
- [Optional] `costPerInputToken`: A number specifying the cost for each input token for the provided model. Defaults to the price in `deepeval`'s model cost registry, else `null`.
- [Optional] `costPerOutputToken`: A number specifying the cost for each output token for the provided model. Defaults to the price in `deepeval`'s model cost registry, else `null`.
Any other key you pass is forwarded as-is to the Azure OpenAI `chat.completions.create(...)` call, so generation parameters `deepeval` does not name go inline alongside the rest:
```typescript
const model = new AzureOpenAIModel({
model: "gpt-4.1",
top_p: 0.9,
seed: 42,
});
```
</Tab>
<Tab value="ENV">
@@ -149,27 +170,6 @@ const answerRelevancy = new AnswerRelevancyMetric({
</Tab>
</Tabs>
There are **ZERO** mandatory and **EIGHT** optional parameters when creating an `AzureOpenAIModel`:
- [Optional] `model`: A string specifying the name of the Azure OpenAI model your deployment serves. Used to look up pricing and capabilities in `deepeval`'s model registry, and used as the deployment name when `deployment` is not passed.
- [Optional] `deployment`: A string specifying the name of your Azure OpenAI deployment, which requests are routed by. Defaults to `model`, then `AZURE_DEPLOYMENT_NAME`, then `AZURE_MODEL_NAME` if not passed; raises an error if unset.
- [Optional] `endpoint`: A string specifying your Azure OpenAI endpoint URL. Defaults to `AZURE_OPENAI_ENDPOINT` if not passed; raises an error if unset.
- [Optional] `apiKey`: A string specifying your Azure OpenAI API key. Defaults to `AZURE_OPENAI_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `apiVersion`: A string specifying the OpenAI API version used in your deployment. Defaults to `OPENAI_API_VERSION` if not passed.
- [Optional] `temperature`: A number specifying the model temperature. Defaults to `0`. Pass `null` to omit it from the request entirely, which is required by reasoning models that reject it.
- [Optional] `costPerInputToken`: A number specifying the cost for each input token for the provided model. Defaults to the price in `deepeval`'s model cost registry, else `null`.
- [Optional] `costPerOutputToken`: A number specifying the cost for each output token for the provided model. Defaults to the price in `deepeval`'s model cost registry, else `null`.
Any other key you pass is forwarded as-is to the Azure OpenAI `chat.completions.create(...)` call, so generation parameters `deepeval` does not name go inline alongside the rest:
```typescript
const model = new AzureOpenAIModel({
model: "gpt-4.1",
top_p: 0.9,
seed: 42,
});
```
</Case>
</Switch>
+28 -28
View File
@@ -51,6 +51,17 @@ model = DeepSeekModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
There are **ZERO** mandatory and **SIX** optional parameters when creating a `DeepSeekModel`:
- [Optional] `model`: A string specifying the name of the DeepSeek model to use. Defaults to `DEEPSEEK_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying your DeepSeek API key for authentication. Defaults to `DEEPSEEK_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `DEEPSEEK_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `DEEPSEEK_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation forwarded to the OpenAI `chat.completions.create(...)` call.
Parameters may be explicitly passed to the model at initialization time, or configured with optional settings. The **mandatory** parameters are required at runtime, but you can provide them either explicitly as constructor arguments, **or** via `deepeval` settings / environment variables (constructor args take precedence). See [Environment variables and settings](/docs/evaluation-flags-and-configs#model-settings-deep-seek) for the DeepSeek-related environment variables.
</Tab>
<Tab value="ENV">
@@ -69,17 +80,6 @@ You should also set the other necessary vars like `DEEPSEEK_API_KEY` to be able
</Tab>
</Tabs>
There are **ZERO** mandatory and **SIX** optional parameters when creating a `DeepSeekModel`:
- [Optional] `model`: A string specifying the name of the DeepSeek model to use. Defaults to `DEEPSEEK_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying your DeepSeek API key for authentication. Defaults to `DEEPSEEK_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `DEEPSEEK_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `DEEPSEEK_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation forwarded to the OpenAI `chat.completions.create(...)` call.
Parameters may be explicitly passed to the model at initialization time, or configured with optional settings. The **mandatory** parameters are required at runtime, but you can provide them either explicitly as constructor arguments, **or** via `deepeval` settings / environment variables (constructor args take precedence). See [Environment variables and settings](/docs/evaluation-flags-and-configs#model-settings-deep-seek) for the DeepSeek-related environment variables.
</Case>
<Case id="typescript">
@@ -100,23 +100,6 @@ const model = new DeepSeekModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
</Tab>
<Tab value="ENV">
To use any DeepSeek model directly in `deepeval`, set `DEEPSEEK_MODEL_NAME` and `DEEPSEEK_API_KEY` in your `env` and create a `DeepSeekModel` with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { DeepSeekModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new DeepSeekModel(),
});
```
</Tab>
</Tabs>
There are **ZERO** mandatory and **SEVEN** optional parameters when creating a `DeepSeekModel`:
- [Optional] `model`: A string specifying the name of the DeepSeek model to use. Defaults to `DEEPSEEK_MODEL_NAME` if not passed; falls back to `deepseek-chat` if unset.
@@ -137,6 +120,23 @@ const model = new DeepSeekModel({
});
```
</Tab>
<Tab value="ENV">
To use any DeepSeek model directly in `deepeval`, set `DEEPSEEK_MODEL_NAME` and `DEEPSEEK_API_KEY` in your `env` and create a `DeepSeekModel` with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { DeepSeekModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new DeepSeekModel(),
});
```
</Tab>
</Tabs>
</Case>
</Switch>
+32 -32
View File
@@ -56,6 +56,21 @@ model = GeminiModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
There are **ZERO** mandatory and **SIX** optional parameters when creating a `GeminiModel`:
- [Optional] `model`: A string specifying the name of the Gemini model to use. Defaults to `GEMINI_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying the Google API key for authentication. Defaults to `GOOGLE_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost per input token for the provided model. Defaults to `GEMINI_COST_PER_INPUT_TOKEN` if set; falls back to `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost per output token for the provided model. Defaults to `GEMINI_COST_PER_OUTPUT_TOKEN` if set; falls back to `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the Gemini API `generate_content(...)` call.
Parameters may be explicitly passed to the model at initialization time, or configured with optional settings. The **mandatory** parameters are required at runtime, but you can provide them either explicitly as constructor arguments, **or** via `deepeval` settings / environment variables (constructor args take precedence). See [Environment variables and settings](/docs/evaluation-flags-and-configs#model-settings-gemini) for the Gemini-related environment variables.
:::note
At runtime, you must provide an API key (via `api_key` or `GOOGLE_API_KEY`) unless you’re using Vertex AI. See [Vertex AI](/docs/integrations/models/vertex-ai).
:::
</Tab>
<Tab value="ENV">
@@ -74,21 +89,6 @@ You should also set the other necessary vars like `GOOGLE_API_KEY` to be able to
</Tab>
</Tabs>
There are **ZERO** mandatory and **SIX** optional parameters when creating a `GeminiModel`:
- [Optional] `model`: A string specifying the name of the Gemini model to use. Defaults to `GEMINI_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying the Google API key for authentication. Defaults to `GOOGLE_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost per input token for the provided model. Defaults to `GEMINI_COST_PER_INPUT_TOKEN` if set; falls back to `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost per output token for the provided model. Defaults to `GEMINI_COST_PER_OUTPUT_TOKEN` if set; falls back to `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the Gemini API `generate_content(...)` call.
Parameters may be explicitly passed to the model at initialization time, or configured with optional settings. The **mandatory** parameters are required at runtime, but you can provide them either explicitly as constructor arguments, **or** via `deepeval` settings / environment variables (constructor args take precedence). See [Environment variables and settings](/docs/evaluation-flags-and-configs#model-settings-gemini) for the Gemini-related environment variables.
:::note
At runtime, you must provide an API key (via `api_key` or `GOOGLE_API_KEY`) unless you’re using Vertex AI. See [Vertex AI](/docs/integrations/models/vertex-ai).
:::
</Case>
<Case id="typescript">
@@ -113,23 +113,6 @@ const model = new GeminiModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
</Tab>
<Tab value="ENV">
Every option is backed by an environment variable, so you can set `GEMINI_MODEL_NAME` and `GOOGLE_API_KEY` in your `env` and construct the model with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { GeminiModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new GeminiModel(),
});
```
</Tab>
</Tabs>
There are **ZERO** mandatory and **EIGHT** optional parameters when creating a `GeminiModel`:
- [Optional] `model`: A string specifying the name of the Gemini model to use. Defaults to `GEMINI_MODEL_NAME` if not passed; falls back to <DefaultLLMModel provider="gemini" /> if unset.
@@ -155,6 +138,23 @@ const model = new GeminiModel({
At runtime, you must provide an API key (via `apiKey`, `GOOGLE_API_KEY`, or `GEMINI_API_KEY`) unless you’re using Vertex AI. See [Vertex AI](/docs/integrations/models/vertex-ai).
:::
</Tab>
<Tab value="ENV">
Every option is backed by an environment variable, so you can set `GEMINI_MODEL_NAME` and `GOOGLE_API_KEY` in your `env` and construct the model with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { GeminiModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new GeminiModel(),
});
```
</Tab>
</Tabs>
</Case>
</Switch>
+26 -26
View File
@@ -74,6 +74,15 @@ model = GrokModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
There are **ZERO** mandatory and **SIX** optional parameters when creating a `GrokModel`:
- [Optional] `model`: A string specifying the name of the Grok model to use. Defaults to `GROK_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying your Grok API key for authentication. Defaults to `GROK_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `GROK_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `GROK_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the xAI SDK `client.chat.create(...)` call.
</Tab>
<Tab value="ENV">
@@ -92,15 +101,6 @@ You should also set the other necessary vars like `GROK_API_KEY` to be able to u
</Tab>
</Tabs>
There are **ZERO** mandatory and **SIX** optional parameters when creating a `GrokModel`:
- [Optional] `model`: A string specifying the name of the Grok model to use. Defaults to `GROK_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying your Grok API key for authentication. Defaults to `GROK_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `GROK_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `GROK_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the xAI SDK `client.chat.create(...)` call.
</Case>
<Case id="typescript">
@@ -121,23 +121,6 @@ const model = new GrokModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
</Tab>
<Tab value="ENV">
To use any Grok model directly in `deepeval`, set `GROK_MODEL_NAME` and `GROK_API_KEY` in your `env` and create a `GrokModel` with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { GrokModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new GrokModel(),
});
```
</Tab>
</Tabs>
There are **ZERO** mandatory and **SEVEN** optional parameters when creating a `GrokModel`:
- [Optional] `model`: A string specifying the name of the Grok model to use. Defaults to `GROK_MODEL_NAME` if not passed; falls back to `grok-3` if unset.
@@ -158,6 +141,23 @@ const model = new GrokModel({
});
```
</Tab>
<Tab value="ENV">
To use any Grok model directly in `deepeval`, set `GROK_MODEL_NAME` and `GROK_API_KEY` in your `env` and create a `GrokModel` with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { GrokModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new GrokModel(),
});
```
</Tab>
</Tabs>
</Case>
</Switch>
+14 -14
View File
@@ -74,6 +74,20 @@ model = LiteLLMModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
There are **ZERO** mandatory and **SEVEN** optional parameters when creating a `LiteLLMModel`:
- [Optional] `model`: A string specifying the provider and model name (e.g. `openai/gpt-3.5-turbo`, `anthropic/claude-3-opus`). Defaults to `LITELLM_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying the API key for the model. If not passed, `deepeval` attempts (in order) `LITELLM_API_KEY`, `LITELLM_PROXY_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, then `GOOGLE_API_KEY`. If none are set, the key is left unset and the underlying LiteLLM/provider behavior applies.
- [Optional] `base_url`: A string specifying the base URL for the model API. Defaults to `LITELLM_API_BASE`, then `LITELLM_PROXY_API_BASE` if not passed.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `None` if not passed; when unset, the cost is taken from LiteLLM's response if it reports one, otherwise reported as unknown.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `None` if not passed; when unset, the cost is taken from LiteLLM's response if it reports one, otherwise reported as unknown.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to LiteLLM's `completion(...)` / `acompletion(...)` call.
:::tip
Any `**kwargs` you would like to use for your model can be passed through the `generation_kwargs` parameter. However, we request you to double check the params supported by the model and your model provider in their [official docs](https://docs.litellm.ai/docs/providers/custom_llm_server).
:::
</Tab>
<Tab value="ENV">
@@ -92,20 +106,6 @@ You should also set the other necessary vars like `LITELLM_API_KEY` to be able t
</Tab>
</Tabs>
There are **ZERO** mandatory and **SEVEN** optional parameters when creating a `LiteLLMModel`:
- [Optional] `model`: A string specifying the provider and model name (e.g. `openai/gpt-3.5-turbo`, `anthropic/claude-3-opus`). Defaults to `LITELLM_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying the API key for the model. If not passed, `deepeval` attempts (in order) `LITELLM_API_KEY`, `LITELLM_PROXY_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, then `GOOGLE_API_KEY`. If none are set, the key is left unset and the underlying LiteLLM/provider behavior applies.
- [Optional] `base_url`: A string specifying the base URL for the model API. Defaults to `LITELLM_API_BASE`, then `LITELLM_PROXY_API_BASE` if not passed.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `None` if not passed; when unset, the cost is taken from LiteLLM's response if it reports one, otherwise reported as unknown.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `None` if not passed; when unset, the cost is taken from LiteLLM's response if it reports one, otherwise reported as unknown.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to LiteLLM's `completion(...)` / `acompletion(...)` call.
:::tip
Any `**kwargs` you would like to use for your model can be passed through the `generation_kwargs` parameter. However, we request you to double check the params supported by the model and your model provider in their [official docs](https://docs.litellm.ai/docs/providers/custom_llm_server).
:::
### Available Models
:::note
+34 -34
View File
@@ -54,6 +54,19 @@ model = LocalModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
There are **ZERO** mandatory and **SIX** optional parameters when creating a `LocalModel`:
- [Optional] `model`: A string specifying the local model to use. Defaults to `LOCAL_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying the API key for your local server. Defaults to `LOCAL_MODEL_API_KEY` if not passed; raises an error at runtime if unset. Local servers without authentication accept any placeholder string.
- [Optional] `base_url`: A string specifying the base URL of your local server. Defaults to `LOCAL_MODEL_BASE_URL` if not passed.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `format`: A string specifying the structured-output response format. Defaults to `LOCAL_MODEL_FORMAT` if not passed; falls back to `"json"` if unset.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the local server's `chat.completions.create(...)` call.
:::tip
Any `**kwargs` you would like to use for your model can be passed directly to `LocalModel(...)`; these are forwarded to the underlying OpenAI client constructor.
:::
</Tab>
<Tab value="ENV">
@@ -72,19 +85,6 @@ You should also set the other necessary vars like `LOCAL_MODEL_BASE_URL` and `LO
</Tab>
</Tabs>
There are **ZERO** mandatory and **SIX** optional parameters when creating a `LocalModel`:
- [Optional] `model`: A string specifying the local model to use. Defaults to `LOCAL_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying the API key for your local server. Defaults to `LOCAL_MODEL_API_KEY` if not passed; raises an error at runtime if unset. Local servers without authentication accept any placeholder string.
- [Optional] `base_url`: A string specifying the base URL of your local server. Defaults to `LOCAL_MODEL_BASE_URL` if not passed.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `format`: A string specifying the structured-output response format. Defaults to `LOCAL_MODEL_FORMAT` if not passed; falls back to `"json"` if unset.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the local server's `chat.completions.create(...)` call.
:::tip
Any `**kwargs` you would like to use for your model can be passed directly to `LocalModel(...)`; these are forwarded to the underlying OpenAI client constructor.
:::
</Case>
<Case id="typescript">
@@ -106,6 +106,27 @@ const model = new LocalModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
There are **ZERO** mandatory and **SEVEN** optional parameters when creating a `LocalModel`:
- [Optional] `model`: A string specifying the local model to use. Defaults to `LOCAL_MODEL_NAME` if not passed; raises an error if unset.
- [Optional] `apiKey`: A string specifying the API key for your local server. Defaults to `LOCAL_MODEL_API_KEY` if not passed; falls back to a placeholder string, so local servers without authentication work out of the box.
- [Optional] `baseURL`: A string specifying the base URL of your local server. Defaults to `LOCAL_MODEL_BASE_URL` if not passed; raises an error if unset.
- [Optional] `temperature`: A number specifying the model temperature. Defaults to `0`. Pass `null` to omit it from the request entirely, which is required by reasoning models that reject it.
- [Optional] `defaultHeaders`: An object of extra HTTP headers sent with every request.
- [Optional] `costPerInputToken`: A number specifying the cost for each input token for the provided model. Defaults to the price in `deepeval`'s model cost registry, else `null`.
- [Optional] `costPerOutputToken`: A number specifying the cost for each output token for the provided model. Defaults to the price in `deepeval`'s model cost registry, else `null`.
Any other key you pass is forwarded as-is to the local server's `chat.completions.create(...)` call, so generation parameters `deepeval` does not name go inline alongside the rest:
```typescript
const model = new LocalModel({
model: "<model_name>",
baseURL: "http://localhost:1234/v1/",
top_p: 0.9,
max_tokens: 512,
});
```
</Tab>
<Tab value="ENV">
@@ -132,27 +153,6 @@ const answerRelevancy = new AnswerRelevancyMetric({
</Tab>
</Tabs>
There are **ZERO** mandatory and **SEVEN** optional parameters when creating a `LocalModel`:
- [Optional] `model`: A string specifying the local model to use. Defaults to `LOCAL_MODEL_NAME` if not passed; raises an error if unset.
- [Optional] `apiKey`: A string specifying the API key for your local server. Defaults to `LOCAL_MODEL_API_KEY` if not passed; falls back to a placeholder string, so local servers without authentication work out of the box.
- [Optional] `baseURL`: A string specifying the base URL of your local server. Defaults to `LOCAL_MODEL_BASE_URL` if not passed; raises an error if unset.
- [Optional] `temperature`: A number specifying the model temperature. Defaults to `0`. Pass `null` to omit it from the request entirely, which is required by reasoning models that reject it.
- [Optional] `defaultHeaders`: An object of extra HTTP headers sent with every request.
- [Optional] `costPerInputToken`: A number specifying the cost for each input token for the provided model. Defaults to the price in `deepeval`'s model cost registry, else `null`.
- [Optional] `costPerOutputToken`: A number specifying the cost for each output token for the provided model. Defaults to the price in `deepeval`'s model cost registry, else `null`.
Any other key you pass is forwarded as-is to the local server's `chat.completions.create(...)` call, so generation parameters `deepeval` does not name go inline alongside the rest:
```typescript
const model = new LocalModel({
model: "<model_name>",
baseURL: "http://localhost:1234/v1/",
top_p: 0.9,
max_tokens: 512,
});
```
</Case>
</Switch>
+26 -26
View File
@@ -55,6 +55,15 @@ model = KimiModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
There are **ZERO** mandatory and **SIX** optional parameters when creating an `KimiModel`:
- [Optional] `model`: A string specifying the name of the Kimi model to use. Defaults to `MOONSHOT_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying your Kimi API key for authentication. Defaults to `MOONSHOT_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset and raises if < 0.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `MOONSHOT_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `MOONSHOT_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the OpenAI `chat.completions.create(...)` call.
</Tab>
<Tab value="ENV">
@@ -73,15 +82,6 @@ You should also set the other necessary vars like `MOONSHOT_API_KEY` to be able
</Tab>
</Tabs>
There are **ZERO** mandatory and **SIX** optional parameters when creating an `KimiModel`:
- [Optional] `model`: A string specifying the name of the Kimi model to use. Defaults to `MOONSHOT_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying your Kimi API key for authentication. Defaults to `MOONSHOT_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset and raises if < 0.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `MOONSHOT_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `MOONSHOT_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the OpenAI `chat.completions.create(...)` call.
</Case>
<Case id="typescript">
@@ -102,23 +102,6 @@ const model = new KimiModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
</Tab>
<Tab value="ENV">
To use any Moonshot model directly in `deepeval`, set `MOONSHOT_MODEL_NAME` and `MOONSHOT_API_KEY` in your `env` and create a `KimiModel` with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { KimiModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new KimiModel(),
});
```
</Tab>
</Tabs>
There are **ZERO** mandatory and **SEVEN** optional parameters when creating a `KimiModel`:
- [Optional] `model`: A string specifying the name of the Kimi model to use. Defaults to `MOONSHOT_MODEL_NAME` if not passed; falls back to `moonshot-v1-8k` if unset.
@@ -139,6 +122,23 @@ const model = new KimiModel({
});
```
</Tab>
<Tab value="ENV">
To use any Moonshot model directly in `deepeval`, set `MOONSHOT_MODEL_NAME` and `MOONSHOT_API_KEY` in your `env` and create a `KimiModel` with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { KimiModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new KimiModel(),
});
```
</Tab>
</Tabs>
</Case>
</Switch>
+37 -26
View File
@@ -81,7 +81,15 @@ answer_relevancy = AnswerRelevancyMetric(model=model)
</Tab>
<Tab value="ENV">
To use any Ollama model directly in `deepeval`, set the `LOCAL_MODEL_API_KEY` in your `env` and simply pass the name of your desired model in your metric initialization:
First point `deepeval` at Ollama. `LOCAL_MODEL_API_KEY` must be the literal string `ollama` — that value is what routes a plain model name to your Ollama server instead of a hosted provider:
```bash
# .env.local
LOCAL_MODEL_API_KEY=ollama
LOCAL_MODEL_BASE_URL=http://localhost:11434
```
Then pass the name of your desired model as a string in your metric initialization:
```python
from deepeval.metrics import AnswerRelevancyMetric
@@ -91,6 +99,8 @@ answer_relevancy = AnswerRelevancyMetric(
)
```
Running `deepeval set-ollama --model=deepseek-r1:1.5b --save` writes these same variables for you.
</Tab>
</Tabs>
@@ -121,31 +131,6 @@ const model = new OllamaModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
</Tab>
<Tab value="ENV">
`deepeval` autoloads `.env.local` then `.env` at import time, so you can keep your Ollama configuration out of your code entirely:
```bash
# .env.local
OLLAMA_MODEL_NAME=deepseek-r1:1.5b
LOCAL_MODEL_BASE_URL=http://localhost:11434
```
Then create the model with no arguments and pass it to any metric:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { OllamaModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new OllamaModel(),
});
```
</Tab>
</Tabs>
There are **ZERO** mandatory and **FIVE** optional parameters when creating an `OllamaModel`:
- [Optional] `model`: A string specifying the name of the Ollama model to use. Defaults to `OLLAMA_MODEL_NAME` if not passed; raises an error at runtime if unset.
@@ -164,6 +149,32 @@ const model = new OllamaModel({
});
```
</Tab>
<Tab value="ENV">
First point `deepeval` at Ollama. `LOCAL_MODEL_API_KEY` must be the literal string `ollama` — that value is what routes a plain model name to your Ollama server instead of a hosted provider:
```bash
# .env.local
LOCAL_MODEL_API_KEY=ollama
LOCAL_MODEL_BASE_URL=http://localhost:11434
```
Then pass the name of your desired model as a string in your metric initialization:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
const answerRelevancy = new AnswerRelevancyMetric({
model: "deepseek-r1:1.5b",
});
```
Running `deepeval set-ollama --model=deepseek-r1:1.5b --save` writes these same variables for you.
</Tab>
</Tabs>
</Case>
</Switch>
+28 -28
View File
@@ -18,13 +18,13 @@ DeepEval autoloads `.env.local` then `.env` at import time (process env -> `.env
OPENAI_API_KEY=<your-openai-api-key>
```
Alternative (Shell/CI)
**Alternative (Shell/CI):**
```bash
export OPENAI_API_KEY=<your-openai-api-key>
```
Alternative (notebook)
**Alternative (notebook):**
If you're working in a notebook environment (Jupyter or Colab), set your `OPENAI_API_KEY` in a cell:
@@ -85,6 +85,16 @@ model = OpenAIModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
There are **ZERO** mandatory and **SEVEN** optional parameters when creating an `OpenAIModel`:
- [Optional] `model`: A string specifying the name of the GPT model to use. Defaulted to `OPENAI_MODEL_NAME` if not set; falls back to <DefaultLLMModel />.
- [Optional] `api_key`: A string specifying the OpenAI API key for authentication. Defaults to `OPENAI_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `base_url`: A string specifying your OpenAI URL.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `OPENAI_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `OPENAI_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the OpenAI `chat.completions.create(...)` and `beta.chat.completions.parse(...)` calls.
</Tab>
<Tab value="ENV">
@@ -101,16 +111,6 @@ answer_relevancy = AnswerRelevancyMetric(
</Tab>
</Tabs>
There are **ZERO** mandatory and **SEVEN** optional parameters when creating an `OpenAIModel`:
- [Optional] `model`: A string specifying the name of the GPT model to use. Defaulted to `OPENAI_MODEL_NAME` if not set; falls back to <DefaultLLMModel />.
- [Optional] `api_key`: A string specifying the OpenAI API key for authentication. Defaults to `OPENAI_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `base_url`: A string specifying your OpenAI URL.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `cost_per_input_token`: A float specifying the cost for each input token for the provided model. Defaults to `OPENAI_COST_PER_INPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `cost_per_output_token`: A float specifying the cost for each output token for the provided model. Defaults to `OPENAI_COST_PER_OUTPUT_TOKEN` if available in `deepeval`'s model cost registry, else `None`.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to the OpenAI `chat.completions.create(...)` and `beta.chat.completions.parse(...)` calls.
</Case>
<Case id="typescript">
@@ -131,22 +131,6 @@ const model = new OpenAIModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
</Tab>
<Tab value="ENV">
`deepeval` by default uses OpenAI models for evaluations, you can simply pass the name of your desired model in metric initialization and set the `OPENAI_API_KEY` to use OpenAI models:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
const answerRelevancy = new AnswerRelevancyMetric({
model: "gpt-4.1",
});
```
</Tab>
</Tabs>
There are **ZERO** mandatory and **SEVEN** optional parameters when creating an `OpenAIModel`:
- [Optional] `model`: A string specifying the name of the OpenAI model to use. Defaulted to `OPENAI_MODEL_NAME` if not set; falls back to <DefaultLLMModel />.
@@ -168,6 +152,22 @@ const model = new OpenAIModel({
});
```
</Tab>
<Tab value="ENV">
`deepeval` by default uses OpenAI models for evaluations, you can simply pass the name of your desired model in metric initialization and set the `OPENAI_API_KEY` to use OpenAI models:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
const answerRelevancy = new AnswerRelevancyMetric({
model: "gpt-4.1",
});
```
</Tab>
</Tabs>
</Case>
</Switch>
+35 -35
View File
@@ -62,24 +62,6 @@ model = OpenRouterModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
</Tab>
<Tab value="ENV">
To use any OpenRouter model directly in `deepeval`, set the `USE_OPENROUTER_MODEL=1` in your `env` and simply pass the name of your desired model in your metric initialization:
```python
from deepeval.metrics import AnswerRelevancyMetric
answer_relevancy = AnswerRelevancyMetric(
model="openai/gpt-4.1",
)
```
You should also set the other necessary vars like `OPENROUTER_API_KEY` to be able to use the OpenRouter models as shown above.
</Tab>
</Tabs>
There are **ZERO** mandatory and **SEVEN** optional parameters when creating an `OpenRouterModel`:
- [Optional] `model`: A string specifying the OpenRouter model to use. Defaults to `OPENROUTER_MODEL_NAME` if set; otherwise falls back to <DefaultLLMModel provider="openrouter" />.
@@ -107,6 +89,24 @@ model = OpenRouterModel(
```
:::
</Tab>
<Tab value="ENV">
To use any OpenRouter model directly in `deepeval`, set the `USE_OPENROUTER_MODEL=1` in your `env` and simply pass the name of your desired model in your metric initialization:
```python
from deepeval.metrics import AnswerRelevancyMetric
answer_relevancy = AnswerRelevancyMetric(
model="openai/gpt-4.1",
)
```
You should also set the other necessary vars like `OPENROUTER_API_KEY` to be able to use the OpenRouter models as shown above.
</Tab>
</Tabs>
</Case>
<Case id="typescript">
@@ -133,23 +133,6 @@ const model = new OpenRouterModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
</Tab>
<Tab value="ENV">
To use any OpenRouter model directly in `deepeval`, set `OPENROUTER_MODEL_NAME` and `OPENROUTER_API_KEY` in your `env` and create an `OpenRouterModel` with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { OpenRouterModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new OpenRouterModel(),
});
```
</Tab>
</Tabs>
There are **ZERO** mandatory and **SEVEN** optional parameters when creating an `OpenRouterModel`:
- [Optional] `model`: A string specifying the OpenRouter model to use. Defaults to `OPENROUTER_MODEL_NAME` if set; otherwise falls back to <DefaultLLMModel provider="openrouter" />.
@@ -187,6 +170,23 @@ const model = new OpenRouterModel({
```
:::
</Tab>
<Tab value="ENV">
To use any OpenRouter model directly in `deepeval`, set `OPENROUTER_MODEL_NAME` and `OPENROUTER_API_KEY` in your `env` and create an `OpenRouterModel` with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { OpenRouterModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new OpenRouterModel(),
});
```
</Tab>
</Tabs>
</Case>
</Switch>
+32 -32
View File
@@ -58,6 +58,21 @@ model = PortkeyModel(
answer_relevancy = AnswerRelevancyMetric(model=model)
```
There are **ZERO** mandatory and **SIX** optional parameters when creating a `PortkeyModel`:
- [Optional] `model`: A string specifying the name of the Portkey model to use. Defaults to `PORTKEY_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying your Portkey API key for authentication. Defaults to `PORTKEY_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `provider`: A string specifying the Portkey provider of your model. Defaults to `PORTKEY_PROVIDER_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `base_url`: A string specifying the base URL for the model API. Defaults to `PORTKEY_BASE_URL` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to Portkey's `chat.completions.create(...)` call.
Any additional `**kwargs` you would like to use for your `Portkey` client can be passed directly to `PortkeyModel(...)`. These are forwarded to the underlying OpenAI client constructor. We recommend double-checking the parameters supported by your chosen model and provider in the [official Portkey docs](https://portkey.ai/docs).
:::tip
Pass generation parameters such as `max_tokens` via `generation_kwargs` (they are forwarded to `chat.completions.create(...)`).
:::
</Tab>
<Tab value="ENV">
@@ -76,21 +91,6 @@ You should also set the other necessary vars like `PORTKEY_API_KEY` to be able t
</Tab>
</Tabs>
There are **ZERO** mandatory and **SIX** optional parameters when creating a `PortkeyModel`:
- [Optional] `model`: A string specifying the name of the Portkey model to use. Defaults to `PORTKEY_MODEL_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `api_key`: A string specifying your Portkey API key for authentication. Defaults to `PORTKEY_API_KEY` if not passed; raises an error at runtime if unset.
- [Optional] `provider`: A string specifying the Portkey provider of your model. Defaults to `PORTKEY_PROVIDER_NAME` if not passed; raises an error at runtime if unset.
- [Optional] `base_url`: A string specifying the base URL for the model API. Defaults to `PORTKEY_BASE_URL` if not passed; raises an error at runtime if unset.
- [Optional] `temperature`: A float specifying the model temperature. Defaults to `TEMPERATURE` if not passed; falls back to `0.0` if unset.
- [Optional] `generation_kwargs`: A dictionary of additional generation parameters forwarded to Portkey's `chat.completions.create(...)` call.
Any additional `**kwargs` you would like to use for your `Portkey` client can be passed directly to `PortkeyModel(...)`. These are forwarded to the underlying OpenAI client constructor. We recommend double-checking the parameters supported by your chosen model and provider in the [official Portkey docs](https://portkey.ai/docs).
:::tip
Pass generation parameters such as `max_tokens` via `generation_kwargs` (they are forwarded to `chat.completions.create(...)`).
:::
</Case>
<Case id="typescript">
@@ -112,23 +112,6 @@ const model = new PortkeyModel({
const answerRelevancy = new AnswerRelevancyMetric({ model });
```
</Tab>
<Tab value="ENV">
To use any Portkey model directly in `deepeval`, set `PORTKEY_MODEL_NAME`, `PORTKEY_PROVIDER_NAME`, and `PORTKEY_API_KEY` in your `env` and create a `PortkeyModel` with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { PortkeyModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new PortkeyModel(),
});
```
</Tab>
</Tabs>
There are **ZERO** mandatory and **EIGHT** optional parameters when creating a `PortkeyModel`:
- [Optional] `model`: A string specifying the name of the Portkey model to use. Defaults to `PORTKEY_MODEL_NAME` if not passed; raises an error at runtime if unset.
@@ -152,6 +135,23 @@ const model = new PortkeyModel({
We recommend double-checking the parameters supported by your chosen model and provider in the [official Portkey docs](https://portkey.ai/docs).
</Tab>
<Tab value="ENV">
To use any Portkey model directly in `deepeval`, set `PORTKEY_MODEL_NAME`, `PORTKEY_PROVIDER_NAME`, and `PORTKEY_API_KEY` in your `env` and create a `PortkeyModel` with no arguments:
```typescript
import { AnswerRelevancyMetric } from "deepeval/metrics";
import { PortkeyModel } from "deepeval/models";
const answerRelevancy = new AnswerRelevancyMetric({
model: new PortkeyModel(),
});
```
</Tab>
</Tabs>
</Case>
</Switch>

Some files were not shown because too many files have changed in this diff Show More