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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 32 additions & 21 deletions packages/deepctl-cmd-login/src/deepctl_cmd_login/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
DeepgramClient,
ProfileInfo,
ProfilesResult,
get_output_format,
)
from rich.console import Console
from rich.prompt import Prompt
Expand Down Expand Up @@ -751,6 +752,7 @@ def handle(
# Determine key and its exact storage source
api_key: str | None = None
key_source = "not set"
env_key = os.environ.get("DEEPGRAM_API_KEY")

try:
api_key = _keyring.get_password(KEYRING_SERVICE, f"api-key.{profile_name}")
Expand All @@ -760,14 +762,19 @@ def handle(
pass

if not api_key and profile_cfg.api_key:
# Config merges DEEPGRAM_API_KEY into the profile at load time
# (environment overrides the file), so a profile key equal to the
# environment value came from the environment, not the config
# file — label it accordingly instead of claiming "config file".
api_key = profile_cfg.api_key
key_source = "config file"

if not api_key:
env_key = os.environ.get("DEEPGRAM_API_KEY")
if env_key:
api_key = env_key
if env_key and profile_cfg.api_key == env_key:
key_source = "DEEPGRAM_API_KEY (env)"
else:
key_source = "config file"

if not api_key and env_key:
api_key = env_key
key_source = "DEEPGRAM_API_KEY (env)"

authenticated = api_key is not None
masked: str | None = None
Expand All @@ -777,21 +784,25 @@ def handle(
project_id = auth_manager.get_project_id()
base_url = profile_cfg.base_url or "https://api.deepgram.com"

if not authenticated:
console.print(
"[yellow]Not logged in.[/yellow] Run 'dg login' to authenticate."
)
else:
console.print("[green]✓[/green] Authenticated")
console.print(f" Profile: {profile_name}")
console.print(f" API Key: {masked} [dim]({key_source})[/dim]")
console.print(
f" Project ID: {project_id}"
if project_id
else " Project ID: [dim]not set[/dim]"
)
if base_url != "https://api.deepgram.com":
console.print(f" Base URL: {base_url}")
# Render the human block only in default mode. For json/yaml/csv the
# framework serialises the returned result to stdout, so printing the
# block here would corrupt that output for piping (the #97 pattern).
if get_output_format() == "default":
if not authenticated:
console.print(
"[yellow]Not logged in.[/yellow] Run 'dg login' to authenticate."
)
else:
console.print("[green]✓[/green] Authenticated")
console.print(f" Profile: {profile_name}")
console.print(f" API Key: {masked} [dim]({key_source})[/dim]")
console.print(
f" Project ID: {project_id}"
if project_id
else " Project ID: [dim]not set[/dim]"
)
if base_url != "https://api.deepgram.com":
console.print(f" Base URL: {base_url}")

return WhoamiResult(
authenticated=authenticated,
Expand Down
73 changes: 73 additions & 0 deletions packages/deepctl-cmd-login/tests/unit/test_login_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,76 @@ def test_proceeds_when_guided_and_tty(self):
mock_stdout.isatty.return_value = True
cmd._maybe_prompt_skills_setup()
mock_detect.assert_called_once()


class TestWhoamiKeySource:
"""The key-source label must name where the key actually came from.

Config merges DEEPGRAM_API_KEY into the profile at load time, so the
profile's api_key being set does not prove the key came from the config
file. whoami used to label an environment-sourced key "config file".
"""

@pytest.fixture
def whoami_command(self):
from deepctl_cmd_login.command import WhoamiCommand

return WhoamiCommand()

def _run(self, whoami_command, mock_config, mock_client, env, profile_key):
auth_manager = Mock(spec=AuthManager)
auth_manager.get_project_id.return_value = "proj-1"
mock_config.get_profile.return_value.api_key = profile_key
with (
patch.dict("os.environ", env, clear=False),
patch("keyring.get_password", return_value=None),
):
if "DEEPGRAM_API_KEY" not in env:
import os

os.environ.pop("DEEPGRAM_API_KEY", None)
return whoami_command.handle(
config=mock_config,
auth_manager=auth_manager,
client=mock_client,
)

def test_env_key_merged_into_profile_labeled_env(
self, whoami_command, mock_config, mock_client
):
"""A profile key equal to DEEPGRAM_API_KEY is labeled as env."""
result = self._run(
whoami_command,
mock_config,
mock_client,
env={"DEEPGRAM_API_KEY": "dg_env_key_12345"},
profile_key="dg_env_key_12345",
)
assert result.key_source == "DEEPGRAM_API_KEY (env)"
assert result.authenticated is True

def test_real_config_file_key_still_labeled_config_file(
self, whoami_command, mock_config, mock_client
):
"""A profile key with no matching env var keeps the config label."""
result = self._run(
whoami_command,
mock_config,
mock_client,
env={},
profile_key="dg_file_key_67890",
)
assert result.key_source == "config file"

def test_env_key_without_profile_labeled_env(
self, whoami_command, mock_config, mock_client
):
"""No profile key, env set: the env fallback branch labels env."""
result = self._run(
whoami_command,
mock_config,
mock_client,
env={"DEEPGRAM_API_KEY": "dg_env_only_11111"},
profile_key=None,
)
assert result.key_source == "DEEPGRAM_API_KEY (env)"
115 changes: 94 additions & 21 deletions packages/deepctl-cmd-models/src/deepctl_cmd_models/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,63 @@
# output that callers pipe into jq and friends.
status_console = get_status_console()

# Models the API still lists but which are legacy. The catalog flags them so
# that nobody — human or coding agent — treats them as a current model family
# or composes new model names from them. (Paying accounts have requested the
# nonexistent "nova-3-conversational", a natural composition of "nova-3" and
# the legacy name below.)
DEPRECATED_MODELS: dict[str, str] = {
"conversationalai": (
"Legacy model. For conversational audio use 'nova-3'. There is no "
"model named 'nova-3-conversational'."
),
"2-conversationalai": (
"Legacy model. For conversational audio use 'nova-3'. There is no "
"model named 'nova-3-conversational'."
),
}


def _build_model_info(m: dict[str, Any], model_type: str) -> ModelInfo:
"""Map one API model entry onto ModelInfo.

The Deepgram Python SDK's generated response classes rename the API's
``uuid`` field to ``uuid_`` (to avoid shadowing), so ``model_dump()``
emits ``uuid_`` — both spellings are read here. The API also reports
``languages`` as a list; the older singular ``language`` key is kept as
a fallback for compatibility.
"""
model_id = m.get("uuid_") or m.get("uuid") or m.get("model_id") or ""

raw_languages = m.get("languages") or []
if not isinstance(raw_languages, list):
raw_languages = [raw_languages]
languages = [str(lang) for lang in raw_languages]
primary_language = str(m.get("language") or (languages[0] if languages else ""))

name = str(m.get("name") or "")
deprecation_note = DEPRECATED_MODELS.get(name.lower(), "")

return ModelInfo(
model_id=str(model_id),
name=name,
canonical_name=str(m.get("canonical_name") or ""),
architecture=str(m.get("architecture") or ""),
version=str(m.get("version") or ""),
language=primary_language,
languages=languages,
model_type=model_type,
deprecated=bool(deprecation_note),
deprecation_note=deprecation_note,
)


def _format_languages(languages: list[str], limit: int = 4) -> str:
"""Join a language list for table display, truncating long lists."""
if len(languages) <= limit:
return ", ".join(languages)
return ", ".join(languages[:limit]) + f" +{len(languages) - limit}"


class ModelsCommand(BaseCommand):
"""Command for listing available Deepgram models."""
Expand All @@ -44,7 +101,9 @@ class ModelsCommand(BaseCommand):
agent_help = (
"List available Deepgram speech-to-text and text-to-speech models. "
"Filter by type (stt/tts) and optionally include outdated versions. "
"Requires authentication."
"Use the canonical_name field as the `model` request parameter. "
"Entries flagged deprecated are legacy: do not use them or derive "
"new model names from them. Requires authentication."
)

def get_arguments(self) -> list[dict[str, Any]]:
Expand Down Expand Up @@ -76,33 +135,32 @@ def handle(
try:
result = client.list_models(include_outdated=include_outdated)

stt_models = result.get("stt", [])
tts_models = result.get("tts", [])
# `or []` also covers an explicit null in the response body.
stt_models = result.get("stt") or []
tts_models = result.get("tts") or []

all_models: list[ModelInfo] = []

if model_type != "tts":
for m in stt_models:
all_models.append(
ModelInfo(
model_id=m.get("uuid", m.get("model_id", "")),
name=m.get("name", ""),
version=m.get("version", ""),
language=m.get("language", ""),
model_type="stt",
)
all_models.append(_build_model_info(m, "stt"))
if not stt_models:
status_console.print(
"[yellow]Warning: the API returned zero speech-to-text "
"models. Deepgram publishes speech-to-text models "
"(Nova-3, Flux), so an empty list usually means an API "
"or account problem, not an empty catalog.[/yellow]"
)

if model_type != "stt":
for m in tts_models:
all_models.append(
ModelInfo(
model_id=m.get("uuid", m.get("model_id", "")),
name=m.get("name", ""),
version=m.get("version", ""),
language=m.get("language", ""),
model_type="tts",
)
all_models.append(_build_model_info(m, "tts"))
if not tts_models:
status_console.print(
"[yellow]Warning: the API returned zero text-to-speech "
"models. Deepgram publishes text-to-speech models "
"(Aura-2), so an empty list usually means an API or "
"account problem, not an empty catalog.[/yellow]"
)

if not all_models:
Expand All @@ -117,19 +175,34 @@ def handle(
title="Deepgram Models", show_header=True, header_style="bold blue"
)
table.add_column("Name", style="green")
table.add_column("Canonical name", style="green")
table.add_column("Type", style="cyan")
table.add_column("Language")
table.add_column("Languages")
table.add_column("Version")
table.add_column("ID", style="dim")

for m in all_models:
display_name = m.name
if m.deprecated:
display_name = f"{m.name} [yellow](deprecated)[/yellow]"
table.add_row(
m.name, m.model_type.upper(), m.language, m.version, m.model_id
display_name,
m.canonical_name,
m.model_type.upper(),
_format_languages(m.languages),
m.version,
m.model_id,
)

console.print(table)
console.print(f"\n[dim]{len(all_models)} model(s) found[/dim]")

deprecated_notes = {
m.name: m.deprecation_note for m in all_models if m.deprecated
}
for name, note in sorted(deprecated_notes.items()):
console.print(f"[yellow]Deprecated:[/yellow] {name} — {note}")

return ModelsResult(
status="success",
models=all_models,
Expand Down
7 changes: 6 additions & 1 deletion packages/deepctl-cmd-models/src/deepctl_cmd_models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@
class ModelInfo(BaseModel):
model_id: str = ""
name: str = ""
canonical_name: str = "" # the value to pass as the `model` parameter
architecture: str = ""
version: str = ""
language: str = ""
language: str = "" # primary language (first entry of `languages`)
languages: list[str] = Field(default_factory=list)
model_type: str = "" # "stt" or "tts"
deprecated: bool = False
deprecation_note: str = ""


class ModelsResult(BaseResult):
Expand Down
Loading
Loading