fix(cli): make CLI output ASCII-only so it doesn't crash legacy Windows consoles (#33465)

* fix(cli): force UTF-8 output so emoji don't crash the CLI on Windows

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(cli): drop dead flush calls flagged by review

* fix(cli): replace non-ASCII CLI output with ASCII so legacy Windows consoles don't crash

---------

Co-authored-by: ryan <ryan@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-07-16 10:35:06 -07:00 • committed by GitHub
parent edc30ea515
commit 260d1eae8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 86 additions and 73 deletions

View file

@ -72,7 +72,7 @@ def display_teams_table(teams: List[Dict[str, Any]]) -> None:
console = Console()
if not teams:
console.print("❌ No teams found for your user.")
console.print("No teams found for your user.")
return
table = Table(title="Available Teams")
@ -162,7 +162,7 @@ def display_interactive_team_selection(teams: List[Dict[str, Any]], selected_ind
# Clear the screen using Rich's method
console.clear()
console.print("🎯 Select a Team (Use ↑↓ arrows, Enter to select, 'q' to skip):\n")
console.print("Select a Team (Use up/down arrows, Enter to select, 'q' to skip):\n")
for i, team in enumerate(teams):
team_alias = team.get("team_alias") or "N/A"
@ -184,7 +184,7 @@ def display_interactive_team_selection(teams: List[Dict[str, Any]], selected_ind
# Highlight the selected item
if i == selected_index:
console.print(f"➤ [bold cyan]{team_alias}[/bold cyan] ({team_id})")
console.print(f"> [bold cyan]{team_alias}[/bold cyan] ({team_id})")
console.print(f" Models: [yellow]{models_str}[/yellow]")
console.print(f" Budget: [blue]{budget_str}[/blue]\n")
else:
@ -220,15 +220,13 @@ def prompt_team_selection(teams: List[Dict[str, Any]]) -> Optional[Dict[str, Any
# Clear screen and show selection
console = Console()
console.clear()
click.echo(
f"✅ Selected team: {selected_team.get('team_alias', 'N/A')} ({selected_team.get('team_id')})"
)
click.echo(f"Selected team: {selected_team.get('team_alias', 'N/A')} ({selected_team.get('team_id')})")
return selected_team
elif key == "quit" or key == "escape":
# Clear screen
console = Console()
console.clear()
click.echo("ℹ️ Team selection skipped.")
click.echo("Team selection skipped.")
return None
elif key is None:
# If we can't get key input, fall back to simple selection
@ -237,7 +235,7 @@ def prompt_team_selection(teams: List[Dict[str, Any]]) -> Optional[Dict[str, Any
except KeyboardInterrupt:
console = Console()
console.clear()
click.echo("\n❌ Team selection cancelled.")
click.echo("\nTeam selection cancelled.")
return None
except Exception:
# If interactive mode fails, fall back to simple selection
@ -265,15 +263,15 @@ def prompt_team_selection_fallback(
if 0 <= index < len(teams):
selected_team = teams[index]
click.echo(
f"\n✅ Selected team: {selected_team.get('team_alias', 'N/A')} ({selected_team.get('team_id')})"
f"\nSelected team: {selected_team.get('team_alias', 'N/A')} ({selected_team.get('team_id')})"
)
return selected_team
else:
click.echo(f"❌ Invalid selection. Please enter a number between 1 and {len(teams)}")
click.echo(f"Invalid selection. Please enter a number between 1 and {len(teams)}")
except ValueError:
click.echo("❌ Invalid input. Please enter a number or 'skip'")
click.echo("Invalid input. Please enter a number or 'skip'")
except KeyboardInterrupt:
click.echo("\n❌ Team selection cancelled.")
click.echo("\nTeam selection cancelled.")
return None
@ -437,7 +435,7 @@ def _poll_for_authentication(base_url: str, key_id: str, poll_secret: str) -> Op
user_id = data.get("user_id")
normalized_teams: List[Dict[str, Any]] = _normalize_teams(teams, team_details)
if not normalized_teams:
click.echo("⚠️ No teams available for selection.")
click.echo("Warning: No teams available for selection.")
return None
# User has multiple teams - let them select
@ -457,7 +455,7 @@ def _poll_for_authentication(base_url: str, key_id: str, poll_secret: str) -> Op
"team_id": None, # Set by server in JWT
}
click.echo("❌ Team selection cancelled or JWT generation failed.")
click.echo("Team selection cancelled or JWT generation failed.")
return None
# JWT is ready (single team or team already selected)
@ -468,7 +466,7 @@ def _poll_for_authentication(base_url: str, key_id: str, poll_secret: str) -> Op
# Show which team was assigned
if team_id and len(teams) == 1:
click.echo(f"\n✅ Automatically assigned to team: {team_id}")
click.echo(f"\nAutomatically assigned to team: {team_id}")
if api_key:
return {
@ -494,19 +492,19 @@ def _handle_team_selection_during_polling(
The JWT token with the selected team, or None if selection was skipped
"""
if not teams:
click.echo("ℹ️ No teams found. You can create or join teams using the web interface.")
click.echo("No teams found. You can create or join teams using the web interface.")
return None
click.echo("\n" + "=" * 60)
click.echo("📋 Select a team for your CLI session...")
click.echo("Select a team for your CLI session...")
team_id = _render_and_prompt_for_team_selection(teams)
if not team_id:
click.echo("ℹ️ No team selected.")
click.echo("No team selected.")
return None
click.echo(f"\n🔄 Generating JWT for team: {team_id}")
click.echo(f"\nGenerating JWT for team: {team_id}")
poll_url = f"{base_url}/sso/cli/poll/{key_id}?team_id={team_id}"
data = _poll_for_ready_data(
@ -520,7 +518,7 @@ def _handle_team_selection_during_polling(
return None
jwt_token = data.get("key")
if jwt_token:
click.echo(f"✅ Successfully generated JWT for team: {team_id}")
click.echo(f"Successfully generated JWT for team: {team_id}")
return jwt_token
return None
@ -568,14 +566,14 @@ def _render_and_prompt_for_team_selection(teams: List[Dict[str, Any]]) -> Option
selected_team = teams[index]
team_id = str(selected_team.get("team_id"))
team_alias = selected_team.get("team_alias") or team_id
click.echo(f"\n✅ Selected team: {team_alias} ({team_id})")
click.echo(f"\nSelected team: {team_alias} ({team_id})")
return team_id
click.echo(f"❌ Invalid selection. Please enter a number between 1 and {len(teams)}")
click.echo(f"Invalid selection. Please enter a number between 1 and {len(teams)}")
except ValueError:
click.echo("❌ Invalid input. Please enter a number or 'skip'")
click.echo("Invalid input. Please enter a number or 'skip'")
except KeyboardInterrupt:
click.echo("\n❌ Team selection cancelled.")
click.echo("\nTeam selection cancelled.")
return None
@ -628,7 +626,7 @@ def login(ctx: click.Context):
}
)
click.echo("\n✅ Login successful!")
click.echo("\nLogin successful!")
click.echo(f"JWT Token: {api_key[:20]}...")
click.echo("You can now use the CLI without specifying --api-key")
@ -637,7 +635,7 @@ def login(ctx: click.Context):
show_commands()
return
else:
click.echo("❌ Authentication timed out. Please try again.")
click.echo("Authentication timed out. Please try again.")
click.echo(
"The proxy never reported the browser sign-in as finished. If you did complete it, "
"check the proxy logs for /sso/callback errors and confirm SSO is configured on the proxy."
@ -645,10 +643,10 @@ def login(ctx: click.Context):
return
except KeyboardInterrupt:
click.echo("\n❌ Authentication cancelled by user.")
click.echo("\nAuthentication cancelled by user.")
return
except Exception as e:
click.echo(f"❌ Authentication failed: {e}")
click.echo(f"Authentication failed: {e}")
return
@ -656,7 +654,7 @@ def login(ctx: click.Context):
def logout():
"""Logout and clear stored authentication"""
clear_token()
click.echo("✅ Logged out successfully. Authentication token cleared.")
click.echo("Logged out successfully. Authentication token cleared.")
@click.command(name="print-token")
@ -703,10 +701,10 @@ def whoami():
token_data = load_token()
if not token_data:
click.echo("❌ Not authenticated. Run 'lite login' to authenticate.")
click.echo("Not authenticated. Run 'lite login' to authenticate.")
return
click.echo("✅ Authenticated")
click.echo("Authenticated")
click.echo(f"User Email: {token_data.get('user_email', 'Unknown')}")
click.echo(f"User ID: {token_data.get('user_id', 'Unknown')}")
click.echo(f"User Role: {token_data.get('user_role', 'Unknown')}")
@ -717,7 +715,7 @@ def whoami():
click.echo(f"Token age: {age_hours:.1f} hours")
if age_hours > CLI_JWT_EXPIRATION_HOURS:
click.echo(f"⚠️ Warning: Token is more than {CLI_JWT_EXPIRATION_HOURS} hours old and may have expired.")
click.echo(f"Warning: Token is more than {CLI_JWT_EXPIRATION_HOURS} hours old and may have expired.")
@click.group(name="auth")

View file

@ -150,7 +150,7 @@ def chat(
f"Max Tokens: [yellow]{max_tokens or 'unlimited'}[/yellow]\n\n"
f"Type your messages and press Enter. Type '/quit' or '/exit' to end the session.\n"
f"Type '/help' for more commands.",
title="🤖 Chat Session",
title="Chat Session",
)
)

View file

@ -32,7 +32,7 @@ def migrate(ctx: click.Context, check_only: bool, dry_run: bool):
Requires the proxy to be started with
``general_settings.encryption_algorithm: aes-256-gcm``. Idempotent and
resumable — safe to re-run after an interruption.
resumable; safe to re-run after an interruption.
Examples:
litellm-proxy encryption migrate --check # attestation scan, no writes

View file

@ -309,12 +309,12 @@ def _import_keys_to_destination(
imported_count += 1
key_alias = key.get("key_alias", "N/A")
click.echo(f"✓ Imported key: {key_alias}")
click.echo(f"Imported key: {key_alias}")
except Exception as e:
failed_count += 1
key_alias = key.get("key_alias", "N/A")
click.echo(f"✗ Failed to import key {key_alias}: {str(e)}", err=True)
click.echo(f"Failed to import key {key_alias}: {str(e)}", err=True)
return imported_count, failed_count

View file

@ -21,7 +21,7 @@ def display_teams_table(teams: List[Dict[str, Any]]) -> None:
console = Console()
if not teams:
console.print("❌ No teams found for your user.")
console.print("No teams found for your user.")
return
table = Table(title="Available Teams")
@ -91,10 +91,10 @@ def available(ctx: click.Context):
teams = client.teams.get_available()
if teams:
console = Console()
console.print("\n🎯 Available Teams to Join:")
console.print("\nAvailable Teams to Join:")
display_teams_table(teams)
else:
click.echo("ℹ️ No available teams to join.")
click.echo("No available teams to join.")
except requests.exceptions.HTTPError as e:
click.echo(f"Error: HTTP {e.response.status_code}", err=True)
error_body = e.response.json()
@ -113,7 +113,7 @@ def assign_key(ctx: click.Context, team_id: Optional[str]):
api_key = ctx.obj["api_key"]
if not api_key:
click.echo("❌ No API key found. Please login first using 'litellm login'")
click.echo("No API key found. Please login first using 'litellm login'")
raise click.Abort()
try:
@ -122,7 +122,7 @@ def assign_key(ctx: click.Context, team_id: Optional[str]):
teams = client.teams.list()
if not teams:
click.echo("❌ No teams found for your user.")
click.echo("No teams found for your user.")
return
# Use interactive selection from auth module
@ -133,14 +133,14 @@ def assign_key(ctx: click.Context, team_id: Optional[str]):
if selected_team:
team_id = selected_team.get("team_id")
else:
click.echo("❌ Operation cancelled.")
click.echo("Operation cancelled.")
return
# Update the key with the selected team
if team_id:
click.echo(f"\n🔄 Assigning your key to team: {team_id}")
click.echo(f"\nAssigning your key to team: {team_id}")
client.keys.update(key=api_key, team_id=team_id)
click.echo(f"✅ Successfully assigned key to team: {team_id}")
click.echo(f"Successfully assigned key to team: {team_id}")
# Show team details if available
teams = client.teams.list()
@ -148,9 +148,9 @@ def assign_key(ctx: click.Context, team_id: Optional[str]):
if team.get("team_id") == team_id:
models = team.get("models", [])
if models:
click.echo(f"🎯 You can now access models: {', '.join(models)}")
click.echo(f"You can now access models: {', '.join(models)}")
else:
click.echo("🎯 You can now access all available models")
click.echo("You can now access all available models")
break
except requests.exceptions.HTTPError as e:

View file

@ -27,13 +27,13 @@ def styled_prompt():
verbose_logger.debug(f"Error getting terminal size: {e}")
click.echo("\n" * 3)
# Unicode box drawing characters
top_left = "┌"
top_right = "┐"
bottom_left = "└"
bottom_right = "┘"
horizontal = "─"
vertical = "│"
# ASCII box drawing characters
top_left = "+"
top_right = "+"
bottom_left = "+"
bottom_right = "+"
horizontal = "-"
vertical = "|"
# Create the box with increased width
width = 80

View file

@ -78,7 +78,7 @@ class TestPollingErrorSurfacing:
result = CliRunner().invoke(login, obj=mock_context.obj)
assert result.exit_code == 0
assert "❌ Authentication failed:" in result.output
assert "Authentication failed:" in result.output
assert "CLI login session not found or expired." in result.output
assert "Authentication timed out" not in result.output
@ -414,7 +414,7 @@ class TestLoginCommand:
result = self.runner.invoke(login, obj=mock_context.obj)
assert result.exit_code == 0
assert "✅ Login successful!" in result.output
assert "Login successful!" in result.output
assert "Automatically assigned to team: team-1" in result.output
# Verify browser was opened with correct URL
@ -456,7 +456,7 @@ class TestLoginCommand:
result = self.runner.invoke(login, obj=mock_context.obj)
assert result.exit_code == 0
assert "❌ Authentication timed out" in result.output
assert "Authentication timed out" in result.output
def test_login_http_error(self):
"""Test login with HTTP error"""
@ -476,7 +476,7 @@ class TestLoginCommand:
result = self.runner.invoke(login, obj=mock_context.obj)
assert result.exit_code == 0
assert "❌ Authentication timed out" in result.output
assert "Authentication timed out" in result.output
def test_login_request_exception(self):
"""Test login with request exception"""
@ -497,7 +497,7 @@ class TestLoginCommand:
result = self.runner.invoke(login, obj=mock_context.obj)
assert result.exit_code == 0
assert "❌ Authentication timed out" in result.output
assert "Authentication timed out" in result.output
def test_login_keyboard_interrupt(self):
"""Test login cancelled by user"""
@ -512,7 +512,7 @@ class TestLoginCommand:
result = self.runner.invoke(login, obj=mock_context.obj)
assert result.exit_code == 0
assert "❌ Authentication cancelled by user" in result.output
assert "Authentication cancelled by user" in result.output
def test_login_no_api_key_in_response(self):
"""Test login when response doesn't contain API key"""
@ -536,7 +536,7 @@ class TestLoginCommand:
result = self.runner.invoke(login, obj=mock_context.obj)
assert result.exit_code == 0
assert "❌ Authentication timed out" in result.output
assert "Authentication timed out" in result.output
def test_login_general_exception(self):
"""Test login with general exception (not requests exception)"""
@ -551,7 +551,7 @@ class TestLoginCommand:
result = self.runner.invoke(login, obj=mock_context.obj)
assert result.exit_code == 0
assert "❌ Authentication failed: Invalid value" in result.output
assert "Authentication failed: Invalid value" in result.output
class TestLogoutCommand:
@ -567,7 +567,7 @@ class TestLogoutCommand:
result = self.runner.invoke(logout)
assert result.exit_code == 0
assert "✅ Logged out successfully" in result.output
assert "Logged out successfully" in result.output
mock_clear.assert_called_once()
@ -591,7 +591,7 @@ class TestWhoamiCommand:
result = self.runner.invoke(whoami)
assert result.exit_code == 0
assert "✅ Authenticated" in result.output
assert "Authenticated" in result.output
assert "test@example.com" in result.output
assert "test-user-123" in result.output
assert "admin" in result.output
@ -603,7 +603,7 @@ class TestWhoamiCommand:
result = self.runner.invoke(whoami)
assert result.exit_code == 0
assert "❌ Not authenticated" in result.output
assert "Not authenticated" in result.output
assert "Run 'lite login'" in result.output
def test_whoami_old_token(self):
@ -619,8 +619,8 @@ class TestWhoamiCommand:
result = self.runner.invoke(whoami)
assert result.exit_code == 0
assert "✅ Authenticated" in result.output
assert "⚠️ Warning: Token is more than 24 hours old" in result.output
assert "Authenticated" in result.output
assert "Warning: Token is more than 24 hours old" in result.output
def test_whoami_missing_fields(self):
"""Test whoami with token missing some fields"""
@ -633,7 +633,7 @@ class TestWhoamiCommand:
result = self.runner.invoke(whoami)
assert result.exit_code == 0
assert "✅ Authenticated" in result.output
assert "Authenticated" in result.output
assert "Unknown" in result.output # Should show "Unknown" for missing fields
def test_whoami_no_timestamp(self):
@ -655,7 +655,7 @@ class TestWhoamiCommand:
result = self.runner.invoke(whoami)
assert result.exit_code == 0
assert "✅ Authenticated" in result.output
assert "Authenticated" in result.output
# Should calculate age based on timestamp=0
assert "Token age:" in result.output
@ -714,7 +714,7 @@ class TestCLIKeyRegenerationFlow:
result = self.runner.invoke(login, obj=mock_context.obj)
assert result.exit_code == 0
assert "✅ Login successful!" in result.output
assert "Login successful!" in result.output
assert "team-beta" in result.output
# Ensure we surface the human-readable team alias to the user
assert "Beta Team" in result.output
@ -774,7 +774,7 @@ class TestCLIKeyRegenerationFlow:
result = self.runner.invoke(login, obj=mock_context.obj)
assert result.exit_code == 0
assert "✅ Login successful!" in result.output
assert "Login successful!" in result.output
# Verify browser was opened
mock_browser.assert_called_once()

View file

@ -1,6 +1,7 @@
# stdlib imports
import os
import sys
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
@ -11,6 +12,7 @@ sys.path.insert(
) # Adds the parent directory to the system path
import litellm.proxy.client.cli
from litellm._version import version as litellm_version
from litellm.proxy.client.cli import cli
@ -36,6 +38,19 @@ def test_cli_version_flag(cli_runner):
assert "LiteLLM Proxy Server Version: 1.2.3" in result.output
def test_cli_source_is_ascii_only():
"""Non-ASCII output (emoji, box-drawing chars) raises UnicodeEncodeError on legacy Windows
consoles (cp1252), so the whole CLI package must stay ASCII-only."""
cli_root = Path(litellm.proxy.client.cli.__file__).parent
offenders = [
f"{path.relative_to(cli_root)}:{line_number}: {line.strip()}"
for path in sorted(cli_root.rglob("*.py"))
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1)
if not line.isascii()
]
assert offenders == []
def test_base_url_trailing_slash_normalized(cli_runner):
"""A trailing slash on --base-url must not produce a double slash (e.g. '//sso/cli/start')."""
with (

View file

@ -262,7 +262,7 @@ def test_keys_import_actual_import_success(mock_keys_client, cli_runner):
assert result.exit_code == 0
assert "Found 1 keys in source instance" in result.output
assert "✓ Imported key: import-key-1" in result.output
assert "Imported key: import-key-1" in result.output
assert "Successfully imported: 1" in result.output
assert "Failed to import: 0" in result.output
@ -481,8 +481,8 @@ def test_keys_import_partial_failure(mock_keys_client, cli_runner):
)
assert result.exit_code == 0 # Command completes even with partial failures
assert "✓ Imported key: success-key" in result.output
assert "✗ Failed to import key fail-key" in result.output
assert "Imported key: success-key" in result.output
assert "Failed to import key fail-key" in result.output
assert "Successfully imported: 1" in result.output
assert "Failed to import: 1" in result.output
assert "Total keys processed: 2" in result.output