diff --git a/cli/planoai/consts.py b/cli/planoai/consts.py index 5b137fa6..1c0400fb 100644 --- a/cli/planoai/consts.py +++ b/cli/planoai/consts.py @@ -1,5 +1,8 @@ import os +# Brand color - Plano purple +PLANO_COLOR = "#969FF4" + SERVICE_NAME_ARCHGW = "plano" PLANO_DOCKER_NAME = "plano" PLANO_DOCKER_IMAGE = os.getenv("PLANO_DOCKER_IMAGE", "katanemo/plano:0.4.2") diff --git a/cli/planoai/init_cmd.py b/cli/planoai/init_cmd.py new file mode 100644 index 00000000..a4f5c6f8 --- /dev/null +++ b/cli/planoai/init_cmd.py @@ -0,0 +1,701 @@ +import re +import os +from dataclasses import dataclass +from pathlib import Path + +import rich_click as click +from rich.console import Console +from rich.panel import Panel + +from planoai.consts import PLANO_COLOR +from planoai.utils import get_llm_provider_access_keys, find_repo_root + + +@dataclass(frozen=True) +class Template: + """ + A Plano config template. + + - id: stable identifier used by --template + - title/description: UI strings + - yaml_text: embedded template contents (works in PyPI installs) + - repo_path: optional path to a real demos/.../config.yaml when running in-repo + """ + + id: str + title: str + description: str + yaml_text: str | None = None + repo_path: str | None = None + + +BUILTIN_TEMPLATES: list[Template] = [ + Template( + id="samples_python/weather_forecast", + title="samples_python/weather_forecast", + description="prompt targets + multiple LLMs (OpenAI/Groq/Anthropic)", + yaml_text="""version: v0.1.0 + +listeners: + ingress_traffic: + address: 0.0.0.0 + port: 10000 + message_format: openai + timeout: 30s + + egress_traffic: + address: 0.0.0.0 + port: 12000 + message_format: openai + timeout: 30s + +endpoints: + weather_forecast_service: + endpoint: host.docker.internal:18083 + connect_timeout: 0.005s + +overrides: + prompt_target_intent_matching_threshold: 0.6 + +llm_providers: + - access_key: $GROQ_API_KEY + model: groq/llama-3.2-3b-preview + + - access_key: $OPENAI_API_KEY + model: openai/gpt-4o + default: true + + - access_key: $OPENAI_API_KEY + model: openai/gpt-4o-mini + + - access_key: $ANTHROPIC_API_KEY + model: anthropic/claude-sonnet-4-20250514 + +system_prompt: | + You are a helpful assistant. + +prompt_targets: + - name: get_current_weather + description: Get current weather at a location. + parameters: + - name: location + description: The location to get the weather for + required: true + type: string + format: City, State + - name: days + description: the number of days for the request + required: true + type: int + endpoint: + name: weather_forecast_service + path: /weather + http_method: POST + + - name: default_target + default: true + description: This is the default target for all unmatched prompts. + endpoint: + name: weather_forecast_service + path: /default_target + http_method: POST + system_prompt: | + You are a helpful assistant! Summarize the user's request and provide a helpful response. + auto_llm_dispatch_on_response: false + +tracing: + random_sampling: 100 + trace_arch_internal: true +""", + ), + Template( + id="samples_python/stock_quote", + title="samples_python/stock_quote", + description="external API headers ($TWELVEDATA_API_KEY) + prompt targets", + yaml_text="""version: v0.1.0 + +listeners: + ingress_traffic: + address: 0.0.0.0 + port: 10000 + message_format: openai + timeout: 30s + +llm_providers: + - access_key: $OPENAI_API_KEY + model: openai/gpt-4o + +endpoints: + twelvedata_api: + endpoint: api.twelvedata.com + protocol: https + +system_prompt: | + You are a helpful assistant. + +prompt_targets: + - name: stock_quote + description: get current stock exchange rate for a given symbol + parameters: + - name: symbol + description: Stock symbol + required: true + type: str + endpoint: + name: twelvedata_api + path: /quote + http_headers: + Authorization: "apikey $TWELVEDATA_API_KEY" + system_prompt: | + You are a helpful stock exchange assistant. Parse the JSON and present it in a human-readable format. Be concise. + + - name: stock_quote_time_series + description: get historical stock exchange rate for a given symbol + parameters: + - name: symbol + description: Stock symbol + required: true + type: str + - name: interval + description: Time interval + default: 1day + enum: + - 1h + - 1day + type: str + endpoint: + name: twelvedata_api + path: /time_series + http_headers: + Authorization: "apikey $TWELVEDATA_API_KEY" + system_prompt: | + You are a helpful stock exchange assistant. Parse the JSON and present it in a human-readable format. Be concise. + +tracing: + random_sampling: 100 + trace_arch_internal: true +""", + ), + Template( + id="use_cases/claude_code_router", + title="use_cases/claude_code_router", + description="multi-model routing preferences + model_aliases (good for CLI agents)", + yaml_text="""version: v0.1 + +listeners: + egress_traffic: + address: 0.0.0.0 + port: 12000 + message_format: openai + timeout: 30s + +llm_providers: + - model: openai/gpt-5-2025-08-07 + access_key: $OPENAI_API_KEY + routing_preferences: + - name: code generation + description: generating new code snippets, functions, or boilerplate based on user prompts or requirements + + - model: openai/gpt-4.1-2025-04-14 + access_key: $OPENAI_API_KEY + routing_preferences: + - name: code understanding + description: understand and explain existing code snippets, functions, or libraries + + - model: anthropic/claude-sonnet-4-5 + default: true + access_key: $ANTHROPIC_API_KEY + + - model: anthropic/claude-haiku-4-5 + access_key: $ANTHROPIC_API_KEY + + - model: ollama/llama3.1 + base_url: http://host.docker.internal:11434 + +model_aliases: + arch.claude.code.small.fast: + target: claude-haiku-4-5 + +tracing: + random_sampling: 100 +""", + ), + Template( + id="use_cases/ollama", + title="use_cases/ollama", + description="local LLM via base_url (OpenAI-compatible provider_interface)", + yaml_text="""version: v0.1.0 + +listeners: + egress_traffic: + address: 0.0.0.0 + port: 12000 + message_format: openai + timeout: 30s + +llm_providers: + - model: my_llm_provider/llama3.2 + provider_interface: openai + base_url: http://host.docker.internal:11434 + default: true + +system_prompt: | + You are a helpful assistant. + +tracing: + random_sampling: 100 + trace_arch_internal: true +""", + ), +] + + +def _discover_repo_demo_templates(repo_root: str | None) -> dict[str, str]: + """ + Returns mapping from template id -> absolute config.yaml path for repo demos. + This is best-effort and should be fast; built-in templates remain the default. + """ + if not repo_root: + return {} + demos_dir = Path(repo_root) / "demos" + if not demos_dir.exists(): + return {} + + result: dict[str, str] = {} + # keep it bounded: just walk demos and match config.yaml (small tree) + for cfg in demos_dir.rglob("config.yaml"): + try: + rel = cfg.relative_to(demos_dir).as_posix() + except Exception: + continue + template_id = rel.removesuffix("/config.yaml") + result[template_id] = str(cfg) + return result + + +def _get_templates() -> list[Template]: + repo_root = find_repo_root() + repo_templates = _discover_repo_demo_templates(repo_root) + templates: list[Template] = [] + for t in BUILTIN_TEMPLATES: + repo_path = repo_templates.get(t.id) + templates.append( + Template( + id=t.id, + title=t.title, + description=t.description, + yaml_text=t.yaml_text, + repo_path=repo_path, + ) + ) + + # Add any extra demo configs not represented by built-ins (no embedded yaml). + builtin_ids = {t.id for t in templates} + for template_id, path in sorted(repo_templates.items()): + if template_id in builtin_ids: + continue + templates.append( + Template( + id=template_id, + title=template_id, + description="(repo demo)", + yaml_text=None, + repo_path=path, + ) + ) + return templates + + +def _resolve_template(template_id_or_path: str | None) -> Template | None: + if not template_id_or_path: + return None + + # 1) explicit path + p = Path(template_id_or_path) + if p.exists() and p.is_file(): + return Template( + id=str(p), + title=str(p), + description="(file)", + yaml_text=None, + repo_path=str(p.resolve()), + ) + + # 2) known id + templates = _get_templates() + for t in templates: + if t.id == template_id_or_path: + return t + + return None + + +def _ensure_parent_dir(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + + +def _write_clean_config(path: Path, force: bool) -> None: + _ensure_parent_dir(path) + if path.exists() and not force: + raise FileExistsError(str(path)) + # user asked for NOTHING in it: empty file, with just a newline for POSIX friendliness + path.write_text("\n", encoding="utf-8") + + +def _write_template_config(path: Path, template: Template, force: bool) -> str: + _ensure_parent_dir(path) + if path.exists() and not force: + raise FileExistsError(str(path)) + + if template.repo_path: + src = Path(template.repo_path) + text = src.read_text(encoding="utf-8") + path.write_text(text, encoding="utf-8") + return f"repo:{template.repo_path}" + + if template.yaml_text is None: + raise ValueError(f"Template '{template.id}' is not available in this install.") + + path.write_text(template.yaml_text, encoding="utf-8") + return "builtin" + + +_ENV_VAR_PATTERN = re.compile(r"\$\{?([A-Z_][A-Z0-9_]*)\}?") + + +def _extract_env_vars(config_path: Path) -> list[str]: + """ + Extract env vars referenced by the config so we can offer .env placeholders. + Uses existing logic (headers/model providers/etc) plus a regex fallback. + """ + keys: set[str] = set() + try: + extracted = get_llm_provider_access_keys(str(config_path)) + for item in extracted: + if not item: + continue + if item.startswith("$"): + keys.add(item[1:]) + else: + # some cases may return raw vars + keys.add(item) + except Exception: + # best-effort; still run regex scan + pass + + try: + text = config_path.read_text(encoding="utf-8") + for m in _ENV_VAR_PATTERN.findall(text): + keys.add(m) + except Exception: + pass + + # Filter obvious false positives if any ever appear + keys.discard("HOST") + keys.discard("PORT") + return sorted(keys) + + +def _read_env_file_keys(env_path: Path) -> set[str]: + if not env_path.exists(): + return set() + keys: set[str] = set() + for line in env_path.read_text(encoding="utf-8").splitlines(): + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + continue + k = s.split("=", 1)[0].strip() + if k: + keys.add(k) + return keys + + +def _upsert_env_placeholders(env_path: Path, keys: list[str]) -> list[str]: + """ + Create or append missing keys with blank values. Returns the keys actually added. + """ + _ensure_parent_dir(env_path) + existing = _read_env_file_keys(env_path) + missing = [k for k in keys if k not in existing] + if not missing: + return [] + + header = "" + if env_path.exists(): + header = "\n# Added by `planoai init`\n" + + addition = header + "\n".join([f"{k}=" for k in missing]) + "\n" + with env_path.open("a", encoding="utf-8") as f: + f.write(addition) + return missing + + +def _questionary_style(): + # prompt_toolkit style string format + from prompt_toolkit.styles import Style + + return Style.from_dict( + { + "qmark": f"fg:{PLANO_COLOR} bold", + "question": "bold", + "answer": f"fg:{PLANO_COLOR} bold", + "pointer": f"fg:{PLANO_COLOR} bold", + "highlighted": f"fg:{PLANO_COLOR} bold", + "selected": f"fg:{PLANO_COLOR}", + "instruction": "fg:#888888", + "text": "", + "disabled": "fg:#666666", + } + ) + + +def _force_truecolor_for_prompt_toolkit() -> None: + """ + Ensure prompt_toolkit uses truecolor so our brand hex (#969FF4) renders correctly. + Without this, some terminals or environments downgrade to 8-bit and the color + can look like a generic blue. + """ + # Only set if user hasn't explicitly chosen a depth. + os.environ.setdefault("PROMPT_TOOLKIT_COLOR_DEPTH", "DEPTH_24_BIT") + + +@click.command() +@click.option( + "--template", + "template_id_or_path", + default=None, + help="Create config.yaml from a template id (e.g. use_cases/claude_code_router) or a path to a YAML file.", +) +@click.option( + "--clean", + is_flag=True, + help="Create an empty config.yaml with no contents.", +) +@click.option( + "--output", + "-o", + "output_path", + default="config.yaml", + show_default=True, + help="Where to write the generated config.", +) +@click.option( + "--force", + is_flag=True, + help="Overwrite existing config file if it already exists.", +) +@click.option( + "--no-env", + is_flag=True, + help="Do not create/update a .env file.", +) +@click.option( + "--yes", + "-y", + is_flag=True, + help="Skip interactive prompts and accept defaults (will NOT overwrite without --force).", +) +@click.option( + "--list-templates", + is_flag=True, + help="List available template ids and exit.", +) +@click.pass_context +def init( + ctx, template_id_or_path, clean, output_path, force, no_env, yes, list_templates +): + """Initialize a Plano config quickly (arrow-key interactive wizard by default).""" + import sys + + console = Console() + + if clean and template_id_or_path: + raise click.UsageError("Use either --clean or --template, not both.") + + templates = _get_templates() + + if list_templates: + console.print(f"[bold {PLANO_COLOR}]Available templates[/bold {PLANO_COLOR}]\n") + for t in templates: + origin = ( + "repo" if t.repo_path else "builtin" if t.yaml_text else "repo-only" + ) + console.print( + f" [bold]{t.id}[/bold] [dim]({origin})[/dim] - {t.description}" + ) + return + + out_path = Path(output_path).expanduser() + + # Non-interactive fast paths + if yes or clean or template_id_or_path: + if clean: + try: + _write_clean_config(out_path, force=force) + except FileExistsError: + raise click.ClickException( + f"Refusing to overwrite existing file: {out_path} (use --force)" + ) + console.print(f"[green]✓[/green] Wrote [bold]{out_path}[/bold]") + return + + if template_id_or_path: + template = _resolve_template(template_id_or_path) + if not template: + raise click.ClickException( + f"Unknown template: {template_id_or_path}\n" + f"Run: planoai init --list-templates" + ) + try: + origin = _write_template_config(out_path, template, force=force) + except FileExistsError: + raise click.ClickException( + f"Refusing to overwrite existing file: {out_path} (use --force)" + ) + console.print( + f"[green]✓[/green] Wrote [bold]{out_path}[/bold] [dim]({template.id}, {origin})[/dim]" + ) + + if no_env: + return + + env_vars = _extract_env_vars(out_path) + if env_vars: + env_path = out_path.parent / ".env" + added = _upsert_env_placeholders(env_path, env_vars) + if added: + console.print( + f"[green]✓[/green] Updated [bold]{env_path}[/bold] [dim](added: {', '.join(added)})[/dim]" + ) + else: + console.print(f"[dim]✓ .env already contains required keys[/dim]") + return + + # yes without clean/template means: do nothing useful + raise click.UsageError( + "Non-interactive mode requires --template or --clean (or omit --yes for the interactive wizard)." + ) + + # Interactive wizard + if not (sys.stdin.isatty() and sys.stdout.isatty()): + raise click.ClickException( + "Interactive mode requires a TTY.\n" + "Use one of:\n" + " planoai init --template \n" + " planoai init --clean\n" + " planoai init --list-templates" + ) + + _force_truecolor_for_prompt_toolkit() + + # Lazy import so non-interactive users don't pay the import/compat cost + import questionary + from questionary import Choice + + # Step 1: mode + mode = questionary.select( + "Welcome to Plano! Pick a starting point:", + choices=[ + Choice("Start from a demo template (recommended)", value="template"), + Choice("Create a clean config.yaml (empty)", value="clean"), + Choice("Cancel", value="cancel"), + ], + style=_questionary_style(), + pointer="❯", + ).ask() + + if mode in (None, "cancel"): + console.print("[dim]Cancelled.[/dim]") + return + + # Step 2: output path (default: config.yaml) + out_answer = questionary.text( + "Where should I write the config?", + default=str(out_path), + style=_questionary_style(), + ).ask() + if not out_answer: + console.print("[dim]Cancelled.[/dim]") + return + out_path = Path(out_answer).expanduser() + + if out_path.exists() and not force: + overwrite = questionary.confirm( + f"{out_path} already exists. Overwrite?", + default=False, + style=_questionary_style(), + ).ask() + if not overwrite: + console.print("[dim]Cancelled.[/dim]") + return + force = True + + if mode == "clean": + _write_clean_config(out_path, force=True) + console.print(f"[green]✓[/green] Wrote [bold]{out_path}[/bold]") + return + + # Step 3: choose template (curated at top, plus any repo-only demos) + # Keep the list compact and readable. + template_choices: list[Choice] = [] + for t in templates: + label = f"{t.title} — {t.description}" + template_choices.append(Choice(label, value=t)) + + template = questionary.select( + "Choose a template", + choices=template_choices, + style=_questionary_style(), + pointer="❯", + use_indicator=True, + ).ask() + if not template: + console.print("[dim]Cancelled.[/dim]") + return + + origin = _write_template_config(out_path, template, force=True) + console.print( + f"[green]✓[/green] Wrote [bold]{out_path}[/bold] [dim]({template.id}, {origin})[/dim]" + ) + + # Step 4: .env placeholders (recommended, fast) + if not no_env: + env_vars = _extract_env_vars(out_path) + if env_vars: + env_path = out_path.parent / ".env" + do_env = questionary.confirm( + "Create/update a .env file with placeholders for required keys?", + default=True, + style=_questionary_style(), + ).ask() + if do_env: + added = _upsert_env_placeholders(env_path, env_vars) + if added: + console.print( + f"[green]✓[/green] Updated [bold]{env_path}[/bold] [dim](added: {', '.join(added)})[/dim]" + ) + else: + console.print(f"[dim]✓ .env already contains required keys[/dim]") + + # Step 5: next step shortcuts (validate/up/done) + next_step = questionary.select( + "Next step", + choices=[ + Choice(f"Run: planoai validate {out_path}", value="validate"), + Choice(f"Run: planoai up {out_path}", value="up"), + Choice("Done", value="done"), + ], + default="validate", + style=_questionary_style(), + pointer="❯", + ).ask() + + if next_step == "validate": + # Reuse existing click command implementation + from planoai.main import validate as validate_cmd + + ctx.invoke(validate_cmd, config_file=str(out_path), path=".", quiet=False) + elif next_step == "up": + from planoai.main import up as up_cmd + + ctx.invoke(up_cmd, file=str(out_path), path=".", foreground=False) diff --git a/cli/planoai/main.py b/cli/planoai/main.py index 151da306..b18258c6 100644 --- a/cli/planoai/main.py +++ b/cli/planoai/main.py @@ -93,6 +93,7 @@ from planoai.core import ( stop_docker_container, start_cli_agent, ) +from planoai.init_cmd import init as init_cmd from planoai.consts import ( PLANO_DOCKER_IMAGE, PLANO_DOCKER_NAME, @@ -879,6 +880,7 @@ main.add_command(logs) main.add_command(cli_agent) main.add_command(generate_prompt_targets) main.add_command(validate) +main.add_command(init_cmd, name="init") if __name__ == "__main__": main() diff --git a/cli/pyproject.toml b/cli/pyproject.toml index be5ba0ca..b901d4c2 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "click>=8.1.7,<9.0.0", "jinja2>=3.1.4,<4.0.0", "jsonschema>=4.23.0,<5.0.0", + "questionary>=2.1.1,<3.0.0", "pyyaml>=6.0.2,<7.0.0", "requests>=2.31.0,<3.0.0", "rich>=14.2.0", diff --git a/cli/test/test_init.py b/cli/test/test_init.py new file mode 100644 index 00000000..ccbae873 --- /dev/null +++ b/cli/test/test_init.py @@ -0,0 +1,59 @@ +from click.testing import CliRunner + +from planoai.init_cmd import init + + +def test_init_clean_writes_empty_config(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke(init, ["--clean"]) + + assert result.exit_code == 0, result.output + config_path = tmp_path / "config.yaml" + assert config_path.exists() + assert config_path.read_text(encoding="utf-8") == "\n" + + +def test_init_template_builtin_writes_config_and_env(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke( + init, ["--template", "use_cases/claude_code_router", "--yes"] + ) + + assert result.exit_code == 0, result.output + + config_path = tmp_path / "config.yaml" + assert config_path.exists() + config_text = config_path.read_text(encoding="utf-8") + assert "llm_providers:" in config_text + + env_path = tmp_path / ".env" + assert env_path.exists() + env_text = env_path.read_text(encoding="utf-8") + assert "OPENAI_API_KEY=" in env_text + assert "ANTHROPIC_API_KEY=" in env_text + + +def test_init_refuses_overwrite_without_force(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "config.yaml").write_text("hello", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(init, ["--clean"]) + + assert result.exit_code != 0 + assert "Refusing to overwrite" in result.output + + +def test_init_force_overwrites(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "config.yaml").write_text("hello", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke(init, ["--clean", "--force"]) + + assert result.exit_code == 0, result.output + assert (tmp_path / "config.yaml").read_text(encoding="utf-8") == "\n" diff --git a/cli/uv.lock b/cli/uv.lock index 764badcc..2a3b1edc 100644 --- a/cli/uv.lock +++ b/cli/uv.lock @@ -271,6 +271,7 @@ dependencies = [ { name = "jinja2" }, { name = "jsonschema" }, { name = "pyyaml" }, + { name = "questionary" }, { name = "requests" }, { name = "rich" }, { name = "rich-click" }, @@ -293,6 +294,7 @@ requires-dist = [ { name = "jsonschema", specifier = ">=4.23.0,<5.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1,<9.0.0" }, { name = "pyyaml", specifier = ">=6.0.2,<7.0.0" }, + { name = "questionary", specifier = ">=2.1.1,<3.0.0" }, { name = "requests", specifier = ">=2.31.0,<3.0.0" }, { name = "rich", specifier = ">=14.2.0" }, { name = "rich-click", specifier = ">=1.9.5" }, @@ -311,6 +313,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -382,6 +396,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "referencing" version = "0.36.2" @@ -630,3 +656,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599 wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, +] diff --git a/demos/use_cases/vercel-ai-sdk/.env.local b/demos/use_cases/vercel-ai-sdk/.env.local new file mode 100644 index 00000000..63f81dd7 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/.env.local @@ -0,0 +1,5 @@ +AUTH_SECRET=R/dTqODnkO4dHsra7q9J78GkBTHG2YKWVnRZdTIYxmw= +AI_GATEWAY_API_KEY=vck_8OmwGvVjwIZvNnOUtXQe7QrwbJY9iqT1VkVmM1JtstIbJsazj71lCMGf +BLOB_READ_WRITE_TOKEN=vercel_blob_rw_m2VnAfbbbo7gcEMm_o6M0Zj7v7OLcz8W4fuJXTzjK6dcTSV +POSTGRES_URL=postgresql://neondb_owner:npg_bkr8fYJiw7HA@ep-wandering-surf-ahvhyf3o-pooler.c-3.us-east-1.aws.neon.tech/neondb?sslmode=require +REDIS_URL="rediss://default:ATU7AAIncDJkMmFlOTMxNjA2ZTI0ZTJmOGU0YWFhNTYxYjhmZmU1OXAyMTM2Mjc@ethical-monster-13627.upstash.io:6379"