diff --git a/docs/getting-started.md b/docs/getting-started.md index 405f5f4..d5b0d06 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -24,19 +24,41 @@ dspy-cli --version ## 2. Create Project +### Interactive Mode (Recommended) + ```bash -dspy-cli new email-subject -s "body, sender, context -> subject, tone, priority" -cd email-subject +dspy-cli new ``` +You'll be prompted for: +- **Project name:** `email-subject` +- **First program name:** `email_subject` (default, or customize) +- **Module type:** Choose from Predict, ChainOfThought, ReAct, etc. +- **Signature:** `body, sender, context -> subject, tone, priority` + - Type `?` for guided field-by-field input +- **Model:** `openai/gpt-4o-mini` (or any LiteLLM-compatible model) +- **API Key:** Enter your key or press Enter to configure later + **Expected output:** ``` -✓ Created project structure -✓ Generated signature: EmailSubjectSignature -✓ Scaffolded module: EmailSubjectPredict +Creating new DSPy project: email-subject + Package name: email_subject + Initial program: email_subject + Module type: Predict + Signature: body, sender, context -> subject, tone, priority + Model: openai/gpt-4o-mini + +✓ Project created successfully! +``` + +### Non-Interactive Mode + +```bash +dspy-cli new email-subject -s "body, sender, context -> subject, tone, priority" +cd email-subject ``` -Configure environment: +Configure environment (if not done during creation): ```bash echo "OPENAI_API_KEY=sk-..." > .env diff --git a/docs/index.md b/docs/index.md index 0d83d89..53a1652 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,13 @@ ## Overview -dspy-cli is a deployment framework for LLM-backed application features. It generates standardized project structure and HTTP interfaces for DSPy modules, reducing setup time from hours to minutes. +`dspy-cli` helps you quickly create, evolve, and deploy [DSPy](https://dspy.ai/) programs. It generates a standardized project structure and HTTP interfaces for DSPy modules, reducing the time required to deploy an AI-powered endpoint. + +`dspy-cli` has three main functions: + +- `new`: Creates a new project, after walking you through a few questions. `new` sets up directory structure, an initial program, configuration, and a Dockerfile. +- `generate`: Creates additional programs, signatures, and modules. Running `generate signature` creates a signature and module according to any provided parameters, with the necessary import statements. +- `serve`: Stands up an HTTP API with endpoints for all modules and a web UI for calling them. The `serve` command auto-detects modules at run time and hot-reloads as files are updated. ## The Problem @@ -12,10 +18,10 @@ Embedding LLM-backed features into applications requires: - Exposing a stable HTTP interface - Wiring API keys and secrets - Implementing health checks and logging -- Configuring routing and auto-discovery +- Configuring routing - Setting up local development and testing infrastructure -This overhead blocks small-to-medium AI features from shipping. A DSPy module that takes 30 minutes to write can require 4+ hours of infrastructure work before it's usable in a browser extension, Notion plugin, or web application. +This overhead blocks small-to-medium AI features from shipping. A DSPy module that takes 30 minutes to write can require hours of infrastructure work before it's usable in a browser extension, Notion integration, chat plugin, or web application. ## What dspy-cli Provides @@ -26,13 +32,13 @@ This overhead blocks small-to-medium AI features from shipping. A DSPy module th **HTTP Interface** - FastAPI-based REST endpoints with automatic module discovery -- OpenAPI documentation and interactive testing UI -- Request/response validation via Pydantic models +- OpenAPI documentation and interactive testing UI {fix} +- Request/response validation via Pydantic models {fix} **Development Workflow** - Hot-reload server for rapid iteration - Built-in testing UI with form-based request construction -- Type-safe module signatures with validation +- Type-safe module signatures with validation {repetitive} **Deployment Infrastructure** - Production-ready Docker containers @@ -88,15 +94,16 @@ Modules in `src/*/modules/` are automatically registered as endpoints at `/{Modu # Install uv tool install dspy-cli -# Create project +# Create project (interactive mode - recommended) +dspy-cli new + +# Or with arguments dspy-cli new my-feature -s "text -> summary" -cd my-feature && uv sync -# Configure -echo "OPENAI_API_KEY=sk-..." > .env +cd my-feature && uv sync # Serve locally -dspy-cli serve --ui +dspy-cli serve ``` Access the API at `http://localhost:8000/{ModuleName}` and testing UI at `http://localhost:8000/`. diff --git a/src/dspy_cli/commands/generate.py b/src/dspy_cli/commands/generate.py index 9fd6c90..21ed5cb 100644 --- a/src/dspy_cli/commands/generate.py +++ b/src/dspy_cli/commands/generate.py @@ -6,19 +6,7 @@ from dspy_cli.config.validator import find_package_directory, validate_project_structure from dspy_cli.utils.signature_utils import parse_signature_string, to_class_name, build_forward_components - - -# Map of module type aliases to their canonical names and template files -MODULE_TYPES = { - "Predict": {"template": "module_predict.py.template", "suffix": "predict"}, - "ChainOfThought": {"template": "module_chain_of_thought.py.template", "suffix": "cot"}, - "CoT": {"template": "module_chain_of_thought.py.template", "suffix": "cot"}, - "ProgramOfThought": {"template": "module_program_of_thought.py.template", "suffix": "pot"}, - "PoT": {"template": "module_program_of_thought.py.template", "suffix": "pot"}, - "ReAct": {"template": "module_react.py.template", "suffix": "react"}, - "MultiChainComparison": {"template": "module_multi_chain_comparison.py.template", "suffix": "mcc"}, - "Refine": {"template": "module_refine.py.template", "suffix": "refine"}, -} +from dspy_cli.utils.constants import MODULE_TYPES @click.group(name="generate") diff --git a/src/dspy_cli/commands/new.py b/src/dspy_cli/commands/new.py index 790858e..dc81e80 100644 --- a/src/dspy_cli/commands/new.py +++ b/src/dspy_cli/commands/new.py @@ -6,10 +6,28 @@ import click from dspy_cli.utils.signature_utils import parse_signature_string, to_class_name, build_forward_components +from dspy_cli.utils.interactive import ( + prompt_project_name, + prompt_setup_first_program, + prompt_program_name, + prompt_module_type, + prompt_signature, + prompt_model, + prompt_api_key, + prompt_api_base, +) +from dspy_cli.utils.model_utils import ( + parse_model_string, + is_local_model, + detect_api_key, + generate_model_config, + get_provider_display_name, +) +from dspy_cli.utils.constants import MODULE_TYPES @click.command() -@click.argument("project_name") +@click.argument("project_name", required=False) @click.option( "--program-name", "-p", @@ -22,19 +40,37 @@ default=None, help='Inline signature string (e.g., "question -> answer" or "post -> tags: list[str]")', ) -def new(project_name, program_name, signature): +@click.option( + "--module-type", + "-m", + default=None, + help="DSPy module type (Predict, ChainOfThought, ReAct, etc.)", +) +@click.option( + "--model", + default=None, + help="LiteLLM model string (e.g., anthropic/claude-sonnet-4-5, openai/gpt-4o)", +) +def new(project_name, program_name, signature, module_type, model): """Create a new DSPy project with boilerplate structure. Creates a directory with PROJECT_NAME and sets up a complete DSPy project structure with example code, configuration files, and a git repository. - Example: + Interactive mode (recommended): + dspy-cli new + + Non-interactive mode: dspy-cli new my-project - dspy-cli new my-project -p custom_program + dspy-cli new my-project -p custom_program -m CoT dspy-cli new my-project -s "post -> tags: list[str]" - dspy-cli new my-project -p analyzer -s "text, context: list[str] -> summary" + dspy-cli new my-project -p analyzer -s "text -> summary" --model anthropic/claude-sonnet-4-5 """ + # Interactive prompts for missing parameters + if not project_name: + project_name = prompt_project_name() + # Validate project name if not project_name or not project_name.strip(): click.echo(click.style("Error: Project name cannot be empty", fg="red")) @@ -50,43 +86,124 @@ def new(project_name, program_name, signature): # Convert project name to Python package name (replace - with _, lowercase) package_name = project_name.replace("-", "_").lower() + # Determine if we should prompt for program details or use defaults + # Only ask in interactive mode (when program_name, module_type, and signature are all None) + customize_program = True + if program_name is None and module_type is None and signature is None: + customize_program = prompt_setup_first_program() + # Determine program name if program_name is None: - # Convert project-name to program_name - program_name = project_name.replace("-", "_") - else: - # Convert dashes to underscores in user-provided program name - original_program_name = program_name - program_name = program_name.replace("-", "_") - if original_program_name != program_name: - click.echo(f"Note: Converted program name '{original_program_name}' to '{program_name}' for Python compatibility") + if customize_program: + # Use default "my_program" instead of deriving from project name + program_name = prompt_program_name(default="my_program") + else: + # Use default without prompting + program_name = "my_program" + + # Convert to valid Python identifier (replace dashes and spaces with underscores) + original_program_name = program_name + program_name = program_name.replace("-", "_").replace(" ", "_") + + if original_program_name != program_name: + click.echo(f"Note: Converted program name '{original_program_name}' to '{program_name}' for Python compatibility") # Validate program name is a valid Python identifier if not program_name.replace("_", "").isalnum() or program_name[0].isdigit(): click.echo(click.style(f"Error: Program name '{program_name}' is not a valid Python identifier", fg="red")) raise click.Abort() - # Parse signature if provided + # Prompt for module type + if module_type is None: + if customize_program: + module_type = prompt_module_type(default="Predict") + else: + # Use default without prompting + module_type = "Predict" + elif module_type not in MODULE_TYPES: + click.echo(click.style(f"Error: Unknown module type '{module_type}'", fg="red")) + click.echo(f"Available: {', '.join(MODULE_TYPES.keys())}") + raise click.Abort() + + # Prompt for signature signature_fields = None - if signature: - signature_fields = parse_signature_string(signature) + if signature is None: + if customize_program: + signature, signature_fields = prompt_signature() + else: + # Use default without prompting + signature = "question:str -> answer:str" + signature_fields = None + else: + # Strip optional quotes from command-line signature + signature = signature.strip().strip('"').strip("'") + # Parse provided signature + try: + signature_fields = parse_signature_string(signature) + except Exception as e: + click.echo(click.style(f"Error parsing signature: {e}", fg="red")) + raise click.Abort() + + # Prompt for model + if model is None: + model = prompt_model(default="openai/gpt-5-mini") + + # Parse model string + model_info = parse_model_string(model) + provider = model_info['provider'] + provider_display = get_provider_display_name(provider) + + # Handle API key for non-local models + api_key_value = None + api_key_env_var = None + + if not is_local_model(provider): + # Detect existing API key + detected_key, env_var_name = detect_api_key(provider) + api_key_env_var = env_var_name + + # Prompt for API key (will ask to confirm if detected, or enter new one) + api_key_value = prompt_api_key(provider_display, env_var_name, detected_key) + else: + # For local models, optionally prompt for api_base + click.echo(click.style(f"Detected local model provider: {provider_display}", fg="green")) + + # Generate model configuration + model_config_dict = generate_model_config(model, api_key_value) - click.echo(f"Creating new DSPy project: {project_name}") + click.echo() + click.echo(f"Creating new DSPy project: {click.style(project_name, fg='green', bold=True)}") click.echo(f" Package name: {package_name}") click.echo(f" Initial program: {program_name}") - if signature: - click.echo(f" Signature: {signature}") + click.echo(f" Module type: {MODULE_TYPES[module_type]['display_name']}") + click.echo(f" Signature: {signature}") + click.echo(f" Model: {model}") click.echo() try: # Create directory structure _create_directory_structure(project_path, package_name, program_name) - # Create configuration files - _create_config_files(project_path, project_name, program_name, package_name) + # Create configuration files with model config + _create_config_files( + project_path, + project_name, + program_name, + package_name, + model_config_dict, + api_key_value, + api_key_env_var + ) - # Create Python code files - _create_code_files(project_path, package_name, program_name, signature, signature_fields) + # Create Python code files with module type + _create_code_files( + project_path, + package_name, + program_name, + signature, + signature_fields, + module_type + ) # Initialize git repository _initialize_git(project_path) @@ -95,7 +212,8 @@ def new(project_name, program_name, signature): click.echo() click.echo("Next steps:") click.echo(f" cd {project_name}") - click.echo(" # Edit .env and add your API keys") + if not api_key_value and not is_local_model(provider): + click.echo(f" # Add your {api_key_env_var} to .env") click.echo(" uv sync") click.echo(" source .venv/bin/activate") click.echo(" dspy-cli serve") @@ -127,7 +245,7 @@ def _create_directory_structure(project_path, package_name, program_name): directory.mkdir(parents=True, exist_ok=True) click.echo(f" Created: {directory.relative_to(project_path.parent)}") -def _create_config_files(project_path, project_name, program_name, package_name): +def _create_config_files(project_path, project_name, program_name, package_name, model_config_dict, api_key_value, api_key_env_var): """Create configuration files from templates.""" from dspy_cli.templates import code_templates @@ -139,9 +257,27 @@ def _create_config_files(project_path, project_name, program_name, package_name) (project_path / "pyproject.toml").write_text(pyproject_content) click.echo(f" Created: {project_name}/pyproject.toml") + # Generate model alias (e.g., "openai:gpt-4o-mini" from "openai/gpt-4o-mini") + model_full = model_config_dict['model'] + model_alias = model_full.replace('/', ':') + + # Format model config as YAML with proper indentation + model_config_lines = [] + for key, value in model_config_dict.items(): + if isinstance(value, str): + model_config_lines.append(f" {key}: {value}") + else: + model_config_lines.append(f" {key}: {value}") + model_config_yaml = "\n".join(model_config_lines) + # Read and write dspy.config.yaml config_template = (templates_dir / "dspy.config.yaml.template").read_text() - config_content = config_template.format(app_id=project_name) + config_content = config_template.format( + app_id=project_name, + default_model_alias=model_alias, + model_alias=model_alias, + model_config=model_config_yaml + ) (project_path / "dspy.config.yaml").write_text(config_content) click.echo(f" Created: {project_name}/dspy.config.yaml") @@ -155,9 +291,22 @@ def _create_config_files(project_path, project_name, program_name, package_name) (project_path / ".dockerignore").write_text(dockerignore_template) click.echo(f" Created: {project_name}/.dockerignore") + # Generate .env content + if api_key_env_var: + if api_key_value: + # User provided API key + api_key_config = f"# {api_key_env_var} for your LLM provider\n{api_key_env_var}={api_key_value}" + else: + # User skipped API key, write placeholder + api_key_config = f"# {api_key_env_var} for your LLM provider\n# Add your API key here\n{api_key_env_var}=" + else: + # Local model, no API key needed + api_key_config = "# No API key required for local models" + # Read and write .env env_template = (templates_dir / "env.template").read_text() - (project_path / ".env").write_text(env_template) + env_content = env_template.format(api_key_config=api_key_config) + (project_path / ".env").write_text(env_content) click.echo(f" Created: {project_name}/.env") # Read and write README.md @@ -175,7 +324,7 @@ def _create_config_files(project_path, project_name, program_name, package_name) (project_path / ".gitignore").write_text(gitignore_template) click.echo(f" Created: {project_name}/.gitignore") -def _create_code_files(project_path, package_name, program_name, signature, signature_fields): +def _create_code_files(project_path, package_name, program_name, signature, signature_fields, module_type): """Create Python code files from templates.""" from dspy_cli.templates import code_templates @@ -218,9 +367,25 @@ def _create_code_files(project_path, package_name, program_name, signature, sign (project_path / "src" / package_name / "signatures" / f"{file_name}.py").write_text(signature_content) - # Create module file - module_class = f"{to_class_name(program_name)}Predict" - module_file = f"{file_name}_predict" + # Get module info from constants + module_info = MODULE_TYPES[module_type] + + # Determine module class name based on module type + if module_type in ["CoT", "ChainOfThought"]: + class_suffix = "CoT" + elif module_type in ["PoT", "ProgramOfThought"]: + class_suffix = "PoT" + elif module_type == "ReAct": + class_suffix = "ReAct" + elif module_type == "Refine": + class_suffix = "Refine" + elif module_type == "MultiChainComparison": + class_suffix = "MCC" + else: + class_suffix = "Predict" + + module_class = f"{to_class_name(program_name)}{class_suffix}" + module_file = f"{file_name}_{module_info['suffix']}" # Build forward method components from signature fields # If no signature was provided, use default fields (question: str -> answer: str) @@ -230,7 +395,8 @@ def _create_code_files(project_path, package_name, program_name, signature, sign } forward_components = build_forward_components(fields_for_forward) - module_template = (templates_dir / "module_predict.py.template").read_text() + # Use the appropriate module template + module_template = (templates_dir / module_info['template']).read_text() module_content = module_template.format( package_name=package_name, program_name=file_name, diff --git a/src/dspy_cli/templates/dspy.config.yaml.template b/src/dspy_cli/templates/dspy.config.yaml.template index 4f0c6e2..4774822 100644 --- a/src/dspy_cli/templates/dspy.config.yaml.template +++ b/src/dspy_cli/templates/dspy.config.yaml.template @@ -4,33 +4,13 @@ app_id: {app_id} models: # The default model to use if no per-program override is specified - default: openai:gpt-5-mini + default: {default_model_alias} # Model registry - define all available models here registry: - openai:gpt-5-mini: - model: openai/gpt-5-mini - env: OPENAI_API_KEY - max_tokens: 16000 - temperature: 1.0 - model_type: chat # 'responses' is available for OpenAI - - # Example: Add more models as needed - # anthropic:sonnet-4-5: - # model: anthropic/claude-sonnet-4-5 - # env: ANTHROPIC_API_KEY - # max_tokens: 8192 - # temperature: 0.7 - # model_type: chat - - # Example: Custom API base (for local models, proxies, etc.) - # local:llama: - # model: ollama/llama3 - # api_base: http://localhost:11434 - # max_tokens: 4096 - # temperature: 0.7 - # model_type: chat + {model_alias}: +{model_config} # Optional: Override the model for specific programs # program_models: -# MySpecialProgram: anthropic:sonnet-4-5 +# MySpecialProgram: {default_model_alias} diff --git a/src/dspy_cli/templates/env.template b/src/dspy_cli/templates/env.template index b51a295..1f09c3d 100644 --- a/src/dspy_cli/templates/env.template +++ b/src/dspy_cli/templates/env.template @@ -1,12 +1,4 @@ # API Keys and Secrets # Add your API keys here - this file is automatically added to .gitignore -# OpenAI -OPENAI_API_KEY= - -# Anthropic -# ANTHROPIC_API_KEY= - -# Other providers -# COHERE_API_KEY= -# TOGETHER_API_KEY= +{api_key_config} diff --git a/src/dspy_cli/utils/constants.py b/src/dspy_cli/utils/constants.py new file mode 100644 index 0000000..f4104a0 --- /dev/null +++ b/src/dspy_cli/utils/constants.py @@ -0,0 +1,56 @@ +"""Shared constants for DSPy CLI.""" + +# Map of module type aliases to their canonical names, template files, and descriptions +MODULE_TYPES = { + "Predict": { + "template": "module_predict.py.template", + "suffix": "predict", + "description": "Basic prediction module", + "display_name": "Predict" + }, + "ChainOfThought": { + "template": "module_chain_of_thought.py.template", + "suffix": "cot", + "description": "Step-by-step reasoning with chain of thought", + "display_name": "ChainOfThought (CoT)" + }, + "CoT": { + "template": "module_chain_of_thought.py.template", + "suffix": "cot", + "description": "Step-by-step reasoning with chain of thought", + "display_name": "ChainOfThought (CoT)" + }, + "ProgramOfThought": { + "template": "module_program_of_thought.py.template", + "suffix": "pot", + "description": "Generates and executes code for reasoning", + "display_name": "ProgramOfThought (PoT)" + }, + "PoT": { + "template": "module_program_of_thought.py.template", + "suffix": "pot", + "description": "Generates and executes code for reasoning", + "display_name": "ProgramOfThought (PoT)" + }, + "ReAct": { + "template": "module_react.py.template", + "suffix": "react", + "description": "Reasoning and acting with tools", + "display_name": "ReAct" + }, + "MultiChainComparison": { + "template": "module_multi_chain_comparison.py.template", + "suffix": "mcc", + "description": "Compare multiple reasoning paths", + "display_name": "MultiChainComparison" + }, + "Refine": { + "template": "module_refine.py.template", + "suffix": "refine", + "description": "Iterative refinement of outputs", + "display_name": "Refine" + }, +} + +# Get unique module types for interactive selection (exclude aliases) +UNIQUE_MODULE_TYPES = ["Predict", "ChainOfThought", "ProgramOfThought", "ReAct", "MultiChainComparison", "Refine"] diff --git a/src/dspy_cli/utils/interactive.py b/src/dspy_cli/utils/interactive.py new file mode 100644 index 0000000..990b2c9 --- /dev/null +++ b/src/dspy_cli/utils/interactive.py @@ -0,0 +1,304 @@ +"""Interactive prompt utilities for DSPy CLI.""" + +import click +from dspy_cli.utils.constants import UNIQUE_MODULE_TYPES, MODULE_TYPES +from dspy_cli.utils.signature_utils import parse_signature_string + + +def prompt_project_name(default: str | None = None) -> str: + """Prompt user for project name. + + Args: + default: Default value to show in prompt + + Returns: + Project name entered by user + """ + default_text = default or "my-project" + + project_name = click.prompt( + click.style("What is your project name?", fg="cyan"), + default=default_text, + type=str + ) + + return project_name.strip() + + +def prompt_setup_first_program() -> bool: + """Ask user if they want to specify their first program. + + Returns: + True if user wants to customize, False to use defaults + """ + return click.confirm( + click.style("Would you like to specify your first program?", fg="cyan"), + default=True + ) + + +def prompt_program_name(default: str | None = None) -> str: + """Prompt user for their first program name. + + Args: + default: Default value to show in prompt + + Returns: + Program name entered by user + """ + if not default: + default = "my_program" + + program_name = click.prompt( + click.style("What is the name of your first DSPy program?", fg="cyan"), + default=default, + type=str + ) + + return program_name.strip() + + +def prompt_module_type(default: str | None = None) -> str: + """Prompt user to select a module type. + + Args: + default: Default module type (defaults to "Predict") + + Returns: + Selected module type + """ + if not default: + default = "Predict" + + click.echo(click.style("Choose a module type:", fg="cyan")) + + # Display module options with descriptions + for i, module_type in enumerate(UNIQUE_MODULE_TYPES, 1): + module_info = MODULE_TYPES[module_type] + display = module_info['display_name'] + desc = module_info['description'] + + if module_type == default: + click.echo(f" {i}. {click.style(display, fg='green', bold=True)} - {desc} (default)") + else: + click.echo(f" {i}. {display} - {desc}") + + # Get user selection + while True: + choice = click.prompt( + "Enter number or name", + default="1" if default == "Predict" else default, + type=str + ) + + # Try to parse as number + try: + choice_num = int(choice) + if 1 <= choice_num <= len(UNIQUE_MODULE_TYPES): + return UNIQUE_MODULE_TYPES[choice_num - 1] + except ValueError: + # Not a number, try as module name + # Check if it matches any module type (case-insensitive) + for module_type in MODULE_TYPES.keys(): + if choice.lower() == module_type.lower(): + # Return the canonical name (not the alias) + if module_type in UNIQUE_MODULE_TYPES: + return module_type + # If it's an alias, return the canonical version + elif module_type == "CoT": + return "ChainOfThought" + elif module_type == "PoT": + return "ProgramOfThought" + + click.echo(click.style("Invalid choice. Please enter a number (1-6) or module name.", fg="red")) + + +def prompt_signature_guided() -> dict: + """Prompt user to build a signature field-by-field. + + Returns: + Signature fields dict with 'inputs' and 'outputs' lists + """ + click.echo(click.style("Let's build your signature step by step.", fg="cyan")) + + inputs = [] + outputs = [] + + # Prompt for input fields + click.echo(click.style("Input fields:", fg="yellow")) + while True: + field_name = click.prompt(" Field name (or press Enter to finish inputs)", default="", show_default=False) + if not field_name: + break + + field_type = click.prompt(" Field type", default="str") + inputs.append({'name': field_name.strip(), 'type': field_type.strip()}) + + if not inputs: + # Default to question:str if no inputs provided + inputs.append({'name': 'question', 'type': 'str'}) + click.echo(click.style(" Using default input: question:str", fg="yellow")) + + # Prompt for output fields + click.echo(click.style("Output fields:", fg="yellow")) + while True: + field_name = click.prompt(" Field name (or press Enter to finish outputs)", default="", show_default=False) + if not field_name: + break + + field_type = click.prompt(" Field type", default="str") + outputs.append({'name': field_name.strip(), 'type': field_type.strip()}) + + if not outputs: + # Default to answer:str if no outputs provided + outputs.append({'name': 'answer', 'type': 'str'}) + click.echo(click.style(" Using default output: answer:str", fg="yellow")) + return {'inputs': inputs, 'outputs': outputs} + + +def prompt_signature(default: str | None = None) -> tuple[str, dict | None]: + """Prompt user for signature string with option for guided input. + + Args: + default: Default signature string + + Returns: + Tuple of (signature_string, signature_fields_dict or None) + If user enters '?' or 'help', enters guided mode and returns (generated_string, fields_dict) + """ + if not default: + default = "question:str -> answer:str" + + click.echo(click.style("Enter your signature or type '?' for guided input:", fg="cyan")) + click.echo(click.style(" Examples: 'question -> answer', 'post:str -> tags:list[str], category:str'", fg="bright_black")) + + signature = click.prompt( + "Signature", + default=default, + type=str + ) + + # Strip both whitespace and optional quotes + signature = signature.strip().strip('"').strip("'") + + # Check for help/guided mode + if signature.lower() in ['?', 'help', 'guide', 'guided']: + signature_fields = prompt_signature_guided() + + # Build signature string from fields + input_parts = [f"{f['name']}:{f['type']}" if f['type'] != 'str' else f['name'] + for f in signature_fields['inputs']] + output_parts = [f"{f['name']}:{f['type']}" if f['type'] != 'str' else f['name'] + for f in signature_fields['outputs']] + + signature_str = f"{', '.join(input_parts)} -> {', '.join(output_parts)}" + click.echo(click.style(f"Generated signature: {signature_str}", fg="green")) + return signature_str, signature_fields + + # Try to parse the signature + try: + signature_fields = parse_signature_string(signature) + return signature, signature_fields + except Exception as e: + click.echo(click.style(f"Warning: Could not parse signature: {e}", fg="yellow")) + click.echo(click.style("Using default signature instead.", fg="yellow")) + return default, None + + +def prompt_model(default: str | None = None) -> str: + """Prompt user for model string. + + Args: + default: Default model string + + Returns: + Model string (e.g., "anthropic/claude-sonnet-4-5") + """ + if not default: + default = "openai/gpt-5-mini" + + click.echo(click.style("Enter your model (LiteLLM format):", fg="cyan")) + click.echo(click.style(" Examples: 'anthropic/claude-sonnet-4-5', 'openai/gpt-4o', 'ollama/llama2'", fg="bright_black")) + + model = click.prompt( + "Model", + default=default, + type=str + ) + + return model.strip() + + +def prompt_api_key(provider_display: str, env_var_name: str, detected_key: str | None = None) -> str | None: + """Prompt user for API key. + + Args: + provider_display: User-friendly provider name (e.g., "Anthropic") + env_var_name: Environment variable name (e.g., "ANTHROPIC_API_KEY") + detected_key: Pre-detected API key from environment (if any) + + Returns: + API key entered by user, or None if skipped + """ + if detected_key: + # Mask the key for display + if len(detected_key) > 8: + masked_key = detected_key[:8] + "..." + detected_key[-4:] + else: + masked_key = "***" + + click.echo(click.style(f"Found {env_var_name} in environment: {masked_key}", fg="green")) + + # Ask if they want to use the detected key or enter a new one + use_detected = click.confirm( + "Proceed with this API key?", + default=True + ) + + if use_detected: + return detected_key + # If they don't want to use the detected key, fall through to prompt for a new one + + # Prompt for API key + click.echo(click.style(f"Enter your {provider_display} API key:", fg="cyan")) + click.echo(click.style(f" (This will be stored in .env as {env_var_name})", fg="bright_black")) + click.echo(click.style(f" Press Enter to skip and set it manually later", fg="bright_black")) + + api_key = click.prompt( + f"{env_var_name}", + default="", + show_default=False, + hide_input=True, # Hide API key input + type=str + ) + + if not api_key: + return None + + return api_key.strip() + + +def prompt_api_base(provider: str) -> str | None: + """Prompt user for custom API base URL (for local models). + + Args: + provider: Provider name + + Returns: + API base URL or None + """ + click.echo(click.style(f"Enter custom API base URL for {provider} (optional):", fg="cyan")) + click.echo(click.style(" Example for Ollama: 'http://localhost:11434'", fg="bright_black")) + click.echo(click.style(" Press Enter to skip", fg="bright_black")) + + api_base = click.prompt( + "API Base URL", + default="", + show_default=False, + type=str + ) + + if not api_base: + return None + + return api_base.strip() diff --git a/src/dspy_cli/utils/model_utils.py b/src/dspy_cli/utils/model_utils.py new file mode 100644 index 0000000..b81ac97 --- /dev/null +++ b/src/dspy_cli/utils/model_utils.py @@ -0,0 +1,163 @@ +"""Utilities for working with LLM model providers.""" + +import os +import re + + +def parse_model_string(model_str: str) -> dict: + """Parse a LiteLLM model string into provider and model components. + + Args: + model_str: Model string in format "provider/model" (e.g., "anthropic/claude-sonnet-4-5") + + Returns: + Dict with 'provider' and 'model' keys + + Examples: + >>> parse_model_string("anthropic/claude-sonnet-4-5") + {'provider': 'anthropic', 'model': 'claude-sonnet-4-5', 'full': 'anthropic/claude-sonnet-4-5'} + + >>> parse_model_string("openai/gpt-4o") + {'provider': 'openai', 'model': 'gpt-4o', 'full': 'openai/gpt-4o'} + """ + if '/' not in model_str: + # If no provider specified, assume openai + return { + 'provider': 'openai', + 'model': model_str, + 'full': f'openai/{model_str}' + } + + parts = model_str.split('/', 1) + return { + 'provider': parts[0], + 'model': parts[1], + 'full': model_str + } + + +def is_local_model(provider: str) -> bool: + """Check if a provider is a local model provider that doesn't need API keys. + + Args: + provider: Provider name (e.g., "ollama", "vllm", "anthropic") + + Returns: + True if provider is local and doesn't need API keys + """ + local_providers = ['ollama', 'vllm', 'lmstudio', 'text-generation-inference', 'tgi'] + return provider.lower() in local_providers + + +def detect_api_key(provider: str) -> tuple[str | None, str]: + """Detect API key environment variable for a given provider. + + Args: + provider: Provider name (e.g., "anthropic", "openai") + + Returns: + Tuple of (api_key_value or None, env_var_name) + + Examples: + >>> detect_api_key("anthropic") + ("sk-ant-...", "ANTHROPIC_API_KEY") # if set + + >>> detect_api_key("openai") + (None, "OPENAI_API_KEY") # if not set + """ + provider_lower = provider.lower() + + # Map of common provider names to their standard env var patterns + provider_env_map = { + 'anthropic': 'ANTHROPIC_API_KEY', + 'openai': 'OPENAI_API_KEY', + 'cohere': 'COHERE_API_KEY', + 'together': 'TOGETHER_API_KEY', + 'together_ai': 'TOGETHER_API_KEY', + 'togetherai': 'TOGETHER_API_KEY', + 'google': 'GOOGLE_API_KEY', + 'gemini': 'GOOGLE_API_KEY', + 'groq': 'GROQ_API_KEY', + 'mistral': 'MISTRAL_API_KEY', + 'huggingface': 'HUGGINGFACE_API_KEY', + 'replicate': 'REPLICATE_API_KEY', + 'ai21': 'AI21_API_KEY', + 'bedrock': 'AWS_ACCESS_KEY_ID', # AWS uses different pattern + 'vertex_ai': 'GOOGLE_APPLICATION_CREDENTIALS', + } + + # Get the standard env var name for this provider + env_var_name = provider_env_map.get(provider_lower) + + # If not in our map, construct a standard pattern: {PROVIDER}_API_KEY + if not env_var_name: + env_var_name = f"{provider.upper()}_API_KEY" + + # Try to get the value from environment + api_key = os.getenv(env_var_name) + + return api_key, env_var_name + + +def generate_model_config(model_str: str, api_key: str | None, api_base: str | None = None) -> dict: + """Generate model configuration for dspy.config.yaml. + + Args: + model_str: Full model string (e.g., "anthropic/claude-sonnet-4-5") + api_key: API key value (or None for local models) + api_base: Optional custom API base URL (for local models or custom endpoints) + + Returns: + Dict with model configuration + """ + parsed = parse_model_string(model_str) + provider = parsed['provider'] + _, env_var_name = detect_api_key(provider) + + config = { + 'model': model_str, + 'model_type': 'chat', + 'max_tokens': 4096, + 'temperature': 1.0, + } + + # Add API key env var if not a local model + if not is_local_model(provider): + config['env'] = env_var_name + + # Add api_base if provided (for local models or custom endpoints) + if api_base: + config['api_base'] = api_base + + return config + + +def get_provider_display_name(provider: str) -> str: + """Get a user-friendly display name for a provider. + + Args: + provider: Provider name (e.g., "anthropic", "openai") + + Returns: + Display name (e.g., "Anthropic", "OpenAI") + """ + display_names = { + 'anthropic': 'Anthropic', + 'openai': 'OpenAI', + 'cohere': 'Cohere', + 'together': 'Together AI', + 'together_ai': 'Together AI', + 'togetherai': 'Together AI', + 'google': 'Google', + 'gemini': 'Google Gemini', + 'groq': 'Groq', + 'mistral': 'Mistral', + 'huggingface': 'Hugging Face', + 'replicate': 'Replicate', + 'ai21': 'AI21 Labs', + 'ollama': 'Ollama (local)', + 'vllm': 'vLLM (local)', + 'lmstudio': 'LM Studio (local)', + } + + return display_names.get(provider.lower(), provider.capitalize())