Add llama.cpp router support
- commit
- 09b92ac68c3346aa52cdfc06859326b357c2fbbe
- author
- patriceckhart · · 2026-07-23 05:58 UTC
- parent
- bd355ab
- signature
- unsigned
30 files · +2458 -67
M README.md +66 -2
| @@ -247,6 +247,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||
| 247 | 247 | | `/login` | Log in via API key or subscription (opens a dialog). | | |
| 248 | 248 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zot again. | | |
| 249 | 249 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | |
| 250 | + | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | |
| 250 | 251 | | `/sessions` | Resume a previous session for this directory. | | |
| 251 | 252 | | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. | | |
| 252 | 253 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | |
| @@ -389,9 +390,9 @@ zot's built-in provider catalog includes: | |||
| 389 | 390 | - **Subscription-capable**: Anthropic Claude Pro/Max (`anthropic`), OpenAI Codex / ChatGPT Plus/Pro (`openai-codex`), Kimi Code (`kimi`), SuperGrok/X Premium (`xai`), GitHub Copilot (`github-copilot`). | |
| 390 | 391 | - **Direct API providers**: Anthropic, OpenAI Chat Completions, OpenAI Responses, DeepSeek, Google Gemini, Kimi/Moonshot, Moonshot CN, Groq, Cerebras, xAI, Together AI, Hugging Face Router, OpenRouter, Mistral, Z.AI, Xiaomi/MiMo token-plan regions, MiniMax global/CN, Fireworks, Vercel AI Gateway, OpenCode/OpenCode Go. | |
| 391 | 392 | - **Cloud/platform providers**: Amazon Bedrock, Google Vertex AI, Azure OpenAI, Cloudflare Workers AI, Cloudflare AI Gateway. | |
| 392 | - | - **Local/compatible**: Ollama and OpenAI-compatible local endpoints via `--base-url`. | |
| 393 | + | - **Local/compatible**: Ollama, llama.cpp router mode, and OpenAI-compatible local endpoints via `--base-url`. | |
| 393 | 394 | ||
| 394 | - | Use `/login` to store API keys or subscription credentials. `/model` only shows models from providers that are currently available from env vars, `auth.json`, Kimi CLI fallback, or local Ollama. | |
| 395 | + | Use `/login` to store API keys or subscription credentials. `/model` only shows models from providers that are currently available from env vars, `auth.json`, Kimi CLI fallback, local Ollama, or a configured llama.cpp router. | |
| 395 | 396 | ||
| 396 | 397 | ## Models | |
| 397 | 398 | ||
| @@ -633,6 +634,69 @@ You can also add models to your `models.json` so you don't need flags every time | |||
| 633 | 634 | ||
| 634 | 635 | The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.). | |
| 635 | 636 | ||
| 637 | + | ### Local models with llama.cpp router mode | |
| 638 | + | ||
| 639 | + | zot can connect to a recent [llama.cpp](https://github.com/ggml-org/llama.cpp) router, manage its GGUF files, and use loaded models through the router's OpenAI-compatible inference API. This is separate from Ollama. An Ollama server normally listens on port `11434` and should use zot's `ollama` provider instead. | |
| 640 | + | ||
| 641 | + | Install or update llama.cpp. On macOS with Homebrew: | |
| 642 | + | ||
| 643 | + | ```bash | |
| 644 | + | brew install llama.cpp | |
| 645 | + | # or, when already installed | |
| 646 | + | brew upgrade llama.cpp | |
| 647 | + | ``` | |
| 648 | + | ||
| 649 | + | Create a directory for GGUF files and start `llama-server` without `--model`, `-m`, or `-hf`. Supplying one of those options starts a single model rather than the router API zot needs. | |
| 650 | + | ||
| 651 | + | ```bash | |
| 652 | + | mkdir -p ~/llama-models | |
| 653 | + | ||
| 654 | + | llama-server \ | |
| 655 | + | --models-dir ~/llama-models \ | |
| 656 | + | --no-models-autoload \ | |
| 657 | + | --jinja \ | |
| 658 | + | --host 127.0.0.1 \ | |
| 659 | + | --port 8080 \ | |
| 660 | + | -ngl 999 \ | |
| 661 | + | -c 32768 | |
| 662 | + | ``` | |
| 663 | + | ||
| 664 | + | `--no-models-autoload` leaves load decisions to `/llama`. `--jinja` enables model chat templates and improves tool-call compatibility. `-ngl 999` requests maximum GPU offload, while `-c 32768` limits each loaded model to a 32K context. Adjust these values for your hardware. | |
| 665 | + | ||
| 666 | + | Confirm that router mode is active before configuring zot: | |
| 667 | + | ||
| 668 | + | ```bash | |
| 669 | + | curl http://127.0.0.1:8080/health | |
| 670 | + | curl http://127.0.0.1:8080/models | |
| 671 | + | ``` | |
| 672 | + | ||
| 673 | + | The models request must return JSON with a `data` array. A 404 usually means the server is outdated, running on a different port, or was started in single-model mode. | |
| 674 | + | ||
| 675 | + | In zot, run `/login`, choose **api key**, and select **llama.cpp**. Enter `http://127.0.0.1:8080` as the router URL and leave the API key empty for a local-only server. Do not enter `/v1`; zot derives the inference URL itself. The saved URL and optional key live in `$ZOT_HOME/auth.json`. | |
| 676 | + | ||
| 677 | + | You can configure the same connection through environment variables: | |
| 678 | + | ||
| 679 | + | ```bash | |
| 680 | + | export LLAMA_BASE_URL=http://127.0.0.1:8080 | |
| 681 | + | export LLAMA_API_KEY=optional-secret | |
| 682 | + | ``` | |
| 683 | + | ||
| 684 | + | When using a key, launch the server with the matching `--api-key` value. Keep `--host 127.0.0.1` unless remote clients must reach the router. | |
| 685 | + | ||
| 686 | + | Run `/llama` to: | |
| 687 | + | ||
| 688 | + | - inspect the router's current model states | |
| 689 | + | - search Hugging Face for GGUF repositories | |
| 690 | + | - choose a quantization and download it with byte progress | |
| 691 | + | - explicitly load or unload a model | |
| 692 | + | - press `d` to ask the router to remove a downloaded cache model after confirmation | |
| 693 | + | ||
| 694 | + | Models discovered through `--models-dir` or a preset cannot be removed by zot. Delete those files from their configured source instead. The router removes the selected GGUF from its Hugging Face cache, but some llama.cpp versions retain shared repository artifacts such as `mmproj` files. Remove the repository's cache directory manually if those artifacts are no longer needed. Hugging Face search uses `HF_TOKEN` when available. Gated repositories require prior access approval, and the `llama-server` process also needs an authorized `HF_TOKEN` because the server performs the download. | |
| 695 | + | ||
| 696 | + | Opening `/model` refreshes the router and lists every loaded model under provider `llama.cpp`. Unloaded models are intentionally omitted because they cannot answer inference requests. Load them through `/llama` first, then select them through `/model`. | |
| 697 | + | ||
| 698 | + | A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually. | |
| 699 | + | ||
| 636 | 700 | ## Inline images | |
| 637 | 701 | ||
| 638 | 702 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | |
| @@ -247,6 +247,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | |||||
| 247 | | `/login` | Log in via API key or subscription (opens a dialog). | | 247 | | `/login` | Log in via API key or subscription (opens a dialog). | | ||
| 248 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zot again. | | 248 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zot again. | | ||
| 249 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | 249 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | ||
| 250 | + | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | |||
| 250 | | `/sessions` | Resume a previous session for this directory. | | 251 | | `/sessions` | Resume a previous session for this directory. | | ||
| 251 | | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. | | 252 | | `/session` | Four ops on the current session: `export` to a portable `.zotsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. | | ||
| 252 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | 253 | | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). | | ||
| @@ -389,9 +390,9 @@ zot's built-in provider catalog includes: | |||||
| 389 | - **Subscription-capable**: Anthropic Claude Pro/Max (`anthropic`), OpenAI Codex / ChatGPT Plus/Pro (`openai-codex`), Kimi Code (`kimi`), SuperGrok/X Premium (`xai`), GitHub Copilot (`github-copilot`). | 390 | - **Subscription-capable**: Anthropic Claude Pro/Max (`anthropic`), OpenAI Codex / ChatGPT Plus/Pro (`openai-codex`), Kimi Code (`kimi`), SuperGrok/X Premium (`xai`), GitHub Copilot (`github-copilot`). | ||
| 390 | - **Direct API providers**: Anthropic, OpenAI Chat Completions, OpenAI Responses, DeepSeek, Google Gemini, Kimi/Moonshot, Moonshot CN, Groq, Cerebras, xAI, Together AI, Hugging Face Router, OpenRouter, Mistral, Z.AI, Xiaomi/MiMo token-plan regions, MiniMax global/CN, Fireworks, Vercel AI Gateway, OpenCode/OpenCode Go. | 391 | - **Direct API providers**: Anthropic, OpenAI Chat Completions, OpenAI Responses, DeepSeek, Google Gemini, Kimi/Moonshot, Moonshot CN, Groq, Cerebras, xAI, Together AI, Hugging Face Router, OpenRouter, Mistral, Z.AI, Xiaomi/MiMo token-plan regions, MiniMax global/CN, Fireworks, Vercel AI Gateway, OpenCode/OpenCode Go. | ||
| 391 | - **Cloud/platform providers**: Amazon Bedrock, Google Vertex AI, Azure OpenAI, Cloudflare Workers AI, Cloudflare AI Gateway. | 392 | - **Cloud/platform providers**: Amazon Bedrock, Google Vertex AI, Azure OpenAI, Cloudflare Workers AI, Cloudflare AI Gateway. | ||
| 392 | - | - **Local/compatible**: Ollama and OpenAI-compatible local endpoints via `--base-url`. | 393 | + | - **Local/compatible**: Ollama, llama.cpp router mode, and OpenAI-compatible local endpoints via `--base-url`. |
| 393 | 394 | ||||
| 394 | - | Use `/login` to store API keys or subscription credentials. `/model` only shows models from providers that are currently available from env vars, `auth.json`, Kimi CLI fallback, or local Ollama. | 395 | + | Use `/login` to store API keys or subscription credentials. `/model` only shows models from providers that are currently available from env vars, `auth.json`, Kimi CLI fallback, local Ollama, or a configured llama.cpp router. |
| 395 | 396 | ||||
| 396 | ## Models | 397 | ## Models | ||
| 397 | 398 | ||||
| @@ -633,6 +634,69 @@ You can also add models to your `models.json` so you don't need flags every time | |||||
| 633 | 634 | ||||
| 634 | The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.). | 635 | The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.). | ||
| 635 | 636 | ||||
| 637 | + | ### Local models with llama.cpp router mode | |||
| 638 | + | ||||
| 639 | + | zot can connect to a recent [llama.cpp](https://github.com/ggml-org/llama.cpp) router, manage its GGUF files, and use loaded models through the router's OpenAI-compatible inference API. This is separate from Ollama. An Ollama server normally listens on port `11434` and should use zot's `ollama` provider instead. | |||
| 640 | + | ||||
| 641 | + | Install or update llama.cpp. On macOS with Homebrew: | |||
| 642 | + | ||||
| 643 | + | ```bash | |||
| 644 | + | brew install llama.cpp | |||
| 645 | + | # or, when already installed | |||
| 646 | + | brew upgrade llama.cpp | |||
| 647 | + | ``` | |||
| 648 | + | ||||
| 649 | + | Create a directory for GGUF files and start `llama-server` without `--model`, `-m`, or `-hf`. Supplying one of those options starts a single model rather than the router API zot needs. | |||
| 650 | + | ||||
| 651 | + | ```bash | |||
| 652 | + | mkdir -p ~/llama-models | |||
| 653 | + | ||||
| 654 | + | llama-server \ | |||
| 655 | + | --models-dir ~/llama-models \ | |||
| 656 | + | --no-models-autoload \ | |||
| 657 | + | --jinja \ | |||
| 658 | + | --host 127.0.0.1 \ | |||
| 659 | + | --port 8080 \ | |||
| 660 | + | -ngl 999 \ | |||
| 661 | + | -c 32768 | |||
| 662 | + | ``` | |||
| 663 | + | ||||
| 664 | + | `--no-models-autoload` leaves load decisions to `/llama`. `--jinja` enables model chat templates and improves tool-call compatibility. `-ngl 999` requests maximum GPU offload, while `-c 32768` limits each loaded model to a 32K context. Adjust these values for your hardware. | |||
| 665 | + | ||||
| 666 | + | Confirm that router mode is active before configuring zot: | |||
| 667 | + | ||||
| 668 | + | ```bash | |||
| 669 | + | curl http://127.0.0.1:8080/health | |||
| 670 | + | curl http://127.0.0.1:8080/models | |||
| 671 | + | ``` | |||
| 672 | + | ||||
| 673 | + | The models request must return JSON with a `data` array. A 404 usually means the server is outdated, running on a different port, or was started in single-model mode. | |||
| 674 | + | ||||
| 675 | + | In zot, run `/login`, choose **api key**, and select **llama.cpp**. Enter `http://127.0.0.1:8080` as the router URL and leave the API key empty for a local-only server. Do not enter `/v1`; zot derives the inference URL itself. The saved URL and optional key live in `$ZOT_HOME/auth.json`. | |||
| 676 | + | ||||
| 677 | + | You can configure the same connection through environment variables: | |||
| 678 | + | ||||
| 679 | + | ```bash | |||
| 680 | + | export LLAMA_BASE_URL=http://127.0.0.1:8080 | |||
| 681 | + | export LLAMA_API_KEY=optional-secret | |||
| 682 | + | ``` | |||
| 683 | + | ||||
| 684 | + | When using a key, launch the server with the matching `--api-key` value. Keep `--host 127.0.0.1` unless remote clients must reach the router. | |||
| 685 | + | ||||
| 686 | + | Run `/llama` to: | |||
| 687 | + | ||||
| 688 | + | - inspect the router's current model states | |||
| 689 | + | - search Hugging Face for GGUF repositories | |||
| 690 | + | - choose a quantization and download it with byte progress | |||
| 691 | + | - explicitly load or unload a model | |||
| 692 | + | - press `d` to ask the router to remove a downloaded cache model after confirmation | |||
| 693 | + | ||||
| 694 | + | Models discovered through `--models-dir` or a preset cannot be removed by zot. Delete those files from their configured source instead. The router removes the selected GGUF from its Hugging Face cache, but some llama.cpp versions retain shared repository artifacts such as `mmproj` files. Remove the repository's cache directory manually if those artifacts are no longer needed. Hugging Face search uses `HF_TOKEN` when available. Gated repositories require prior access approval, and the `llama-server` process also needs an authorized `HF_TOKEN` because the server performs the download. | |||
| 695 | + | ||||
| 696 | + | Opening `/model` refreshes the router and lists every loaded model under provider `llama.cpp`. Unloaded models are intentionally omitted because they cannot answer inference requests. Load them through `/llama` first, then select them through `/model`. | |||
| 697 | + | ||||
| 698 | + | A model installed through Ollama is kept in Ollama's internal storage and is not automatically available as a llama.cpp GGUF file. Download a GGUF copy through `/llama` or place GGUF files in `~/llama-models`, then restart the router so it discovers files added manually. | |||
| 699 | + | ||||
| 636 | ## Inline images | 700 | ## Inline images | ||
| 637 | 701 | ||||
| 638 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | 702 | When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var: | ||
M docs/providers.md +53 -1
| @@ -6,7 +6,7 @@ with `/model`, list them with `zot --list-models`, and add private models in | |||
| 6 | 6 | ||
| 7 | 7 | ## Login methods | |
| 8 | 8 | ||
| 9 | - | Use `/login` in interactive mode. | |
| 9 | + | Use `/login` in interactive mode. Type in either provider picker to filter the list by provider ID or display name. | |
| 10 | 10 | ||
| 11 | 11 | - `api key`: stores an API key in `$ZOT_HOME/auth.json` when the provider uses a normal key. | |
| 12 | 12 | - `subscription`: stores OAuth credentials for subscription-backed providers. | |
| @@ -87,6 +87,58 @@ export OPENROUTER_API_KEY=... | |||
| 87 | 87 | zot --provider openrouter | |
| 88 | 88 | ``` | |
| 89 | 89 | ||
| 90 | + | ## Local llama.cpp router | |
| 91 | + | ||
| 92 | + | The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints. | |
| 93 | + | ||
| 94 | + | Install a current llama.cpp build. On macOS: | |
| 95 | + | ||
| 96 | + | ```bash | |
| 97 | + | brew install llama.cpp | |
| 98 | + | # use `brew upgrade llama.cpp` for an existing installation | |
| 99 | + | ``` | |
| 100 | + | ||
| 101 | + | Start `llama-server` without `--model`, `-m`, or `-hf`. Those flags select one model and disable the router behavior zot expects. | |
| 102 | + | ||
| 103 | + | ```bash | |
| 104 | + | mkdir -p ~/llama-models | |
| 105 | + | ||
| 106 | + | llama-server \ | |
| 107 | + | --models-dir ~/llama-models \ | |
| 108 | + | --no-models-autoload \ | |
| 109 | + | --jinja \ | |
| 110 | + | --host 127.0.0.1 \ | |
| 111 | + | --port 8080 \ | |
| 112 | + | -ngl 999 \ | |
| 113 | + | -c 32768 | |
| 114 | + | ``` | |
| 115 | + | ||
| 116 | + | This configuration discovers GGUF files below `~/llama-models`, leaves model loading under explicit user control, enables chat templates, requests GPU offload, and sets a 32K context. Tune the GPU layers and context size for the available memory. | |
| 117 | + | ||
| 118 | + | Check the management API before opening zot: | |
| 119 | + | ||
| 120 | + | ```bash | |
| 121 | + | curl http://127.0.0.1:8080/health | |
| 122 | + | curl http://127.0.0.1:8080/models | |
| 123 | + | ``` | |
| 124 | + | ||
| 125 | + | `/models` must return JSON with a `data` array. A 404 indicates the wrong URL, an older server build, or single-model mode. | |
| 126 | + | ||
| 127 | + | To save the connection, run `/login`, choose `api key`, and select `llama.cpp`. Enter `http://127.0.0.1:8080`, without `/v1`, followed by an optional bearer key. zot validates the URL and stores both values in `$ZOT_HOME/auth.json`. Environment variables override the saved connection: | |
| 128 | + | ||
| 129 | + | ```bash | |
| 130 | + | export LLAMA_BASE_URL=http://127.0.0.1:8080 | |
| 131 | + | export LLAMA_API_KEY=optional-secret | |
| 132 | + | ``` | |
| 133 | + | ||
| 134 | + | If a key is configured, start the router with the same `--api-key` value. A server bound only to `127.0.0.1` normally does not need authentication. | |
| 135 | + | ||
| 136 | + | After login, `/llama` becomes visible. It shows model state, searches Hugging Face for GGUF repositories, offers available quantizations, reports download and load progress, and explicitly loads or unloads models. Press `d` on a router-downloaded cache model to ask the router to remove it after confirmation. Models discovered through `--models-dir` or a preset are not removable through the router API and must be deleted from their configured source. Some llama.cpp versions retain shared Hugging Face repository artifacts such as `mmproj` files after removing the selected GGUF; remove that repository's cache directory manually if those artifacts are no longer needed. `HF_TOKEN` raises Hugging Face API limits and may be required for repository metadata. For gated files, approve access first and provide the authorized token to the `llama-server` process because the router performs the download. | |
| 137 | + | ||
| 138 | + | Opening `/model` refreshes the router and displays its loaded models under provider `llama.cpp`. Unloaded models cannot handle inference and therefore remain exclusive to `/llama` until loaded. Inference is sent to the router's derived `/v1` endpoint. | |
| 139 | + | ||
| 140 | + | Ollama downloads are stored in Ollama's internal layout and do not automatically become llama.cpp models. Obtain a GGUF copy through `/llama` or place GGUF files under `~/llama-models`, then restart the router after adding files manually. | |
| 141 | + | ||
| 90 | 142 | ## Cloud providers | |
| 91 | 143 | ||
| 92 | 144 | ### Amazon Bedrock | |
| @@ -6,7 +6,7 @@ with `/model`, list them with `zot --list-models`, and add private models in | |||||
| 6 | 6 | ||||
| 7 | ## Login methods | 7 | ## Login methods | ||
| 8 | 8 | ||||
| 9 | - | Use `/login` in interactive mode. | 9 | + | Use `/login` in interactive mode. Type in either provider picker to filter the list by provider ID or display name. |
| 10 | 10 | ||||
| 11 | - `api key`: stores an API key in `$ZOT_HOME/auth.json` when the provider uses a normal key. | 11 | - `api key`: stores an API key in `$ZOT_HOME/auth.json` when the provider uses a normal key. | ||
| 12 | - `subscription`: stores OAuth credentials for subscription-backed providers. | 12 | - `subscription`: stores OAuth credentials for subscription-backed providers. | ||
| @@ -87,6 +87,58 @@ export OPENROUTER_API_KEY=... | |||||
| 87 | zot --provider openrouter | 87 | zot --provider openrouter | ||
| 88 | ``` | 88 | ``` | ||
| 89 | 89 | ||||
| 90 | + | ## Local llama.cpp router | |||
| 91 | + | ||||
| 92 | + | The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints. | |||
| 93 | + | ||||
| 94 | + | Install a current llama.cpp build. On macOS: | |||
| 95 | + | ||||
| 96 | + | ```bash | |||
| 97 | + | brew install llama.cpp | |||
| 98 | + | # use `brew upgrade llama.cpp` for an existing installation | |||
| 99 | + | ``` | |||
| 100 | + | ||||
| 101 | + | Start `llama-server` without `--model`, `-m`, or `-hf`. Those flags select one model and disable the router behavior zot expects. | |||
| 102 | + | ||||
| 103 | + | ```bash | |||
| 104 | + | mkdir -p ~/llama-models | |||
| 105 | + | ||||
| 106 | + | llama-server \ | |||
| 107 | + | --models-dir ~/llama-models \ | |||
| 108 | + | --no-models-autoload \ | |||
| 109 | + | --jinja \ | |||
| 110 | + | --host 127.0.0.1 \ | |||
| 111 | + | --port 8080 \ | |||
| 112 | + | -ngl 999 \ | |||
| 113 | + | -c 32768 | |||
| 114 | + | ``` | |||
| 115 | + | ||||
| 116 | + | This configuration discovers GGUF files below `~/llama-models`, leaves model loading under explicit user control, enables chat templates, requests GPU offload, and sets a 32K context. Tune the GPU layers and context size for the available memory. | |||
| 117 | + | ||||
| 118 | + | Check the management API before opening zot: | |||
| 119 | + | ||||
| 120 | + | ```bash | |||
| 121 | + | curl http://127.0.0.1:8080/health | |||
| 122 | + | curl http://127.0.0.1:8080/models | |||
| 123 | + | ``` | |||
| 124 | + | ||||
| 125 | + | `/models` must return JSON with a `data` array. A 404 indicates the wrong URL, an older server build, or single-model mode. | |||
| 126 | + | ||||
| 127 | + | To save the connection, run `/login`, choose `api key`, and select `llama.cpp`. Enter `http://127.0.0.1:8080`, without `/v1`, followed by an optional bearer key. zot validates the URL and stores both values in `$ZOT_HOME/auth.json`. Environment variables override the saved connection: | |||
| 128 | + | ||||
| 129 | + | ```bash | |||
| 130 | + | export LLAMA_BASE_URL=http://127.0.0.1:8080 | |||
| 131 | + | export LLAMA_API_KEY=optional-secret | |||
| 132 | + | ``` | |||
| 133 | + | ||||
| 134 | + | If a key is configured, start the router with the same `--api-key` value. A server bound only to `127.0.0.1` normally does not need authentication. | |||
| 135 | + | ||||
| 136 | + | After login, `/llama` becomes visible. It shows model state, searches Hugging Face for GGUF repositories, offers available quantizations, reports download and load progress, and explicitly loads or unloads models. Press `d` on a router-downloaded cache model to ask the router to remove it after confirmation. Models discovered through `--models-dir` or a preset are not removable through the router API and must be deleted from their configured source. Some llama.cpp versions retain shared Hugging Face repository artifacts such as `mmproj` files after removing the selected GGUF; remove that repository's cache directory manually if those artifacts are no longer needed. `HF_TOKEN` raises Hugging Face API limits and may be required for repository metadata. For gated files, approve access first and provide the authorized token to the `llama-server` process because the router performs the download. | |||
| 137 | + | ||||
| 138 | + | Opening `/model` refreshes the router and displays its loaded models under provider `llama.cpp`. Unloaded models cannot handle inference and therefore remain exclusive to `/llama` until loaded. Inference is sent to the router's derived `/v1` endpoint. | |||
| 139 | + | ||||
| 140 | + | Ollama downloads are stored in Ollama's internal layout and do not automatically become llama.cpp models. Obtain a GGUF copy through `/llama` or place GGUF files under `~/llama-models`, then restart the router after adding files manually. | |||
| 141 | + | ||||
| 90 | ## Cloud providers | 142 | ## Cloud providers | ||
| 91 | 143 | ||||
| 92 | ### Amazon Bedrock | 144 | ### Amazon Bedrock | ||
M packages/agent/args.go +1 -1
| @@ -382,7 +382,7 @@ func PrintHelp(version string) { | |||
| 382 | 382 | row{"zot tg ...", "short alias for telegram-bot"}, | |
| 383 | 383 | ) | |
| 384 | 384 | section("provider and model flags", | |
| 385 | - | row{"--provider", "provider to use (anthropic|openai|openai-codex|kimi|deepseek|google|ollama)"}, | |
| 385 | + | row{"--provider", "provider to use (anthropic|openai|openai-codex|kimi|deepseek|google|ollama|llama.cpp)"}, | |
| 386 | 386 | row{"--model ID", "model id (see --list-models)"}, | |
| 387 | 387 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | |
| 388 | 388 | row{"--base-url URL", "override provider api base url"}, | |
| @@ -382,7 +382,7 @@ func PrintHelp(version string) { | |||||
| 382 | row{"zot tg ...", "short alias for telegram-bot"}, | 382 | row{"zot tg ...", "short alias for telegram-bot"}, | ||
| 383 | ) | 383 | ) | ||
| 384 | section("provider and model flags", | 384 | section("provider and model flags", | ||
| 385 | - | row{"--provider", "provider to use (anthropic|openai|openai-codex|kimi|deepseek|google|ollama)"}, | 385 | + | row{"--provider", "provider to use (anthropic|openai|openai-codex|kimi|deepseek|google|ollama|llama.cpp)"}, |
| 386 | row{"--model ID", "model id (see --list-models)"}, | 386 | row{"--model ID", "model id (see --list-models)"}, | ||
| 387 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | 387 | row{"--api-key KEY", "api key for this run (env / auth.json fallback)"}, | ||
| 388 | row{"--base-url URL", "override provider api base url"}, | 388 | row{"--base-url URL", "override provider api base url"}, | ||
M packages/agent/build.go +36 -15
| @@ -155,7 +155,7 @@ func defaultModelForProvider(prov string) string { | |||
| 155 | 155 | return "deepseek-v4-pro" | |
| 156 | 156 | case "google": | |
| 157 | 157 | return "gemini-2.5-pro" | |
| 158 | - | case "ollama": | |
| 158 | + | case "ollama", provider.LlamaCPPProviderID: | |
| 159 | 159 | return "" | |
| 160 | 160 | case "moonshotai", "moonshotai-cn": | |
| 161 | 161 | return "kimi-k2.6" | |
| @@ -210,7 +210,7 @@ func defaultModelForProvider(prov string) string { | |||
| 210 | 210 | // auto-fallback logic that picks any logged-in provider when the user's | |
| 211 | 211 | // preferred one has no credentials. | |
| 212 | 212 | var knownProviders = []string{ | |
| 213 | - | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", | |
| 213 | + | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, | |
| 214 | 214 | "moonshotai", "moonshotai-cn", | |
| 215 | 215 | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", | |
| 216 | 216 | "mistral", "zai", | |
| @@ -271,6 +271,8 @@ var providerAliases = map[string]string{ | |||
| 271 | 271 | "cloudflare": "cloudflare-workers-ai", | |
| 272 | 272 | "workers-ai": "cloudflare-workers-ai", | |
| 273 | 273 | "hf": "huggingface", | |
| 274 | + | "llama": provider.LlamaCPPProviderID, | |
| 275 | + | "llamacpp": provider.LlamaCPPProviderID, | |
| 274 | 276 | } | |
| 275 | 277 | ||
| 276 | 278 | // canonicalProvider normalises a user-supplied provider name: trims | |
| @@ -379,10 +381,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 379 | 381 | // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE / | |
| 380 | 382 | // IAM keys) when no config.json pins the provider, such as after | |
| 381 | 383 | // pointing ZOT_HOME at a fresh home dir. Iteration order of | |
| 382 | - | // knownProviders defines fallback priority. ollama is skipped: | |
| 383 | - | // it has no credential and would always "match". | |
| 384 | + | // knownProviders defines fallback priority. Local providers without a | |
| 385 | + | // default model are skipped because selecting either one here would | |
| 386 | + | // fail before the user can choose a model. | |
| 384 | 387 | for _, other := range knownProviders { | |
| 385 | - | if other == provName || other == "ollama" { | |
| 388 | + | if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID { | |
| 386 | 389 | continue | |
| 387 | 390 | } | |
| 388 | 391 | if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil { | |
| @@ -395,8 +398,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 395 | 398 | ||
| 396 | 399 | model := firstNonEmpty(args.Model, cfg.Model) | |
| 397 | 400 | if model == "" { | |
| 398 | - | if provName == "ollama" { | |
| 399 | - | return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)") | |
| 401 | + | if provName == "ollama" || provName == provider.LlamaCPPProviderID { | |
| 402 | + | return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName) | |
| 400 | 403 | } | |
| 401 | 404 | model = defaultModelForProvider(provName) | |
| 402 | 405 | } | |
| @@ -412,18 +415,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 412 | 415 | } | |
| 413 | 416 | } | |
| 414 | 417 | resolvedModel, err := provider.FindModel(provName, model) | |
| 415 | - | if err != nil && provName == "ollama" { | |
| 416 | - | // ollama is intentionally open-catalogue: any model id the | |
| 417 | - | // local server understands is valid, even if not in the | |
| 418 | - | // baked-in catalog. | |
| 418 | + | if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) { | |
| 419 | + | // Local providers are intentionally open-catalogue: any model id the | |
| 420 | + | // configured server understands is valid, even if it is not cached. | |
| 419 | 421 | resolvedModel = provider.Model{ | |
| 420 | - | Provider: "ollama", | |
| 422 | + | Provider: provName, | |
| 421 | 423 | ID: model, | |
| 422 | 424 | DisplayName: model, | |
| 423 | - | ContextWindow: 32768, | |
| 424 | - | MaxOutput: 8192, | |
| 425 | + | ContextWindow: 128000, | |
| 426 | + | MaxOutput: 16384, | |
| 425 | 427 | BaseURL: args.BaseURL, | |
| 426 | - | Source: "ollama", | |
| 428 | + | Source: provName, | |
| 427 | 429 | } | |
| 428 | 430 | err = nil | |
| 429 | 431 | } | |
| @@ -512,6 +514,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 512 | 514 | if args.BaseURL == "" && provName == "ollama" { | |
| 513 | 515 | args.BaseURL = "http://localhost:11434" | |
| 514 | 516 | } | |
| 517 | + | if args.BaseURL == "" && provName == provider.LlamaCPPProviderID { | |
| 518 | + | managementURL, _, configErr := ResolveLlamaCPPConfig() | |
| 519 | + | if configErr != nil { | |
| 520 | + | return Resolved{}, configErr | |
| 521 | + | } | |
| 522 | + | var baseErr error | |
| 523 | + | args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL) | |
| 524 | + | if baseErr != nil { | |
| 525 | + | return Resolved{}, baseErr | |
| 526 | + | } | |
| 527 | + | } | |
| 515 | 528 | ||
| 516 | 529 | // Insecure TLS is intentionally scoped to explicit custom endpoints. | |
| 517 | 530 | // Built-in provider base URLs, auth calls, and model discovery keep normal | |
| @@ -743,6 +756,12 @@ func (r Resolved) NewClient() provider.Client { | |||
| 743 | 756 | switch r.Provider { | |
| 744 | 757 | case "ollama": | |
| 745 | 758 | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | |
| 759 | + | case provider.LlamaCPPProviderID: | |
| 760 | + | credential := r.Credential | |
| 761 | + | if credential == "local" { | |
| 762 | + | credential = "" | |
| 763 | + | } | |
| 764 | + | return wrap(provider.NewOpenAI(credential, r.BaseURL)) | |
| 746 | 765 | case "kimi": | |
| 747 | 766 | // kimi-coding speaks anthropic-messages on api.kimi.com/coding. | |
| 748 | 767 | // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client. | |
| @@ -1013,6 +1032,8 @@ func envVarName(provider string) string { | |||
| 1013 | 1032 | return "GEMINI" | |
| 1014 | 1033 | case "ollama": | |
| 1015 | 1034 | return "OLLAMA" | |
| 1035 | + | case "llama.cpp": | |
| 1036 | + | return "LLAMA" | |
| 1016 | 1037 | case "moonshotai", "moonshotai-cn": | |
| 1017 | 1038 | return "MOONSHOT" | |
| 1018 | 1039 | case "groq": | |
| @@ -155,7 +155,7 @@ func defaultModelForProvider(prov string) string { | |||||
| 155 | return "deepseek-v4-pro" | 155 | return "deepseek-v4-pro" | ||
| 156 | case "google": | 156 | case "google": | ||
| 157 | return "gemini-2.5-pro" | 157 | return "gemini-2.5-pro" | ||
| 158 | - | case "ollama": | 158 | + | case "ollama", provider.LlamaCPPProviderID: |
| 159 | return "" | 159 | return "" | ||
| 160 | case "moonshotai", "moonshotai-cn": | 160 | case "moonshotai", "moonshotai-cn": | ||
| 161 | return "kimi-k2.6" | 161 | return "kimi-k2.6" | ||
| @@ -210,7 +210,7 @@ func defaultModelForProvider(prov string) string { | |||||
| 210 | // auto-fallback logic that picks any logged-in provider when the user's | 210 | // auto-fallback logic that picks any logged-in provider when the user's | ||
| 211 | // preferred one has no credentials. | 211 | // preferred one has no credentials. | ||
| 212 | var knownProviders = []string{ | 212 | var knownProviders = []string{ | ||
| 213 | - | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", | 213 | + | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, |
| 214 | "moonshotai", "moonshotai-cn", | 214 | "moonshotai", "moonshotai-cn", | ||
| 215 | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", | 215 | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", | ||
| 216 | "mistral", "zai", | 216 | "mistral", "zai", | ||
| @@ -271,6 +271,8 @@ var providerAliases = map[string]string{ | |||||
| 271 | "cloudflare": "cloudflare-workers-ai", | 271 | "cloudflare": "cloudflare-workers-ai", | ||
| 272 | "workers-ai": "cloudflare-workers-ai", | 272 | "workers-ai": "cloudflare-workers-ai", | ||
| 273 | "hf": "huggingface", | 273 | "hf": "huggingface", | ||
| 274 | + | "llama": provider.LlamaCPPProviderID, | |||
| 275 | + | "llamacpp": provider.LlamaCPPProviderID, | |||
| 274 | } | 276 | } | ||
| 275 | 277 | ||||
| 276 | // canonicalProvider normalises a user-supplied provider name: trims | 278 | // canonicalProvider normalises a user-supplied provider name: trims | ||
| @@ -379,10 +381,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 379 | // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE / | 381 | // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE / | ||
| 380 | // IAM keys) when no config.json pins the provider, such as after | 382 | // IAM keys) when no config.json pins the provider, such as after | ||
| 381 | // pointing ZOT_HOME at a fresh home dir. Iteration order of | 383 | // pointing ZOT_HOME at a fresh home dir. Iteration order of | ||
| 382 | - | // knownProviders defines fallback priority. ollama is skipped: | 384 | + | // knownProviders defines fallback priority. Local providers without a |
| 383 | - | // it has no credential and would always "match". | 385 | + | // default model are skipped because selecting either one here would |
| 386 | + | // fail before the user can choose a model. | |||
| 384 | for _, other := range knownProviders { | 387 | for _, other := range knownProviders { | ||
| 385 | - | if other == provName || other == "ollama" { | 388 | + | if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID { |
| 386 | continue | 389 | continue | ||
| 387 | } | 390 | } | ||
| 388 | if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil { | 391 | if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil { | ||
| @@ -395,8 +398,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 395 | 398 | ||||
| 396 | model := firstNonEmpty(args.Model, cfg.Model) | 399 | model := firstNonEmpty(args.Model, cfg.Model) | ||
| 397 | if model == "" { | 400 | if model == "" { | ||
| 398 | - | if provName == "ollama" { | 401 | + | if provName == "ollama" || provName == provider.LlamaCPPProviderID { |
| 399 | - | return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)") | 402 | + | return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName) |
| 400 | } | 403 | } | ||
| 401 | model = defaultModelForProvider(provName) | 404 | model = defaultModelForProvider(provName) | ||
| 402 | } | 405 | } | ||
| @@ -412,18 +415,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 412 | } | 415 | } | ||
| 413 | } | 416 | } | ||
| 414 | resolvedModel, err := provider.FindModel(provName, model) | 417 | resolvedModel, err := provider.FindModel(provName, model) | ||
| 415 | - | if err != nil && provName == "ollama" { | 418 | + | if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) { |
| 416 | - | // ollama is intentionally open-catalogue: any model id the | 419 | + | // Local providers are intentionally open-catalogue: any model id the |
| 417 | - | // local server understands is valid, even if not in the | 420 | + | // configured server understands is valid, even if it is not cached. |
| 418 | - | // baked-in catalog. | |||
| 419 | resolvedModel = provider.Model{ | 421 | resolvedModel = provider.Model{ | ||
| 420 | - | Provider: "ollama", | 422 | + | Provider: provName, |
| 421 | ID: model, | 423 | ID: model, | ||
| 422 | DisplayName: model, | 424 | DisplayName: model, | ||
| 423 | - | ContextWindow: 32768, | 425 | + | ContextWindow: 128000, |
| 424 | - | MaxOutput: 8192, | 426 | + | MaxOutput: 16384, |
| 425 | BaseURL: args.BaseURL, | 427 | BaseURL: args.BaseURL, | ||
| 426 | - | Source: "ollama", | 428 | + | Source: provName, |
| 427 | } | 429 | } | ||
| 428 | err = nil | 430 | err = nil | ||
| 429 | } | 431 | } | ||
| @@ -512,6 +514,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 512 | if args.BaseURL == "" && provName == "ollama" { | 514 | if args.BaseURL == "" && provName == "ollama" { | ||
| 513 | args.BaseURL = "http://localhost:11434" | 515 | args.BaseURL = "http://localhost:11434" | ||
| 514 | } | 516 | } | ||
| 517 | + | if args.BaseURL == "" && provName == provider.LlamaCPPProviderID { | |||
| 518 | + | managementURL, _, configErr := ResolveLlamaCPPConfig() | |||
| 519 | + | if configErr != nil { | |||
| 520 | + | return Resolved{}, configErr | |||
| 521 | + | } | |||
| 522 | + | var baseErr error | |||
| 523 | + | args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL) | |||
| 524 | + | if baseErr != nil { | |||
| 525 | + | return Resolved{}, baseErr | |||
| 526 | + | } | |||
| 527 | + | } | |||
| 515 | 528 | ||||
| 516 | // Insecure TLS is intentionally scoped to explicit custom endpoints. | 529 | // Insecure TLS is intentionally scoped to explicit custom endpoints. | ||
| 517 | // Built-in provider base URLs, auth calls, and model discovery keep normal | 530 | // Built-in provider base URLs, auth calls, and model discovery keep normal | ||
| @@ -743,6 +756,12 @@ func (r Resolved) NewClient() provider.Client { | |||||
| 743 | switch r.Provider { | 756 | switch r.Provider { | ||
| 744 | case "ollama": | 757 | case "ollama": | ||
| 745 | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | 758 | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | ||
| 759 | + | case provider.LlamaCPPProviderID: | |||
| 760 | + | credential := r.Credential | |||
| 761 | + | if credential == "local" { | |||
| 762 | + | credential = "" | |||
| 763 | + | } | |||
| 764 | + | return wrap(provider.NewOpenAI(credential, r.BaseURL)) | |||
| 746 | case "kimi": | 765 | case "kimi": | ||
| 747 | // kimi-coding speaks anthropic-messages on api.kimi.com/coding. | 766 | // kimi-coding speaks anthropic-messages on api.kimi.com/coding. | ||
| 748 | // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client. | 767 | // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client. | ||
| @@ -1013,6 +1032,8 @@ func envVarName(provider string) string { | |||||
| 1013 | return "GEMINI" | 1032 | return "GEMINI" | ||
| 1014 | case "ollama": | 1033 | case "ollama": | ||
| 1015 | return "OLLAMA" | 1034 | return "OLLAMA" | ||
| 1035 | + | case "llama.cpp": | |||
| 1036 | + | return "LLAMA" | |||
| 1016 | case "moonshotai", "moonshotai-cn": | 1037 | case "moonshotai", "moonshotai-cn": | ||
| 1017 | return "MOONSHOT" | 1038 | return "MOONSHOT" | ||
| 1018 | case "groq": | 1039 | case "groq": | ||
M packages/agent/build_test.go +39 -0
| @@ -237,6 +237,45 @@ func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) { | |||
| 237 | 237 | } | |
| 238 | 238 | } | |
| 239 | 239 | ||
| 240 | + | func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) { | |
| 241 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 242 | + | t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/") | |
| 243 | + | t.Setenv("LLAMA_API_KEY", "") | |
| 244 | + | provider.SetManagedModels(nil) | |
| 245 | + | t.Cleanup(func() { provider.SetManagedModels(nil) }) | |
| 246 | + | ||
| 247 | + | r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true) | |
| 248 | + | if err != nil { | |
| 249 | + | t.Fatalf("Resolve failed: %v", err) | |
| 250 | + | } | |
| 251 | + | if r.BaseURL != "http://127.0.0.1:8080/v1" { | |
| 252 | + | t.Fatalf("BaseURL = %q", r.BaseURL) | |
| 253 | + | } | |
| 254 | + | if r.Credential != "local" || !r.HasCredential() { | |
| 255 | + | t.Fatalf("credential = %q", r.Credential) | |
| 256 | + | } | |
| 257 | + | if got := r.NewClient().Name(); got != "openai" { | |
| 258 | + | t.Fatalf("client name = %q", got) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) { | |
| 263 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 264 | + | t.Setenv("LLAMA_BASE_URL", "") | |
| 265 | + | t.Setenv("LLAMA_API_KEY", "") | |
| 266 | + | if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil { | |
| 267 | + | t.Fatal(err) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true) | |
| 271 | + | if err != nil { | |
| 272 | + | t.Fatalf("Resolve failed: %v", err) | |
| 273 | + | } | |
| 274 | + | if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" { | |
| 275 | + | t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential) | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 240 | 279 | func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) { | |
| 241 | 280 | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 242 | 281 | t.Setenv("MY_COMPANY_API_KEY", "test-key") | |
| @@ -237,6 +237,45 @@ func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) { | |||||
| 237 | } | 237 | } | ||
| 238 | } | 238 | } | ||
| 239 | 239 | ||||
| 240 | + | func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) { | |||
| 241 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 242 | + | t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/") | |||
| 243 | + | t.Setenv("LLAMA_API_KEY", "") | |||
| 244 | + | provider.SetManagedModels(nil) | |||
| 245 | + | t.Cleanup(func() { provider.SetManagedModels(nil) }) | |||
| 246 | + | ||||
| 247 | + | r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true) | |||
| 248 | + | if err != nil { | |||
| 249 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 250 | + | } | |||
| 251 | + | if r.BaseURL != "http://127.0.0.1:8080/v1" { | |||
| 252 | + | t.Fatalf("BaseURL = %q", r.BaseURL) | |||
| 253 | + | } | |||
| 254 | + | if r.Credential != "local" || !r.HasCredential() { | |||
| 255 | + | t.Fatalf("credential = %q", r.Credential) | |||
| 256 | + | } | |||
| 257 | + | if got := r.NewClient().Name(); got != "openai" { | |||
| 258 | + | t.Fatalf("client name = %q", got) | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) { | |||
| 263 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 264 | + | t.Setenv("LLAMA_BASE_URL", "") | |||
| 265 | + | t.Setenv("LLAMA_API_KEY", "") | |||
| 266 | + | if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil { | |||
| 267 | + | t.Fatal(err) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true) | |||
| 271 | + | if err != nil { | |||
| 272 | + | t.Fatalf("Resolve failed: %v", err) | |||
| 273 | + | } | |||
| 274 | + | if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" { | |||
| 275 | + | t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential) | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | ||||
| 240 | func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) { | 279 | func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) { | ||
| 241 | t.Setenv("ZOT_HOME", t.TempDir()) | 280 | t.Setenv("ZOT_HOME", t.TempDir()) | ||
| 242 | t.Setenv("MY_COMPANY_API_KEY", "test-key") | 281 | t.Setenv("MY_COMPANY_API_KEY", "test-key") | ||
M packages/agent/cli.go +2 -0
| @@ -1012,6 +1012,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1012 | 1012 | Agent: ag, | |
| 1013 | 1013 | InitialInput: args.Prompt, | |
| 1014 | 1014 | AuthManager: mgr, | |
| 1015 | + | LlamaCPPConfig: ResolveLlamaCPPConfig, | |
| 1016 | + | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | |
| 1015 | 1017 | BuildAgent: buildAgent, | |
| 1016 | 1018 | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | |
| 1017 | 1019 | BuildAgentFor: buildAgentFor, | |
| @@ -1012,6 +1012,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1012 | Agent: ag, | 1012 | Agent: ag, | ||
| 1013 | InitialInput: args.Prompt, | 1013 | InitialInput: args.Prompt, | ||
| 1014 | AuthManager: mgr, | 1014 | AuthManager: mgr, | ||
| 1015 | + | LlamaCPPConfig: ResolveLlamaCPPConfig, | |||
| 1016 | + | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | |||
| 1015 | BuildAgent: buildAgent, | 1017 | BuildAgent: buildAgent, | ||
| 1016 | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | 1018 | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | ||
| 1017 | BuildAgentFor: buildAgentFor, | 1019 | BuildAgentFor: buildAgentFor, | ||
M packages/agent/config.go +32 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "strings" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | providerpkg "github.com/patriceckhart/zot/packages/provider" | |
| 15 | 16 | "github.com/patriceckhart/zot/packages/provider/auth" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| @@ -216,6 +217,29 @@ func SaveConfig(c Config) error { | |||
| 216 | 217 | // AuthStoreFor returns the auth.Store backed by AuthPath(). | |
| 217 | 218 | func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) } | |
| 218 | 219 | ||
| 220 | + | // ResolveLlamaCPPConfig resolves the router URL and optional API key. The | |
| 221 | + | // environment overrides the credential stored through /login. | |
| 222 | + | func ResolveLlamaCPPConfig() (baseURL, apiKey string, err error) { | |
| 223 | + | baseURL = strings.TrimSpace(os.Getenv("LLAMA_BASE_URL")) | |
| 224 | + | apiKey = strings.TrimSpace(os.Getenv("LLAMA_API_KEY")) | |
| 225 | + | if baseURL == "" { | |
| 226 | + | var storedKey string | |
| 227 | + | var loadErr error | |
| 228 | + | baseURL, storedKey, loadErr = AuthStoreFor().EndpointCredential(providerpkg.LlamaCPPProviderID) | |
| 229 | + | if loadErr != nil { | |
| 230 | + | return "", "", loadErr | |
| 231 | + | } | |
| 232 | + | if apiKey == "" { | |
| 233 | + | apiKey = storedKey | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | if baseURL == "" { | |
| 237 | + | return "", apiKey, nil | |
| 238 | + | } | |
| 239 | + | baseURL, err = providerpkg.NormalizeLlamaCPPURL(baseURL) | |
| 240 | + | return baseURL, apiKey, err | |
| 241 | + | } | |
| 242 | + | ||
| 219 | 243 | // ResolveCredential returns the credential (api key or oauth access | |
| 220 | 244 | // token), the method ("apikey"/"oauth"), and an error when no | |
| 221 | 245 | // credential is available. | |
| @@ -283,6 +307,14 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||
| 283 | 307 | if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { | |
| 284 | 308 | return v, "apikey", "", nil | |
| 285 | 309 | } | |
| 310 | + | case "llama.cpp": | |
| 311 | + | baseURL, apiKey, resolveErr := ResolveLlamaCPPConfig() | |
| 312 | + | if resolveErr != nil { | |
| 313 | + | return "", "", "", resolveErr | |
| 314 | + | } | |
| 315 | + | if baseURL != "" { | |
| 316 | + | return firstNonEmpty(apiKey, "local"), "apikey", "", nil | |
| 317 | + | } | |
| 286 | 318 | case "moonshotai", "moonshotai-cn": | |
| 287 | 319 | // Moonshot direct API (separate from kimi-coding, which is the | |
| 288 | 320 | // Anthropic-Messages-fronted /coding endpoint with subscription OAuth). | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "strings" | 12 | "strings" | ||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | + | providerpkg "github.com/patriceckhart/zot/packages/provider" | |||
| 15 | "github.com/patriceckhart/zot/packages/provider/auth" | 16 | "github.com/patriceckhart/zot/packages/provider/auth" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| @@ -216,6 +217,29 @@ func SaveConfig(c Config) error { | |||||
| 216 | // AuthStoreFor returns the auth.Store backed by AuthPath(). | 217 | // AuthStoreFor returns the auth.Store backed by AuthPath(). | ||
| 217 | func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) } | 218 | func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) } | ||
| 218 | 219 | ||||
| 220 | + | // ResolveLlamaCPPConfig resolves the router URL and optional API key. The | |||
| 221 | + | // environment overrides the credential stored through /login. | |||
| 222 | + | func ResolveLlamaCPPConfig() (baseURL, apiKey string, err error) { | |||
| 223 | + | baseURL = strings.TrimSpace(os.Getenv("LLAMA_BASE_URL")) | |||
| 224 | + | apiKey = strings.TrimSpace(os.Getenv("LLAMA_API_KEY")) | |||
| 225 | + | if baseURL == "" { | |||
| 226 | + | var storedKey string | |||
| 227 | + | var loadErr error | |||
| 228 | + | baseURL, storedKey, loadErr = AuthStoreFor().EndpointCredential(providerpkg.LlamaCPPProviderID) | |||
| 229 | + | if loadErr != nil { | |||
| 230 | + | return "", "", loadErr | |||
| 231 | + | } | |||
| 232 | + | if apiKey == "" { | |||
| 233 | + | apiKey = storedKey | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | if baseURL == "" { | |||
| 237 | + | return "", apiKey, nil | |||
| 238 | + | } | |||
| 239 | + | baseURL, err = providerpkg.NormalizeLlamaCPPURL(baseURL) | |||
| 240 | + | return baseURL, apiKey, err | |||
| 241 | + | } | |||
| 242 | + | ||||
| 219 | // ResolveCredential returns the credential (api key or oauth access | 243 | // ResolveCredential returns the credential (api key or oauth access | ||
| 220 | // token), the method ("apikey"/"oauth"), and an error when no | 244 | // token), the method ("apikey"/"oauth"), and an error when no | ||
| 221 | // credential is available. | 245 | // credential is available. | ||
| @@ -283,6 +307,14 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s | |||||
| 283 | if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { | 307 | if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { | ||
| 284 | return v, "apikey", "", nil | 308 | return v, "apikey", "", nil | ||
| 285 | } | 309 | } | ||
| 310 | + | case "llama.cpp": | |||
| 311 | + | baseURL, apiKey, resolveErr := ResolveLlamaCPPConfig() | |||
| 312 | + | if resolveErr != nil { | |||
| 313 | + | return "", "", "", resolveErr | |||
| 314 | + | } | |||
| 315 | + | if baseURL != "" { | |||
| 316 | + | return firstNonEmpty(apiKey, "local"), "apikey", "", nil | |||
| 317 | + | } | |||
| 286 | case "moonshotai", "moonshotai-cn": | 318 | case "moonshotai", "moonshotai-cn": | ||
| 287 | // Moonshot direct API (separate from kimi-coding, which is the | 319 | // Moonshot direct API (separate from kimi-coding, which is the | ||
| 288 | // Anthropic-Messages-fronted /coding endpoint with subscription OAuth). | 320 | // Anthropic-Messages-fronted /coding endpoint with subscription OAuth). | ||
M packages/agent/modelsync.go +26 -1
| @@ -114,7 +114,7 @@ func ValidateAndRepairConfig() { | |||
| 114 | 114 | cfg.Model, m.Provider, cfg.Provider, fix) | |
| 115 | 115 | cfg.Model = fix | |
| 116 | 116 | changed = true | |
| 117 | - | } else if cfg.Provider != "ollama" { | |
| 117 | + | } else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID { | |
| 118 | 118 | // Model id not in any catalog. Reset to provider's default. | |
| 119 | 119 | fix := defaultModelForProvider(cfg.Provider) | |
| 120 | 120 | fmt.Fprintf(os.Stderr, | |
| @@ -141,6 +141,31 @@ func ValidateAndRepairConfig() { | |||
| 141 | 141 | // the baked-in catalog if this fails. | |
| 142 | 142 | func RefreshModelsAsync() { | |
| 143 | 143 | go refreshModels() | |
| 144 | + | go func() { | |
| 145 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 146 | + | defer cancel() | |
| 147 | + | _ = RefreshLlamaCPPModels(ctx) | |
| 148 | + | }() | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // RefreshLlamaCPPModels adds the router's currently loaded models to the | |
| 152 | + | // active catalog. Unloaded models remain in the management UI and cannot be | |
| 153 | + | // selected for inference until they are loaded. | |
| 154 | + | func RefreshLlamaCPPModels(ctx context.Context) error { | |
| 155 | + | baseURL, apiKey, err := ResolveLlamaCPPConfig() | |
| 156 | + | if err != nil || baseURL == "" { | |
| 157 | + | return err | |
| 158 | + | } | |
| 159 | + | client, err := provider.NewLlamaCPPClient(baseURL, apiKey) | |
| 160 | + | if err != nil { | |
| 161 | + | return err | |
| 162 | + | } | |
| 163 | + | models, err := client.List(ctx, false) | |
| 164 | + | if err != nil { | |
| 165 | + | return err | |
| 166 | + | } | |
| 167 | + | provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL)) | |
| 168 | + | return nil | |
| 144 | 169 | } | |
| 145 | 170 | ||
| 146 | 171 | func refreshModels() { | |
| @@ -114,7 +114,7 @@ func ValidateAndRepairConfig() { | |||||
| 114 | cfg.Model, m.Provider, cfg.Provider, fix) | 114 | cfg.Model, m.Provider, cfg.Provider, fix) | ||
| 115 | cfg.Model = fix | 115 | cfg.Model = fix | ||
| 116 | changed = true | 116 | changed = true | ||
| 117 | - | } else if cfg.Provider != "ollama" { | 117 | + | } else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID { |
| 118 | // Model id not in any catalog. Reset to provider's default. | 118 | // Model id not in any catalog. Reset to provider's default. | ||
| 119 | fix := defaultModelForProvider(cfg.Provider) | 119 | fix := defaultModelForProvider(cfg.Provider) | ||
| 120 | fmt.Fprintf(os.Stderr, | 120 | fmt.Fprintf(os.Stderr, | ||
| @@ -141,6 +141,31 @@ func ValidateAndRepairConfig() { | |||||
| 141 | // the baked-in catalog if this fails. | 141 | // the baked-in catalog if this fails. | ||
| 142 | func RefreshModelsAsync() { | 142 | func RefreshModelsAsync() { | ||
| 143 | go refreshModels() | 143 | go refreshModels() | ||
| 144 | + | go func() { | |||
| 145 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |||
| 146 | + | defer cancel() | |||
| 147 | + | _ = RefreshLlamaCPPModels(ctx) | |||
| 148 | + | }() | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // RefreshLlamaCPPModels adds the router's currently loaded models to the | |||
| 152 | + | // active catalog. Unloaded models remain in the management UI and cannot be | |||
| 153 | + | // selected for inference until they are loaded. | |||
| 154 | + | func RefreshLlamaCPPModels(ctx context.Context) error { | |||
| 155 | + | baseURL, apiKey, err := ResolveLlamaCPPConfig() | |||
| 156 | + | if err != nil || baseURL == "" { | |||
| 157 | + | return err | |||
| 158 | + | } | |||
| 159 | + | client, err := provider.NewLlamaCPPClient(baseURL, apiKey) | |||
| 160 | + | if err != nil { | |||
| 161 | + | return err | |||
| 162 | + | } | |||
| 163 | + | models, err := client.List(ctx, false) | |||
| 164 | + | if err != nil { | |||
| 165 | + | return err | |||
| 166 | + | } | |||
| 167 | + | provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL)) | |||
| 168 | + | return nil | |||
| 144 | } | 169 | } | ||
| 145 | 170 | ||||
| 146 | func refreshModels() { | 171 | func refreshModels() { | ||
M packages/agent/modelsync_test.go +39 -0
| @@ -1,12 +1,51 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 5 | 9 | "os" | |
| 6 | 10 | "path/filepath" | |
| 7 | 11 | "testing" | |
| 12 | + | ||
| 13 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 8 | 14 | ) | |
| 9 | 15 | ||
| 16 | + | func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) { | |
| 17 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 18 | + | if r.URL.Path != "/models" { | |
| 19 | + | http.NotFound(w, r) | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | fmt.Fprint(w, `{"data":[{"id":"loaded","status":{"value":"loaded"},"meta":{"n_ctx":32768}},{"id":"offline","status":{"value":"unloaded"}}]}`) | |
| 23 | + | })) | |
| 24 | + | defer server.Close() | |
| 25 | + | ||
| 26 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 27 | + | t.Setenv("LLAMA_BASE_URL", "") | |
| 28 | + | if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | provider.SetManagedModels(nil) | |
| 32 | + | t.Cleanup(func() { provider.SetManagedModels(nil) }) | |
| 33 | + | ||
| 34 | + | if err := RefreshLlamaCPPModels(context.Background()); err != nil { | |
| 35 | + | t.Fatal(err) | |
| 36 | + | } | |
| 37 | + | loaded, err := provider.FindModel(provider.LlamaCPPProviderID, "loaded") | |
| 38 | + | if err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | if loaded.ContextWindow != 32768 || loaded.BaseURL != server.URL+"/v1" { | |
| 42 | + | t.Fatalf("loaded model = %+v", loaded) | |
| 43 | + | } | |
| 44 | + | if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil { | |
| 45 | + | t.Fatal("unloaded model must not be selectable") | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 10 | 49 | // TestValidateAndRepairConfig_MismatchedPair simulates the bug from a | |
| 11 | 50 | // stale /model switch: provider=anthropic but model=kimi-for-coding | |
| 12 | 51 | // (which belongs to provider=kimi). The validator should rewrite the | |
| @@ -1,12 +1,51 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "fmt" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 5 | "os" | 9 | "os" | ||
| 6 | "path/filepath" | 10 | "path/filepath" | ||
| 7 | "testing" | 11 | "testing" | ||
| 12 | + | ||||
| 13 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 8 | ) | 14 | ) | ||
| 9 | 15 | ||||
| 16 | + | func TestRefreshLlamaCPPModelsAddsOnlyLoadedModels(t *testing.T) { | |||
| 17 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 18 | + | if r.URL.Path != "/models" { | |||
| 19 | + | http.NotFound(w, r) | |||
| 20 | + | return | |||
| 21 | + | } | |||
| 22 | + | fmt.Fprint(w, `{"data":[{"id":"loaded","status":{"value":"loaded"},"meta":{"n_ctx":32768}},{"id":"offline","status":{"value":"unloaded"}}]}`) | |||
| 23 | + | })) | |||
| 24 | + | defer server.Close() | |||
| 25 | + | ||||
| 26 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 27 | + | t.Setenv("LLAMA_BASE_URL", "") | |||
| 28 | + | if err := AuthStoreFor().SetEndpointCredential(provider.LlamaCPPProviderID, server.URL, ""); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | provider.SetManagedModels(nil) | |||
| 32 | + | t.Cleanup(func() { provider.SetManagedModels(nil) }) | |||
| 33 | + | ||||
| 34 | + | if err := RefreshLlamaCPPModels(context.Background()); err != nil { | |||
| 35 | + | t.Fatal(err) | |||
| 36 | + | } | |||
| 37 | + | loaded, err := provider.FindModel(provider.LlamaCPPProviderID, "loaded") | |||
| 38 | + | if err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | if loaded.ContextWindow != 32768 || loaded.BaseURL != server.URL+"/v1" { | |||
| 42 | + | t.Fatalf("loaded model = %+v", loaded) | |||
| 43 | + | } | |||
| 44 | + | if _, err := provider.FindModel(provider.LlamaCPPProviderID, "offline"); err == nil { | |||
| 45 | + | t.Fatal("unloaded model must not be selectable") | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 10 | // TestValidateAndRepairConfig_MismatchedPair simulates the bug from a | 49 | // TestValidateAndRepairConfig_MismatchedPair simulates the bug from a | ||
| 11 | // stale /model switch: provider=anthropic but model=kimi-for-coding | 50 | // stale /model switch: provider=anthropic but model=kimi-for-coding | ||
| 12 | // (which belongs to provider=kimi). The validator should rewrite the | 51 | // (which belongs to provider=kimi). The validator should rewrite the | ||
M packages/agent/modes/help.go +7 -1
| @@ -36,7 +36,7 @@ var helpKeyRows = [][2]string{ | |||
| 36 | 36 | // section it lives in. The width is computed from the longest label | |
| 37 | 37 | // across BOTH lists, with a minimum of 14 cells so changes to either | |
| 38 | 38 | // list don't compress the column visually. | |
| 39 | - | func renderHelpBlock(th tui.Theme, width int) []string { | |
| 39 | + | func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string { | |
| 40 | 40 | if width < 20 { | |
| 41 | 41 | width = 20 | |
| 42 | 42 | } | |
| @@ -50,6 +50,9 @@ func renderHelpBlock(th tui.Theme, width int) []string { | |||
| 50 | 50 | // already, leaving its description mis-aligned). | |
| 51 | 51 | labelWidth := 14 | |
| 52 | 52 | for _, c := range slashCatalog { | |
| 53 | + | if c.Name == "/llama" && !llamaConfigured { | |
| 54 | + | continue | |
| 55 | + | } | |
| 53 | 56 | if n := runewidth.StringWidth(c.Name); n > labelWidth { | |
| 54 | 57 | labelWidth = n | |
| 55 | 58 | } | |
| @@ -74,6 +77,9 @@ func renderHelpBlock(th tui.Theme, width int) []string { | |||
| 74 | 77 | // commands section | |
| 75 | 78 | out = append(out, tui.Bold("slash commands:")) | |
| 76 | 79 | for _, c := range slashCatalog { | |
| 80 | + | if c.Name == "/llama" && !llamaConfigured { | |
| 81 | + | continue | |
| 82 | + | } | |
| 77 | 83 | out = append(out, fmt.Sprintf(" %s %s", | |
| 78 | 84 | th.FG256(th.Accent, pad(c.Name)), | |
| 79 | 85 | th.FG256(th.Muted, c.Desc))) | |
| @@ -36,7 +36,7 @@ var helpKeyRows = [][2]string{ | |||||
| 36 | // section it lives in. The width is computed from the longest label | 36 | // section it lives in. The width is computed from the longest label | ||
| 37 | // across BOTH lists, with a minimum of 14 cells so changes to either | 37 | // across BOTH lists, with a minimum of 14 cells so changes to either | ||
| 38 | // list don't compress the column visually. | 38 | // list don't compress the column visually. | ||
| 39 | - | func renderHelpBlock(th tui.Theme, width int) []string { | 39 | + | func renderHelpBlock(th tui.Theme, width int, llamaConfigured bool) []string { |
| 40 | if width < 20 { | 40 | if width < 20 { | ||
| 41 | width = 20 | 41 | width = 20 | ||
| 42 | } | 42 | } | ||
| @@ -50,6 +50,9 @@ func renderHelpBlock(th tui.Theme, width int) []string { | |||||
| 50 | // already, leaving its description mis-aligned). | 50 | // already, leaving its description mis-aligned). | ||
| 51 | labelWidth := 14 | 51 | labelWidth := 14 | ||
| 52 | for _, c := range slashCatalog { | 52 | for _, c := range slashCatalog { | ||
| 53 | + | if c.Name == "/llama" && !llamaConfigured { | |||
| 54 | + | continue | |||
| 55 | + | } | |||
| 53 | if n := runewidth.StringWidth(c.Name); n > labelWidth { | 56 | if n := runewidth.StringWidth(c.Name); n > labelWidth { | ||
| 54 | labelWidth = n | 57 | labelWidth = n | ||
| 55 | } | 58 | } | ||
| @@ -74,6 +77,9 @@ func renderHelpBlock(th tui.Theme, width int) []string { | |||||
| 74 | // commands section | 77 | // commands section | ||
| 75 | out = append(out, tui.Bold("slash commands:")) | 78 | out = append(out, tui.Bold("slash commands:")) | ||
| 76 | for _, c := range slashCatalog { | 79 | for _, c := range slashCatalog { | ||
| 80 | + | if c.Name == "/llama" && !llamaConfigured { | |||
| 81 | + | continue | |||
| 82 | + | } | |||
| 77 | out = append(out, fmt.Sprintf(" %s %s", | 83 | out = append(out, fmt.Sprintf(" %s %s", | ||
| 78 | th.FG256(th.Accent, pad(c.Name)), | 84 | th.FG256(th.Accent, pad(c.Name)), | ||
| 79 | th.FG256(th.Muted, c.Desc))) | 85 | th.FG256(th.Muted, c.Desc))) | ||
A packages/agent/modes/help_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | |
| 11 | + | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n") | |
| 12 | + | if strings.Contains(without, "/llama") { | |
| 13 | + | t.Fatalf("help exposed /llama without login: %q", without) | |
| 14 | + | } | |
| 15 | + | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true), "\n") | |
| 16 | + | if !strings.Contains(with, "/llama") { | |
| 17 | + | t.Fatalf("help omitted /llama with login: %q", with) | |
| 18 | + | } | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHelpShowsLlamaOnlyWhenConfigured(t *testing.T) { | |||
| 11 | + | without := strings.Join(renderHelpBlock(tui.Theme{}, 80, false), "\n") | |||
| 12 | + | if strings.Contains(without, "/llama") { | |||
| 13 | + | t.Fatalf("help exposed /llama without login: %q", without) | |||
| 14 | + | } | |||
| 15 | + | with := strings.Join(renderHelpBlock(tui.Theme{}, 80, true), "\n") | |||
| 16 | + | if !strings.Contains(with, "/llama") { | |||
| 17 | + | t.Fatalf("help omitted /llama with login: %q", with) | |||
| 18 | + | } | |||
| 19 | + | } | |||
M packages/agent/modes/interactive.go +114 -7
| @@ -114,6 +114,15 @@ type InteractiveConfig struct { | |||
| 114 | 114 | // Auth is required. When the user runs /login, Interactive talks to | |
| 115 | 115 | // AuthManager to open a browser and wait for the callback. | |
| 116 | 116 | AuthManager *auth.Manager | |
| 117 | + | ||
| 118 | + | // LlamaCPPConfig resolves the router URL and optional API key from env or | |
| 119 | + | // the credential saved by /login. | |
| 120 | + | LlamaCPPConfig func() (baseURL, apiKey string, err error) | |
| 121 | + | ||
| 122 | + | // RefreshLlamaCPPModels synchronizes currently loaded router models into | |
| 123 | + | // the catalog before /model opens. | |
| 124 | + | RefreshLlamaCPPModels func(context.Context) error | |
| 125 | + | ||
| 117 | 126 | // BuildAgent is called after a successful login to (re)construct the | |
| 118 | 127 | // agent with the fresh credential. It returns the new agent and | |
| 119 | 128 | // the concrete provider/model in use. | |
| @@ -253,6 +262,8 @@ type ChangelogPayload struct { | |||
| 253 | 262 | URL string | |
| 254 | 263 | } | |
| 255 | 264 | ||
| 265 | + | type modelRefreshResult struct{ err error } | |
| 266 | + | ||
| 256 | 267 | // Interactive is the TUI chat loop. | |
| 257 | 268 | type chatCacheKey struct { | |
| 258 | 269 | cols int | |
| @@ -340,6 +351,7 @@ type Interactive struct { | |||
| 340 | 351 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | |
| 341 | 352 | busy bool | |
| 342 | 353 | dirty chan struct{} | |
| 354 | + | modelRefresh chan modelRefreshResult | |
| 343 | 355 | cancelTurn context.CancelFunc | |
| 344 | 356 | scrollOffset int // rows from the bottom; 0 = pinned to latest | |
| 345 | 357 | prevScrollOffset int // last value redraw snapped against; tracks intent | |
| @@ -391,6 +403,7 @@ type Interactive struct { | |||
| 391 | 403 | ||
| 392 | 404 | dialog *loginDialog | |
| 393 | 405 | modelDialog *modelDialog | |
| 406 | + | llamaDialog *llamaDialog | |
| 394 | 407 | rescueDialog *rescueDialog | |
| 395 | 408 | sessionDialog *sessionDialog | |
| 396 | 409 | swarmDialog *swarmDialog | |
| @@ -407,6 +420,7 @@ type Interactive struct { | |||
| 407 | 420 | sessionOpsDialog *sessionOpsDialog | |
| 408 | 421 | sessionTreeDialog *sessionTreeDialog | |
| 409 | 422 | extPanel *extPanelDialog | |
| 423 | + | llamaConfigured bool | |
| 410 | 424 | ||
| 411 | 425 | // swarmWatch tracks auto-swarm sub-agents the main agent spawned | |
| 412 | 426 | // via swarm_spawn. Each entry holds the agent + the task text; | |
| @@ -520,8 +534,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 520 | 534 | toolCalls: map[string]*tui.ToolCallView{}, | |
| 521 | 535 | toolGate: map[string]int{}, | |
| 522 | 536 | dirty: make(chan struct{}, 8), | |
| 537 | + | modelRefresh: make(chan modelRefreshResult, 1), | |
| 523 | 538 | dialog: newLoginDialog(), | |
| 524 | 539 | modelDialog: newModelDialog(), | |
| 540 | + | llamaDialog: newLlamaDialog(), | |
| 525 | 541 | rescueDialog: newRescueDialog(), | |
| 526 | 542 | sessionDialog: newSessionDialog(), | |
| 527 | 543 | swarmDialog: newSwarmDialog(), | |
| @@ -543,6 +559,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 543 | 559 | } | |
| 544 | 560 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | |
| 545 | 561 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | |
| 562 | + | if cfg.LlamaCPPConfig != nil { | |
| 563 | + | baseURL, _, err := cfg.LlamaCPPConfig() | |
| 564 | + | i.llamaConfigured = err == nil && baseURL != "" | |
| 565 | + | } | |
| 546 | 566 | if cfg.Agent != nil { | |
| 547 | 567 | i.agent = cfg.Agent | |
| 548 | 568 | i.view.Messages = cfg.Agent.Messages() | |
| @@ -768,6 +788,9 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 768 | 788 | case ev := <-authEvents: | |
| 769 | 789 | i.handleAuthEvent(ev) | |
| 770 | 790 | i.invalidate() | |
| 791 | + | case result := <-i.modelRefresh: | |
| 792 | + | i.openModelPickerAfterRefresh(result.err) | |
| 793 | + | i.invalidate() | |
| 771 | 794 | case info, ok := <-updates: | |
| 772 | 795 | if ok && info.Available { | |
| 773 | 796 | i.mu.Lock() | |
| @@ -1102,6 +1125,8 @@ func (i *Interactive) redraw() { | |||
| 1102 | 1125 | dialog = i.dialog.Render(i.cfg.Theme, cols) | |
| 1103 | 1126 | case i.modelDialog.Active(): | |
| 1104 | 1127 | dialog = i.modelDialog.Render(i.cfg.Theme, cols) | |
| 1128 | + | case i.llamaDialog.Active(): | |
| 1129 | + | dialog = i.llamaDialog.Render(i.cfg.Theme, cols) | |
| 1105 | 1130 | case i.rescueDialog.Active(): | |
| 1106 | 1131 | dialog = i.rescueDialog.Render(i.cfg.Theme, cols) | |
| 1107 | 1132 | case i.sessionDialog.Active(): | |
| @@ -1150,6 +1175,7 @@ func (i *Interactive) redraw() { | |||
| 1150 | 1175 | // Feed extension-registered commands into the suggester first so | |
| 1151 | 1176 | // they show up in tab-complete + the popup alongside the built-ins. | |
| 1152 | 1177 | i.suggest.SetJailed(i.cfg.Sandbox.Locked()) | |
| 1178 | + | i.suggest.SetLlamaConfigured(i.llamaConfigured) | |
| 1153 | 1179 | if i.cfg.Extensions != nil { | |
| 1154 | 1180 | catalog := i.cfg.Extensions.Commands() | |
| 1155 | 1181 | extra := make([]slashCommand, 0, len(catalog)) | |
| @@ -1531,6 +1557,15 @@ func (i *Interactive) redraw() { | |||
| 1531 | 1557 | cursorCol = c | |
| 1532 | 1558 | } | |
| 1533 | 1559 | } | |
| 1560 | + | if i.llamaDialog.Active() { | |
| 1561 | + | if r, c := i.llamaDialog.CursorPos(); r >= 0 { | |
| 1562 | + | cursorRow = dialogLead + r | |
| 1563 | + | cursorCol = c | |
| 1564 | + | } else { | |
| 1565 | + | cursorRow = -1 | |
| 1566 | + | cursorCol = 0 | |
| 1567 | + | } | |
| 1568 | + | } | |
| 1534 | 1569 | if i.sessionDialog.Active() { | |
| 1535 | 1570 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | |
| 1536 | 1571 | cursorRow = dialogLead + r | |
| @@ -1865,6 +1900,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1865 | 1900 | if act.SubmitCode != "" { | |
| 1866 | 1901 | i.submitManualOAuthCode(act.SubmitCode) | |
| 1867 | 1902 | } | |
| 1903 | + | if act.SaveLlama { | |
| 1904 | + | i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey) | |
| 1905 | + | } | |
| 1868 | 1906 | return false | |
| 1869 | 1907 | } | |
| 1870 | 1908 | if i.modelDialog.Active() { | |
| @@ -1887,6 +1925,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1887 | 1925 | } | |
| 1888 | 1926 | return false | |
| 1889 | 1927 | } | |
| 1928 | + | if i.llamaDialog.Active() { | |
| 1929 | + | i.llamaDialog.HandleKey(k) | |
| 1930 | + | i.invalidate() | |
| 1931 | + | return false | |
| 1932 | + | } | |
| 1890 | 1933 | if i.rescueDialog.Active() { | |
| 1891 | 1934 | if k.Kind == tui.KeyCtrlC { | |
| 1892 | 1935 | i.rescueDialog.Close() | |
| @@ -3857,7 +3900,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 3857 | 3900 | i.mu.Unlock() | |
| 3858 | 3901 | case "/help": | |
| 3859 | 3902 | i.mu.Lock() | |
| 3860 | - | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols()) | |
| 3903 | + | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | |
| 3861 | 3904 | i.statusErr = "" | |
| 3862 | 3905 | i.statusOK = "" | |
| 3863 | 3906 | // Pin the viewport to the newest content so the help block, | |
| @@ -3880,12 +3923,36 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 3880 | 3923 | case "/model": | |
| 3881 | 3924 | if len(parts) >= 2 { | |
| 3882 | 3925 | i.applyModelSelection("", parts[1]) | |
| 3926 | + | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | |
| 3927 | + | i.mu.Lock() | |
| 3928 | + | i.statusOK = "refreshing models" | |
| 3929 | + | i.statusErr = "" | |
| 3930 | + | i.mu.Unlock() | |
| 3931 | + | go func() { | |
| 3932 | + | refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 3933 | + | defer cancel() | |
| 3934 | + | i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)} | |
| 3935 | + | }() | |
| 3883 | 3936 | } else { | |
| 3884 | - | var loggedIn []string | |
| 3885 | - | if i.cfg.LoggedInProviders != nil { | |
| 3886 | - | loggedIn = i.cfg.LoggedInProviders() | |
| 3887 | - | } | |
| 3888 | - | i.modelDialog.Open(i.cfg.Model, loggedIn) | |
| 3937 | + | i.openModelPickerAfterRefresh(nil) | |
| 3938 | + | } | |
| 3939 | + | case "/llama": | |
| 3940 | + | if i.cfg.LlamaCPPConfig == nil { | |
| 3941 | + | i.mu.Lock() | |
| 3942 | + | i.statusErr = "llama.cpp configuration is unavailable" | |
| 3943 | + | i.statusOK = "" | |
| 3944 | + | i.mu.Unlock() | |
| 3945 | + | break | |
| 3946 | + | } | |
| 3947 | + | baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig() | |
| 3948 | + | if configErr == nil { | |
| 3949 | + | configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate) | |
| 3950 | + | } | |
| 3951 | + | if configErr != nil { | |
| 3952 | + | i.mu.Lock() | |
| 3953 | + | i.statusErr = configErr.Error() | |
| 3954 | + | i.statusOK = "" | |
| 3955 | + | i.mu.Unlock() | |
| 3889 | 3956 | } | |
| 3890 | 3957 | case "/settings": | |
| 3891 | 3958 | i.openSettingsDialog() | |
| @@ -4040,6 +4107,22 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4040 | 4107 | return false | |
| 4041 | 4108 | } | |
| 4042 | 4109 | ||
| 4110 | + | func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |
| 4111 | + | var loggedIn []string | |
| 4112 | + | if i.cfg.LoggedInProviders != nil { | |
| 4113 | + | loggedIn = i.cfg.LoggedInProviders() | |
| 4114 | + | } | |
| 4115 | + | i.modelDialog.Open(i.cfg.Model, loggedIn) | |
| 4116 | + | i.mu.Lock() | |
| 4117 | + | if refreshErr != nil { | |
| 4118 | + | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | |
| 4119 | + | i.statusOK = "" | |
| 4120 | + | } else if i.statusOK == "refreshing models" { | |
| 4121 | + | i.statusOK = "" | |
| 4122 | + | } | |
| 4123 | + | i.mu.Unlock() | |
| 4124 | + | } | |
| 4125 | + | ||
| 4043 | 4126 | // openLogoutDialog shows the provider picker for `/logout` with no | |
| 4044 | 4127 | // argument. Only providers the user is currently logged into are | |
| 4045 | 4128 | // listed, plus an "all" entry when more than one is present. If | |
| @@ -4091,7 +4174,7 @@ func (i *Interactive) openLogoutDialog() { | |||
| 4091 | 4174 | items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"}) | |
| 4092 | 4175 | } | |
| 4093 | 4176 | for p, c := range creds.AdditionalAPIKeyCreds { | |
| 4094 | - | if c.APIKey != "" { | |
| 4177 | + | if c.APIKey != "" || c.BaseURL != "" { | |
| 4095 | 4178 | items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"}) | |
| 4096 | 4179 | } | |
| 4097 | 4180 | } | |
| @@ -4182,8 +4265,15 @@ func (i *Interactive) doLogout(target string) { | |||
| 4182 | 4265 | } | |
| 4183 | 4266 | } | |
| 4184 | 4267 | ||
| 4268 | + | llamaConfigured := i.llamaConfigured | |
| 4269 | + | if i.cfg.LlamaCPPConfig != nil { | |
| 4270 | + | baseURL, _, err := i.cfg.LlamaCPPConfig() | |
| 4271 | + | llamaConfigured = err == nil && baseURL != "" | |
| 4272 | + | } | |
| 4273 | + | ||
| 4185 | 4274 | i.mu.Lock() | |
| 4186 | 4275 | defer i.mu.Unlock() | |
| 4276 | + | i.llamaConfigured = llamaConfigured | |
| 4187 | 4277 | if len(errs) > 0 { | |
| 4188 | 4278 | i.statusErr = "logout errors: " + strings.Join(errs, "; ") | |
| 4189 | 4279 | return | |
| @@ -4268,6 +4358,23 @@ func providerSetupInfo(provider string) (string, []string, bool) { | |||
| 4268 | 4358 | } | |
| 4269 | 4359 | } | |
| 4270 | 4360 | ||
| 4361 | + | func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) { | |
| 4362 | + | if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil { | |
| 4363 | + | i.dialog.ShowResult(false, "auth store is unavailable") | |
| 4364 | + | return | |
| 4365 | + | } | |
| 4366 | + | if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil { | |
| 4367 | + | i.dialog.ShowResult(false, err.Error()) | |
| 4368 | + | return | |
| 4369 | + | } | |
| 4370 | + | i.mu.Lock() | |
| 4371 | + | i.llamaConfigured = true | |
| 4372 | + | i.statusErr = "" | |
| 4373 | + | i.statusOK = "configured llama.cpp router" | |
| 4374 | + | i.mu.Unlock() | |
| 4375 | + | i.dialog.ShowResult(true, "") | |
| 4376 | + | } | |
| 4377 | + | ||
| 4271 | 4378 | func (i *Interactive) startAPIKeyFlow(provider string) { | |
| 4272 | 4379 | if title, lines, ok := providerSetupInfo(provider); ok { | |
| 4273 | 4380 | i.dialog.ShowInfo(title, lines) | |
| @@ -114,6 +114,15 @@ type InteractiveConfig struct { | |||||
| 114 | // Auth is required. When the user runs /login, Interactive talks to | 114 | // Auth is required. When the user runs /login, Interactive talks to | ||
| 115 | // AuthManager to open a browser and wait for the callback. | 115 | // AuthManager to open a browser and wait for the callback. | ||
| 116 | AuthManager *auth.Manager | 116 | AuthManager *auth.Manager | ||
| 117 | + | ||||
| 118 | + | // LlamaCPPConfig resolves the router URL and optional API key from env or | |||
| 119 | + | // the credential saved by /login. | |||
| 120 | + | LlamaCPPConfig func() (baseURL, apiKey string, err error) | |||
| 121 | + | ||||
| 122 | + | // RefreshLlamaCPPModels synchronizes currently loaded router models into | |||
| 123 | + | // the catalog before /model opens. | |||
| 124 | + | RefreshLlamaCPPModels func(context.Context) error | |||
| 125 | + | ||||
| 117 | // BuildAgent is called after a successful login to (re)construct the | 126 | // BuildAgent is called after a successful login to (re)construct the | ||
| 118 | // agent with the fresh credential. It returns the new agent and | 127 | // agent with the fresh credential. It returns the new agent and | ||
| 119 | // the concrete provider/model in use. | 128 | // the concrete provider/model in use. | ||
| @@ -253,6 +262,8 @@ type ChangelogPayload struct { | |||||
| 253 | URL string | 262 | URL string | ||
| 254 | } | 263 | } | ||
| 255 | 264 | ||||
| 265 | + | type modelRefreshResult struct{ err error } | |||
| 266 | + | ||||
| 256 | // Interactive is the TUI chat loop. | 267 | // Interactive is the TUI chat loop. | ||
| 257 | type chatCacheKey struct { | 268 | type chatCacheKey struct { | ||
| 258 | cols int | 269 | cols int | ||
| @@ -340,6 +351,7 @@ type Interactive struct { | |||||
| 340 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | 351 | lastCtxInput int // input_tokens of the most recent turn — approximates current context size | ||
| 341 | busy bool | 352 | busy bool | ||
| 342 | dirty chan struct{} | 353 | dirty chan struct{} | ||
| 354 | + | modelRefresh chan modelRefreshResult | |||
| 343 | cancelTurn context.CancelFunc | 355 | cancelTurn context.CancelFunc | ||
| 344 | scrollOffset int // rows from the bottom; 0 = pinned to latest | 356 | scrollOffset int // rows from the bottom; 0 = pinned to latest | ||
| 345 | prevScrollOffset int // last value redraw snapped against; tracks intent | 357 | prevScrollOffset int // last value redraw snapped against; tracks intent | ||
| @@ -391,6 +403,7 @@ type Interactive struct { | |||||
| 391 | 403 | ||||
| 392 | dialog *loginDialog | 404 | dialog *loginDialog | ||
| 393 | modelDialog *modelDialog | 405 | modelDialog *modelDialog | ||
| 406 | + | llamaDialog *llamaDialog | |||
| 394 | rescueDialog *rescueDialog | 407 | rescueDialog *rescueDialog | ||
| 395 | sessionDialog *sessionDialog | 408 | sessionDialog *sessionDialog | ||
| 396 | swarmDialog *swarmDialog | 409 | swarmDialog *swarmDialog | ||
| @@ -407,6 +420,7 @@ type Interactive struct { | |||||
| 407 | sessionOpsDialog *sessionOpsDialog | 420 | sessionOpsDialog *sessionOpsDialog | ||
| 408 | sessionTreeDialog *sessionTreeDialog | 421 | sessionTreeDialog *sessionTreeDialog | ||
| 409 | extPanel *extPanelDialog | 422 | extPanel *extPanelDialog | ||
| 423 | + | llamaConfigured bool | |||
| 410 | 424 | ||||
| 411 | // swarmWatch tracks auto-swarm sub-agents the main agent spawned | 425 | // swarmWatch tracks auto-swarm sub-agents the main agent spawned | ||
| 412 | // via swarm_spawn. Each entry holds the agent + the task text; | 426 | // via swarm_spawn. Each entry holds the agent + the task text; | ||
| @@ -520,8 +534,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 520 | toolCalls: map[string]*tui.ToolCallView{}, | 534 | toolCalls: map[string]*tui.ToolCallView{}, | ||
| 521 | toolGate: map[string]int{}, | 535 | toolGate: map[string]int{}, | ||
| 522 | dirty: make(chan struct{}, 8), | 536 | dirty: make(chan struct{}, 8), | ||
| 537 | + | modelRefresh: make(chan modelRefreshResult, 1), | |||
| 523 | dialog: newLoginDialog(), | 538 | dialog: newLoginDialog(), | ||
| 524 | modelDialog: newModelDialog(), | 539 | modelDialog: newModelDialog(), | ||
| 540 | + | llamaDialog: newLlamaDialog(), | |||
| 525 | rescueDialog: newRescueDialog(), | 541 | rescueDialog: newRescueDialog(), | ||
| 526 | sessionDialog: newSessionDialog(), | 542 | sessionDialog: newSessionDialog(), | ||
| 527 | swarmDialog: newSwarmDialog(), | 543 | swarmDialog: newSwarmDialog(), | ||
| @@ -543,6 +559,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 543 | } | 559 | } | ||
| 544 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | 560 | i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest) | ||
| 545 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | 561 | i.fileSuggest.SetRespectGitignore(cfg.RespectGitignore == nil || *cfg.RespectGitignore) | ||
| 562 | + | if cfg.LlamaCPPConfig != nil { | |||
| 563 | + | baseURL, _, err := cfg.LlamaCPPConfig() | |||
| 564 | + | i.llamaConfigured = err == nil && baseURL != "" | |||
| 565 | + | } | |||
| 546 | if cfg.Agent != nil { | 566 | if cfg.Agent != nil { | ||
| 547 | i.agent = cfg.Agent | 567 | i.agent = cfg.Agent | ||
| 548 | i.view.Messages = cfg.Agent.Messages() | 568 | i.view.Messages = cfg.Agent.Messages() | ||
| @@ -768,6 +788,9 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 768 | case ev := <-authEvents: | 788 | case ev := <-authEvents: | ||
| 769 | i.handleAuthEvent(ev) | 789 | i.handleAuthEvent(ev) | ||
| 770 | i.invalidate() | 790 | i.invalidate() | ||
| 791 | + | case result := <-i.modelRefresh: | |||
| 792 | + | i.openModelPickerAfterRefresh(result.err) | |||
| 793 | + | i.invalidate() | |||
| 771 | case info, ok := <-updates: | 794 | case info, ok := <-updates: | ||
| 772 | if ok && info.Available { | 795 | if ok && info.Available { | ||
| 773 | i.mu.Lock() | 796 | i.mu.Lock() | ||
| @@ -1102,6 +1125,8 @@ func (i *Interactive) redraw() { | |||||
| 1102 | dialog = i.dialog.Render(i.cfg.Theme, cols) | 1125 | dialog = i.dialog.Render(i.cfg.Theme, cols) | ||
| 1103 | case i.modelDialog.Active(): | 1126 | case i.modelDialog.Active(): | ||
| 1104 | dialog = i.modelDialog.Render(i.cfg.Theme, cols) | 1127 | dialog = i.modelDialog.Render(i.cfg.Theme, cols) | ||
| 1128 | + | case i.llamaDialog.Active(): | |||
| 1129 | + | dialog = i.llamaDialog.Render(i.cfg.Theme, cols) | |||
| 1105 | case i.rescueDialog.Active(): | 1130 | case i.rescueDialog.Active(): | ||
| 1106 | dialog = i.rescueDialog.Render(i.cfg.Theme, cols) | 1131 | dialog = i.rescueDialog.Render(i.cfg.Theme, cols) | ||
| 1107 | case i.sessionDialog.Active(): | 1132 | case i.sessionDialog.Active(): | ||
| @@ -1150,6 +1175,7 @@ func (i *Interactive) redraw() { | |||||
| 1150 | // Feed extension-registered commands into the suggester first so | 1175 | // Feed extension-registered commands into the suggester first so | ||
| 1151 | // they show up in tab-complete + the popup alongside the built-ins. | 1176 | // they show up in tab-complete + the popup alongside the built-ins. | ||
| 1152 | i.suggest.SetJailed(i.cfg.Sandbox.Locked()) | 1177 | i.suggest.SetJailed(i.cfg.Sandbox.Locked()) | ||
| 1178 | + | i.suggest.SetLlamaConfigured(i.llamaConfigured) | |||
| 1153 | if i.cfg.Extensions != nil { | 1179 | if i.cfg.Extensions != nil { | ||
| 1154 | catalog := i.cfg.Extensions.Commands() | 1180 | catalog := i.cfg.Extensions.Commands() | ||
| 1155 | extra := make([]slashCommand, 0, len(catalog)) | 1181 | extra := make([]slashCommand, 0, len(catalog)) | ||
| @@ -1531,6 +1557,15 @@ func (i *Interactive) redraw() { | |||||
| 1531 | cursorCol = c | 1557 | cursorCol = c | ||
| 1532 | } | 1558 | } | ||
| 1533 | } | 1559 | } | ||
| 1560 | + | if i.llamaDialog.Active() { | |||
| 1561 | + | if r, c := i.llamaDialog.CursorPos(); r >= 0 { | |||
| 1562 | + | cursorRow = dialogLead + r | |||
| 1563 | + | cursorCol = c | |||
| 1564 | + | } else { | |||
| 1565 | + | cursorRow = -1 | |||
| 1566 | + | cursorCol = 0 | |||
| 1567 | + | } | |||
| 1568 | + | } | |||
| 1534 | if i.sessionDialog.Active() { | 1569 | if i.sessionDialog.Active() { | ||
| 1535 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | 1570 | if r, c := i.sessionDialog.CursorPos(); r >= 0 { | ||
| 1536 | cursorRow = dialogLead + r | 1571 | cursorRow = dialogLead + r | ||
| @@ -1865,6 +1900,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1865 | if act.SubmitCode != "" { | 1900 | if act.SubmitCode != "" { | ||
| 1866 | i.submitManualOAuthCode(act.SubmitCode) | 1901 | i.submitManualOAuthCode(act.SubmitCode) | ||
| 1867 | } | 1902 | } | ||
| 1903 | + | if act.SaveLlama { | |||
| 1904 | + | i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey) | |||
| 1905 | + | } | |||
| 1868 | return false | 1906 | return false | ||
| 1869 | } | 1907 | } | ||
| 1870 | if i.modelDialog.Active() { | 1908 | if i.modelDialog.Active() { | ||
| @@ -1887,6 +1925,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1887 | } | 1925 | } | ||
| 1888 | return false | 1926 | return false | ||
| 1889 | } | 1927 | } | ||
| 1928 | + | if i.llamaDialog.Active() { | |||
| 1929 | + | i.llamaDialog.HandleKey(k) | |||
| 1930 | + | i.invalidate() | |||
| 1931 | + | return false | |||
| 1932 | + | } | |||
| 1890 | if i.rescueDialog.Active() { | 1933 | if i.rescueDialog.Active() { | ||
| 1891 | if k.Kind == tui.KeyCtrlC { | 1934 | if k.Kind == tui.KeyCtrlC { | ||
| 1892 | i.rescueDialog.Close() | 1935 | i.rescueDialog.Close() | ||
| @@ -3857,7 +3900,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 3857 | i.mu.Unlock() | 3900 | i.mu.Unlock() | ||
| 3858 | case "/help": | 3901 | case "/help": | ||
| 3859 | i.mu.Lock() | 3902 | i.mu.Lock() | ||
| 3860 | - | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols()) | 3903 | + | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) |
| 3861 | i.statusErr = "" | 3904 | i.statusErr = "" | ||
| 3862 | i.statusOK = "" | 3905 | i.statusOK = "" | ||
| 3863 | // Pin the viewport to the newest content so the help block, | 3906 | // Pin the viewport to the newest content so the help block, | ||
| @@ -3880,12 +3923,36 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 3880 | case "/model": | 3923 | case "/model": | ||
| 3881 | if len(parts) >= 2 { | 3924 | if len(parts) >= 2 { | ||
| 3882 | i.applyModelSelection("", parts[1]) | 3925 | i.applyModelSelection("", parts[1]) | ||
| 3926 | + | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | |||
| 3927 | + | i.mu.Lock() | |||
| 3928 | + | i.statusOK = "refreshing models" | |||
| 3929 | + | i.statusErr = "" | |||
| 3930 | + | i.mu.Unlock() | |||
| 3931 | + | go func() { | |||
| 3932 | + | refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second) | |||
| 3933 | + | defer cancel() | |||
| 3934 | + | i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)} | |||
| 3935 | + | }() | |||
| 3883 | } else { | 3936 | } else { | ||
| 3884 | - | var loggedIn []string | 3937 | + | i.openModelPickerAfterRefresh(nil) |
| 3885 | - | if i.cfg.LoggedInProviders != nil { | 3938 | + | } |
| 3886 | - | loggedIn = i.cfg.LoggedInProviders() | 3939 | + | case "/llama": |
| 3887 | - | } | 3940 | + | if i.cfg.LlamaCPPConfig == nil { |
| 3888 | - | i.modelDialog.Open(i.cfg.Model, loggedIn) | 3941 | + | i.mu.Lock() |
| 3942 | + | i.statusErr = "llama.cpp configuration is unavailable" | |||
| 3943 | + | i.statusOK = "" | |||
| 3944 | + | i.mu.Unlock() | |||
| 3945 | + | break | |||
| 3946 | + | } | |||
| 3947 | + | baseURL, apiKey, configErr := i.cfg.LlamaCPPConfig() | |||
| 3948 | + | if configErr == nil { | |||
| 3949 | + | configErr = i.llamaDialog.Open(baseURL, apiKey, i.invalidate) | |||
| 3950 | + | } | |||
| 3951 | + | if configErr != nil { | |||
| 3952 | + | i.mu.Lock() | |||
| 3953 | + | i.statusErr = configErr.Error() | |||
| 3954 | + | i.statusOK = "" | |||
| 3955 | + | i.mu.Unlock() | |||
| 3889 | } | 3956 | } | ||
| 3890 | case "/settings": | 3957 | case "/settings": | ||
| 3891 | i.openSettingsDialog() | 3958 | i.openSettingsDialog() | ||
| @@ -4040,6 +4107,22 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 4040 | return false | 4107 | return false | ||
| 4041 | } | 4108 | } | ||
| 4042 | 4109 | ||||
| 4110 | + | func (i *Interactive) openModelPickerAfterRefresh(refreshErr error) { | |||
| 4111 | + | var loggedIn []string | |||
| 4112 | + | if i.cfg.LoggedInProviders != nil { | |||
| 4113 | + | loggedIn = i.cfg.LoggedInProviders() | |||
| 4114 | + | } | |||
| 4115 | + | i.modelDialog.Open(i.cfg.Model, loggedIn) | |||
| 4116 | + | i.mu.Lock() | |||
| 4117 | + | if refreshErr != nil { | |||
| 4118 | + | i.statusErr = "llama.cpp model refresh: " + refreshErr.Error() | |||
| 4119 | + | i.statusOK = "" | |||
| 4120 | + | } else if i.statusOK == "refreshing models" { | |||
| 4121 | + | i.statusOK = "" | |||
| 4122 | + | } | |||
| 4123 | + | i.mu.Unlock() | |||
| 4124 | + | } | |||
| 4125 | + | ||||
| 4043 | // openLogoutDialog shows the provider picker for `/logout` with no | 4126 | // openLogoutDialog shows the provider picker for `/logout` with no | ||
| 4044 | // argument. Only providers the user is currently logged into are | 4127 | // argument. Only providers the user is currently logged into are | ||
| 4045 | // listed, plus an "all" entry when more than one is present. If | 4128 | // listed, plus an "all" entry when more than one is present. If | ||
| @@ -4091,7 +4174,7 @@ func (i *Interactive) openLogoutDialog() { | |||||
| 4091 | items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"}) | 4174 | items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"}) | ||
| 4092 | } | 4175 | } | ||
| 4093 | for p, c := range creds.AdditionalAPIKeyCreds { | 4176 | for p, c := range creds.AdditionalAPIKeyCreds { | ||
| 4094 | - | if c.APIKey != "" { | 4177 | + | if c.APIKey != "" || c.BaseURL != "" { |
| 4095 | items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"}) | 4178 | items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"}) | ||
| 4096 | } | 4179 | } | ||
| 4097 | } | 4180 | } | ||
| @@ -4182,8 +4265,15 @@ func (i *Interactive) doLogout(target string) { | |||||
| 4182 | } | 4265 | } | ||
| 4183 | } | 4266 | } | ||
| 4184 | 4267 | ||||
| 4268 | + | llamaConfigured := i.llamaConfigured | |||
| 4269 | + | if i.cfg.LlamaCPPConfig != nil { | |||
| 4270 | + | baseURL, _, err := i.cfg.LlamaCPPConfig() | |||
| 4271 | + | llamaConfigured = err == nil && baseURL != "" | |||
| 4272 | + | } | |||
| 4273 | + | ||||
| 4185 | i.mu.Lock() | 4274 | i.mu.Lock() | ||
| 4186 | defer i.mu.Unlock() | 4275 | defer i.mu.Unlock() | ||
| 4276 | + | i.llamaConfigured = llamaConfigured | |||
| 4187 | if len(errs) > 0 { | 4277 | if len(errs) > 0 { | ||
| 4188 | i.statusErr = "logout errors: " + strings.Join(errs, "; ") | 4278 | i.statusErr = "logout errors: " + strings.Join(errs, "; ") | ||
| 4189 | return | 4279 | return | ||
| @@ -4268,6 +4358,23 @@ func providerSetupInfo(provider string) (string, []string, bool) { | |||||
| 4268 | } | 4358 | } | ||
| 4269 | } | 4359 | } | ||
| 4270 | 4360 | ||||
| 4361 | + | func (i *Interactive) saveLlamaCPPLogin(baseURL, apiKey string) { | |||
| 4362 | + | if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil { | |||
| 4363 | + | i.dialog.ShowResult(false, "auth store is unavailable") | |||
| 4364 | + | return | |||
| 4365 | + | } | |||
| 4366 | + | if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LlamaCPPProviderID, baseURL, apiKey); err != nil { | |||
| 4367 | + | i.dialog.ShowResult(false, err.Error()) | |||
| 4368 | + | return | |||
| 4369 | + | } | |||
| 4370 | + | i.mu.Lock() | |||
| 4371 | + | i.llamaConfigured = true | |||
| 4372 | + | i.statusErr = "" | |||
| 4373 | + | i.statusOK = "configured llama.cpp router" | |||
| 4374 | + | i.mu.Unlock() | |||
| 4375 | + | i.dialog.ShowResult(true, "") | |||
| 4376 | + | } | |||
| 4377 | + | ||||
| 4271 | func (i *Interactive) startAPIKeyFlow(provider string) { | 4378 | func (i *Interactive) startAPIKeyFlow(provider string) { | ||
| 4272 | if title, lines, ok := providerSetupInfo(provider); ok { | 4379 | if title, lines, ok := providerSetupInfo(provider); ok { | ||
| 4273 | i.dialog.ShowInfo(title, lines) | 4380 | i.dialog.ShowInfo(title, lines) | ||
A packages/agent/modes/interactive_model_test.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) { | |
| 9 | + | i := &Interactive{ | |
| 10 | + | cfg: InteractiveConfig{ | |
| 11 | + | RefreshLlamaCPPModels: func(context.Context) error { return nil }, | |
| 12 | + | }, | |
| 13 | + | modelRefresh: make(chan modelRefreshResult, 1), | |
| 14 | + | modelDialog: newModelDialog(), | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | i.runSlash(context.Background(), "/model") | |
| 18 | + | ||
| 19 | + | if !i.modelDialog.Active() { | |
| 20 | + | t.Fatal("model picker did not open immediately") | |
| 21 | + | } | |
| 22 | + | if i.statusOK == "refreshing models" { | |
| 23 | + | t.Fatal("refresh status shown without llama.cpp router configuration") | |
| 24 | + | } | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestModelPickerSkipsLlamaRefreshWhenRouterIsNotConfigured(t *testing.T) { | |||
| 9 | + | i := &Interactive{ | |||
| 10 | + | cfg: InteractiveConfig{ | |||
| 11 | + | RefreshLlamaCPPModels: func(context.Context) error { return nil }, | |||
| 12 | + | }, | |||
| 13 | + | modelRefresh: make(chan modelRefreshResult, 1), | |||
| 14 | + | modelDialog: newModelDialog(), | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | i.runSlash(context.Background(), "/model") | |||
| 18 | + | ||||
| 19 | + | if !i.modelDialog.Active() { | |||
| 20 | + | t.Fatal("model picker did not open immediately") | |||
| 21 | + | } | |||
| 22 | + | if i.statusOK == "refreshing models" { | |||
| 23 | + | t.Fatal("refresh status shown without llama.cpp router configuration") | |||
| 24 | + | } | |||
| 25 | + | } | |||
A packages/agent/modes/llama_dialog.go +646 -0
| @@ -0,0 +1,646 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sort" | |
| 7 | + | "strings" | |
| 8 | + | "sync" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/mattn/go-runewidth" | |
| 12 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 13 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type llamaDialogStep int | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | llamaClosed llamaDialogStep = iota | |
| 20 | + | llamaLoading | |
| 21 | + | llamaModels | |
| 22 | + | llamaConfirmUnload | |
| 23 | + | llamaConfirmRemove | |
| 24 | + | llamaSearch | |
| 25 | + | llamaModelDetails | |
| 26 | + | llamaQuantizations | |
| 27 | + | llamaProgress | |
| 28 | + | llamaError | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // llamaDialog manages models exposed by a configured llama.cpp router. Network | |
| 32 | + | // operations run outside the input loop and update this state under mu. | |
| 33 | + | type llamaDialog struct { | |
| 34 | + | mu sync.Mutex | |
| 35 | + | ||
| 36 | + | step llamaDialogStep | |
| 37 | + | client *provider.LlamaCPPClient | |
| 38 | + | hf *provider.HuggingFaceClient | |
| 39 | + | models []provider.LlamaCPPModel | |
| 40 | + | results []provider.HuggingFaceModel | |
| 41 | + | details provider.HuggingFaceModelDetails | |
| 42 | + | cursor int | |
| 43 | + | query string | |
| 44 | + | selected string | |
| 45 | + | message string | |
| 46 | + | progress provider.LlamaCPPProgress | |
| 47 | + | cancel context.CancelFunc | |
| 48 | + | searchCancel context.CancelFunc | |
| 49 | + | searchSeq uint64 | |
| 50 | + | invalidate func() | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func newLlamaDialog() *llamaDialog { return &llamaDialog{} } | |
| 54 | + | ||
| 55 | + | func (d *llamaDialog) Active() bool { | |
| 56 | + | d.mu.Lock() | |
| 57 | + | defer d.mu.Unlock() | |
| 58 | + | return d.step != llamaClosed | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (d *llamaDialog) Open(serverURL, apiKey string, invalidate func()) error { | |
| 62 | + | if strings.TrimSpace(serverURL) == "" { | |
| 63 | + | return fmt.Errorf("configure the llama.cpp router with /login first") | |
| 64 | + | } | |
| 65 | + | client, err := provider.NewLlamaCPPClient(serverURL, apiKey) | |
| 66 | + | if err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | d.mu.Lock() | |
| 70 | + | d.step = llamaLoading | |
| 71 | + | d.client = client | |
| 72 | + | d.hf = provider.NewHuggingFaceClient(provider.FindHuggingFaceToken()) | |
| 73 | + | d.cursor = 0 | |
| 74 | + | d.query = "" | |
| 75 | + | d.message = "Connecting to " + client.ServerURL | |
| 76 | + | d.invalidate = invalidate | |
| 77 | + | d.mu.Unlock() | |
| 78 | + | go d.refresh(llamaModels, "") | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (d *llamaDialog) Close() { | |
| 83 | + | d.mu.Lock() | |
| 84 | + | if d.cancel != nil { | |
| 85 | + | d.cancel() | |
| 86 | + | d.cancel = nil | |
| 87 | + | } | |
| 88 | + | if d.searchCancel != nil { | |
| 89 | + | d.searchCancel() | |
| 90 | + | d.searchCancel = nil | |
| 91 | + | } | |
| 92 | + | d.step = llamaClosed | |
| 93 | + | d.mu.Unlock() | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (d *llamaDialog) notify() { | |
| 97 | + | d.mu.Lock() | |
| 98 | + | invalidate := d.invalidate | |
| 99 | + | d.mu.Unlock() | |
| 100 | + | if invalidate != nil { | |
| 101 | + | invalidate() | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (d *llamaDialog) setError(err error) { | |
| 106 | + | d.mu.Lock() | |
| 107 | + | if d.step != llamaClosed { | |
| 108 | + | d.step = llamaError | |
| 109 | + | d.message = err.Error() | |
| 110 | + | d.cancel = nil | |
| 111 | + | } | |
| 112 | + | d.mu.Unlock() | |
| 113 | + | d.notify() | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (d *llamaDialog) refresh(next llamaDialogStep, message string) { | |
| 117 | + | d.mu.Lock() | |
| 118 | + | client := d.client | |
| 119 | + | d.mu.Unlock() | |
| 120 | + | models, err := client.List(context.Background(), true) | |
| 121 | + | if err != nil { | |
| 122 | + | d.setError(err) | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | sort.Slice(models, func(i, j int) bool { | |
| 126 | + | il := models[i].Status.Value == "loaded" || models[i].Status.Value == "sleeping" | |
| 127 | + | jl := models[j].Status.Value == "loaded" || models[j].Status.Value == "sleeping" | |
| 128 | + | if il != jl { | |
| 129 | + | return il | |
| 130 | + | } | |
| 131 | + | return models[i].ID < models[j].ID | |
| 132 | + | }) | |
| 133 | + | provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL)) | |
| 134 | + | d.mu.Lock() | |
| 135 | + | if d.step != llamaClosed { | |
| 136 | + | d.models = models | |
| 137 | + | d.cursor = min(d.cursor, len(models)) | |
| 138 | + | d.step = next | |
| 139 | + | d.message = message | |
| 140 | + | d.cancel = nil | |
| 141 | + | } | |
| 142 | + | d.mu.Unlock() | |
| 143 | + | d.notify() | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (d *llamaDialog) beginProgress(title, model string, run func(context.Context, func(provider.LlamaCPPProgress)) error) { | |
| 147 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 148 | + | d.mu.Lock() | |
| 149 | + | d.step = llamaProgress | |
| 150 | + | d.selected = model | |
| 151 | + | d.message = title | |
| 152 | + | d.progress = provider.LlamaCPPProgress{Message: "Starting"} | |
| 153 | + | d.cancel = cancel | |
| 154 | + | d.mu.Unlock() | |
| 155 | + | d.notify() | |
| 156 | + | go func() { | |
| 157 | + | err := run(ctx, func(progress provider.LlamaCPPProgress) { | |
| 158 | + | d.mu.Lock() | |
| 159 | + | if d.step == llamaProgress { | |
| 160 | + | d.progress = progress | |
| 161 | + | } | |
| 162 | + | d.mu.Unlock() | |
| 163 | + | d.notify() | |
| 164 | + | }) | |
| 165 | + | if err != nil { | |
| 166 | + | if ctx.Err() != nil { | |
| 167 | + | d.refresh(llamaModels, "operation cancelled") | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | d.setError(err) | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | d.refresh(llamaModels, title+" complete") | |
| 174 | + | }() | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (d *llamaDialog) load(model string) { | |
| 178 | + | d.mu.Lock() | |
| 179 | + | client := d.client | |
| 180 | + | d.mu.Unlock() | |
| 181 | + | d.beginProgress("Loading model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { | |
| 182 | + | _, err := client.LoadAndWait(ctx, model, update) | |
| 183 | + | return err | |
| 184 | + | }) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (d *llamaDialog) unload(model string) { | |
| 188 | + | d.mu.Lock() | |
| 189 | + | client := d.client | |
| 190 | + | d.mu.Unlock() | |
| 191 | + | d.beginProgress("Unloading model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { | |
| 192 | + | update(provider.LlamaCPPProgress{Message: "Unloading model"}) | |
| 193 | + | return client.UnloadAndWait(ctx, model) | |
| 194 | + | }) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (d *llamaDialog) download(model string) { | |
| 198 | + | d.mu.Lock() | |
| 199 | + | client := d.client | |
| 200 | + | d.mu.Unlock() | |
| 201 | + | d.beginProgress("Downloading model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { | |
| 202 | + | _, err := client.DownloadAndWait(ctx, model, update) | |
| 203 | + | return err | |
| 204 | + | }) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func (d *llamaDialog) remove(model string) { | |
| 208 | + | d.mu.Lock() | |
| 209 | + | client := d.client | |
| 210 | + | d.mu.Unlock() | |
| 211 | + | d.beginProgress("Removing model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { | |
| 212 | + | update(provider.LlamaCPPProgress{Message: "Removing cached files"}) | |
| 213 | + | return client.Remove(ctx, model) | |
| 214 | + | }) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func llamaModelLoaded(model provider.LlamaCPPModel) bool { | |
| 218 | + | return model.Status.Value == "loaded" || model.Status.Value == "sleeping" | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (d *llamaDialog) scheduleSearchLocked() { | |
| 222 | + | if d.searchCancel != nil { | |
| 223 | + | d.searchCancel() | |
| 224 | + | d.searchCancel = nil | |
| 225 | + | } | |
| 226 | + | d.searchSeq++ | |
| 227 | + | d.results = nil | |
| 228 | + | d.cursor = 0 | |
| 229 | + | query := strings.TrimSpace(d.query) | |
| 230 | + | if len(query) < 2 { | |
| 231 | + | d.message = "type at least 2 characters" | |
| 232 | + | return | |
| 233 | + | } | |
| 234 | + | d.message = "searching Hugging Face..." | |
| 235 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 236 | + | d.searchCancel = cancel | |
| 237 | + | seq := d.searchSeq | |
| 238 | + | hf := d.hf | |
| 239 | + | go func() { | |
| 240 | + | timer := time.NewTimer(400 * time.Millisecond) | |
| 241 | + | defer timer.Stop() | |
| 242 | + | select { | |
| 243 | + | case <-ctx.Done(): | |
| 244 | + | return | |
| 245 | + | case <-timer.C: | |
| 246 | + | } | |
| 247 | + | results, err := hf.Search(ctx, query) | |
| 248 | + | if ctx.Err() != nil { | |
| 249 | + | return | |
| 250 | + | } | |
| 251 | + | d.mu.Lock() | |
| 252 | + | if d.step != llamaSearch || d.searchSeq != seq || strings.TrimSpace(d.query) != query { | |
| 253 | + | d.mu.Unlock() | |
| 254 | + | return | |
| 255 | + | } | |
| 256 | + | d.searchCancel = nil | |
| 257 | + | if err != nil { | |
| 258 | + | d.results = nil | |
| 259 | + | d.message = err.Error() | |
| 260 | + | } else { | |
| 261 | + | d.results = results | |
| 262 | + | d.message = "" | |
| 263 | + | if len(results) == 0 { | |
| 264 | + | d.message = "no GGUF models found" | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | d.mu.Unlock() | |
| 268 | + | d.notify() | |
| 269 | + | }() | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func (d *llamaDialog) HandleKey(k tui.Key) { | |
| 273 | + | d.mu.Lock() | |
| 274 | + | step := d.step | |
| 275 | + | if k.Kind == tui.KeyCtrlC { | |
| 276 | + | cancel := d.cancel | |
| 277 | + | searchCancel := d.searchCancel | |
| 278 | + | client := d.client | |
| 279 | + | model := d.selected | |
| 280 | + | d.step = llamaClosed | |
| 281 | + | d.mu.Unlock() | |
| 282 | + | if cancel != nil { | |
| 283 | + | cancel() | |
| 284 | + | go func() { _ = client.Unload(context.Background(), model) }() | |
| 285 | + | } | |
| 286 | + | if searchCancel != nil { | |
| 287 | + | searchCancel() | |
| 288 | + | } | |
| 289 | + | return | |
| 290 | + | } | |
| 291 | + | if k.Kind == tui.KeyEsc { | |
| 292 | + | switch step { | |
| 293 | + | case llamaProgress: | |
| 294 | + | cancel := d.cancel | |
| 295 | + | client := d.client | |
| 296 | + | model := d.selected | |
| 297 | + | d.mu.Unlock() | |
| 298 | + | if cancel != nil { | |
| 299 | + | cancel() | |
| 300 | + | go func() { _ = client.Unload(context.Background(), model) }() | |
| 301 | + | } | |
| 302 | + | return | |
| 303 | + | case llamaModelDetails, llamaQuantizations: | |
| 304 | + | d.step = llamaSearch | |
| 305 | + | d.cursor = 0 | |
| 306 | + | d.mu.Unlock() | |
| 307 | + | return | |
| 308 | + | case llamaSearch: | |
| 309 | + | if d.searchCancel != nil { | |
| 310 | + | d.searchCancel() | |
| 311 | + | d.searchCancel = nil | |
| 312 | + | } | |
| 313 | + | d.step = llamaModels | |
| 314 | + | d.cursor = 0 | |
| 315 | + | d.mu.Unlock() | |
| 316 | + | return | |
| 317 | + | case llamaConfirmUnload, llamaConfirmRemove: | |
| 318 | + | d.step = llamaModels | |
| 319 | + | d.cursor = 0 | |
| 320 | + | d.mu.Unlock() | |
| 321 | + | return | |
| 322 | + | case llamaError: | |
| 323 | + | d.step = llamaClosed | |
| 324 | + | d.mu.Unlock() | |
| 325 | + | return | |
| 326 | + | default: | |
| 327 | + | d.step = llamaClosed | |
| 328 | + | d.mu.Unlock() | |
| 329 | + | return | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | switch step { | |
| 334 | + | case llamaModels: | |
| 335 | + | count := len(d.models) + 1 | |
| 336 | + | switch k.Kind { | |
| 337 | + | case tui.KeyUp: | |
| 338 | + | if d.cursor > 0 { | |
| 339 | + | d.cursor-- | |
| 340 | + | } | |
| 341 | + | case tui.KeyDown: | |
| 342 | + | if d.cursor+1 < count { | |
| 343 | + | d.cursor++ | |
| 344 | + | } | |
| 345 | + | case tui.KeyEnter: | |
| 346 | + | if d.cursor == len(d.models) { | |
| 347 | + | d.step, d.cursor, d.query, d.message = llamaSearch, 0, "", "type at least 2 characters" | |
| 348 | + | d.results = nil | |
| 349 | + | d.mu.Unlock() | |
| 350 | + | return | |
| 351 | + | } | |
| 352 | + | if len(d.models) > 0 { | |
| 353 | + | model := d.models[d.cursor] | |
| 354 | + | if llamaModelLoaded(model) { | |
| 355 | + | d.selected = model.ID | |
| 356 | + | d.step = llamaConfirmUnload | |
| 357 | + | d.cursor = 0 | |
| 358 | + | d.mu.Unlock() | |
| 359 | + | return | |
| 360 | + | } | |
| 361 | + | if model.Status.Value == "unloaded" { | |
| 362 | + | id := model.ID | |
| 363 | + | d.mu.Unlock() | |
| 364 | + | d.load(id) | |
| 365 | + | return | |
| 366 | + | } | |
| 367 | + | d.message = model.ID + " is " + model.Status.Value | |
| 368 | + | } | |
| 369 | + | case tui.KeyRune: | |
| 370 | + | if (k.Rune == 'd' || k.Rune == 'D') && !k.Ctrl && !k.Alt && d.cursor < len(d.models) { | |
| 371 | + | model := d.models[d.cursor] | |
| 372 | + | if !model.CanRemove { | |
| 373 | + | d.message = "only router-downloaded cache models can be removed" | |
| 374 | + | break | |
| 375 | + | } | |
| 376 | + | d.selected = model.ID | |
| 377 | + | d.step = llamaConfirmRemove | |
| 378 | + | d.cursor = 0 | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | case llamaConfirmUnload: | |
| 382 | + | switch k.Kind { | |
| 383 | + | case tui.KeyUp, tui.KeyDown: | |
| 384 | + | d.cursor = 1 - d.cursor | |
| 385 | + | case tui.KeyEnter: | |
| 386 | + | if d.cursor == 0 { | |
| 387 | + | id := d.selected | |
| 388 | + | d.mu.Unlock() | |
| 389 | + | d.unload(id) | |
| 390 | + | return | |
| 391 | + | } | |
| 392 | + | d.step = llamaModels | |
| 393 | + | } | |
| 394 | + | case llamaConfirmRemove: | |
| 395 | + | switch k.Kind { | |
| 396 | + | case tui.KeyUp, tui.KeyDown: | |
| 397 | + | d.cursor = 1 - d.cursor | |
| 398 | + | case tui.KeyEnter: | |
| 399 | + | if d.cursor == 0 { | |
| 400 | + | id := d.selected | |
| 401 | + | d.mu.Unlock() | |
| 402 | + | d.remove(id) | |
| 403 | + | return | |
| 404 | + | } | |
| 405 | + | d.step = llamaModels | |
| 406 | + | } | |
| 407 | + | case llamaSearch: | |
| 408 | + | switch k.Kind { | |
| 409 | + | case tui.KeyBackspace: | |
| 410 | + | runes := []rune(d.query) | |
| 411 | + | if len(runes) > 0 { | |
| 412 | + | d.query = string(runes[:len(runes)-1]) | |
| 413 | + | d.scheduleSearchLocked() | |
| 414 | + | } | |
| 415 | + | case tui.KeyPaste: | |
| 416 | + | d.query += strings.Join(strings.Fields(k.Paste), " ") | |
| 417 | + | d.scheduleSearchLocked() | |
| 418 | + | case tui.KeyRune: | |
| 419 | + | if !k.Ctrl && !k.Alt && k.Rune >= 0x20 && k.Rune < 0x7f { | |
| 420 | + | d.query += string(k.Rune) | |
| 421 | + | d.scheduleSearchLocked() | |
| 422 | + | } | |
| 423 | + | case tui.KeyUp: | |
| 424 | + | if d.cursor > 0 { | |
| 425 | + | d.cursor-- | |
| 426 | + | } | |
| 427 | + | case tui.KeyDown: | |
| 428 | + | if d.cursor+1 < len(d.results) { | |
| 429 | + | d.cursor++ | |
| 430 | + | } | |
| 431 | + | case tui.KeyEnter: | |
| 432 | + | if len(d.results) == 0 { | |
| 433 | + | break | |
| 434 | + | } | |
| 435 | + | if d.searchCancel != nil { | |
| 436 | + | d.searchCancel() | |
| 437 | + | d.searchCancel = nil | |
| 438 | + | } | |
| 439 | + | id := d.results[d.cursor].ID | |
| 440 | + | hf := d.hf | |
| 441 | + | d.step, d.message = llamaLoading, "loading model details" | |
| 442 | + | d.mu.Unlock() | |
| 443 | + | go func() { | |
| 444 | + | details, err := hf.Details(context.Background(), id) | |
| 445 | + | if err != nil { | |
| 446 | + | d.setError(err) | |
| 447 | + | return | |
| 448 | + | } | |
| 449 | + | d.mu.Lock() | |
| 450 | + | if d.step != llamaClosed { | |
| 451 | + | d.details, d.cursor = details, 0 | |
| 452 | + | if len(details.Quantizations) > 0 { | |
| 453 | + | d.step = llamaQuantizations | |
| 454 | + | } else { | |
| 455 | + | d.step = llamaModelDetails | |
| 456 | + | } | |
| 457 | + | } | |
| 458 | + | d.mu.Unlock() | |
| 459 | + | d.notify() | |
| 460 | + | }() | |
| 461 | + | return | |
| 462 | + | } | |
| 463 | + | case llamaModelDetails: | |
| 464 | + | if k.Kind == tui.KeyEnter { | |
| 465 | + | id := d.details.ID | |
| 466 | + | d.mu.Unlock() | |
| 467 | + | d.download(id) | |
| 468 | + | return | |
| 469 | + | } | |
| 470 | + | case llamaQuantizations: | |
| 471 | + | switch k.Kind { | |
| 472 | + | case tui.KeyUp: | |
| 473 | + | if d.cursor > 0 { | |
| 474 | + | d.cursor-- | |
| 475 | + | } | |
| 476 | + | case tui.KeyDown: | |
| 477 | + | if d.cursor+1 < len(d.details.Quantizations) { | |
| 478 | + | d.cursor++ | |
| 479 | + | } | |
| 480 | + | case tui.KeyEnter: | |
| 481 | + | if len(d.details.Quantizations) > 0 { | |
| 482 | + | id := d.details.ID + ":" + d.details.Quantizations[d.cursor].Name | |
| 483 | + | d.mu.Unlock() | |
| 484 | + | d.download(id) | |
| 485 | + | return | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | } | |
| 489 | + | d.mu.Unlock() | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func (d *llamaDialog) Render(th tui.Theme, width int) []string { | |
| 493 | + | d.mu.Lock() | |
| 494 | + | defer d.mu.Unlock() | |
| 495 | + | if d.step == llamaClosed { | |
| 496 | + | return nil | |
| 497 | + | } | |
| 498 | + | lines := []string{frameHeader(th, "llama.cpp models", width)} | |
| 499 | + | if d.client != nil { | |
| 500 | + | lines = append(lines, th.FG256(th.Muted, d.client.ServerURL)) | |
| 501 | + | } | |
| 502 | + | if d.message != "" { | |
| 503 | + | lines = append(lines, th.FG256(th.Muted, d.message)) | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | switch d.step { | |
| 507 | + | case llamaLoading: | |
| 508 | + | lines = append(lines, th.FG256(th.Muted, " working...")) | |
| 509 | + | case llamaModels: | |
| 510 | + | const visible = 12 | |
| 511 | + | start, end := dialogWindow(d.cursor, len(d.models)+1, visible) | |
| 512 | + | for index := start; index < end; index++ { | |
| 513 | + | label := "Download model from Hugging Face..." | |
| 514 | + | if index < len(d.models) { | |
| 515 | + | model := d.models[index] | |
| 516 | + | status := model.Status.Value | |
| 517 | + | if status == "loaded" || status == "sleeping" { | |
| 518 | + | status = "loaded" | |
| 519 | + | } | |
| 520 | + | label = fmt.Sprintf("%-10s %s", status, model.ID) | |
| 521 | + | } | |
| 522 | + | line := " " + label | |
| 523 | + | if index == d.cursor { | |
| 524 | + | lines = append(lines, th.PadHighlight(line, width)) | |
| 525 | + | } else { | |
| 526 | + | lines = append(lines, th.FG256(th.Muted, line)) | |
| 527 | + | } | |
| 528 | + | } | |
| 529 | + | lines = append(lines, th.FG256(th.Muted, "enter loads/unloads, d removes cached model, esc closes")) | |
| 530 | + | case llamaConfirmUnload: | |
| 531 | + | lines = append(lines, "", " Unload "+d.selected+"?") | |
| 532 | + | for index, label := range []string{"Yes", "No"} { | |
| 533 | + | line := " " + label | |
| 534 | + | if index == d.cursor { | |
| 535 | + | lines = append(lines, th.PadHighlight(line, width)) | |
| 536 | + | } else { | |
| 537 | + | lines = append(lines, th.FG256(th.Muted, line)) | |
| 538 | + | } | |
| 539 | + | } | |
| 540 | + | case llamaConfirmRemove: | |
| 541 | + | lines = append(lines, "", " Remove cached model "+d.selected+"?") | |
| 542 | + | for index, label := range []string{"Yes", "No"} { | |
| 543 | + | line := " " + label | |
| 544 | + | if index == d.cursor { | |
| 545 | + | lines = append(lines, th.PadHighlight(line, width)) | |
| 546 | + | } else { | |
| 547 | + | lines = append(lines, th.FG256(th.Muted, line)) | |
| 548 | + | } | |
| 549 | + | } | |
| 550 | + | case llamaSearch: | |
| 551 | + | lines = append(lines, "", th.FG256(th.Muted, " Model name or owner/repository"), th.FG256(th.FG, " "+d.query), "") | |
| 552 | + | start, end := dialogWindow(d.cursor, len(d.results), 10) | |
| 553 | + | for index := start; index < end; index++ { | |
| 554 | + | result := d.results[index] | |
| 555 | + | line := fmt.Sprintf(" %s %s downloads", result.ID, compactNumber(result.Downloads)) | |
| 556 | + | if index == d.cursor { | |
| 557 | + | lines = append(lines, th.PadHighlight(line, width)) | |
| 558 | + | } else { | |
| 559 | + | lines = append(lines, th.FG256(th.Muted, line)) | |
| 560 | + | } | |
| 561 | + | } | |
| 562 | + | lines = append(lines, th.FG256(th.Muted, "enter selects, esc goes back")) | |
| 563 | + | case llamaModelDetails: | |
| 564 | + | lines = append(lines, "", " "+d.details.ID) | |
| 565 | + | if d.details.Gated != "" { | |
| 566 | + | lines = append(lines, th.FG256(th.Error, " access approval and HF_TOKEN may be required")) | |
| 567 | + | } | |
| 568 | + | lines = append(lines, th.FG256(th.Muted, " enter downloads, esc goes back")) | |
| 569 | + | case llamaQuantizations: | |
| 570 | + | lines = append(lines, "", " Select quantization for "+d.details.ID) | |
| 571 | + | if d.details.Gated != "" { | |
| 572 | + | lines = append(lines, th.FG256(th.Error, " gated model: approve access on huggingface.co and configure HF_TOKEN on the router")) | |
| 573 | + | } | |
| 574 | + | start, end := dialogWindow(d.cursor, len(d.details.Quantizations), 10) | |
| 575 | + | for index := start; index < end; index++ { | |
| 576 | + | quant := d.details.Quantizations[index] | |
| 577 | + | detail := "" | |
| 578 | + | if quant.HasSize { | |
| 579 | + | detail = " " + provider.FormatBytes(quant.Size) | |
| 580 | + | } | |
| 581 | + | if quant.Name == "Q4_K_M" { | |
| 582 | + | detail += " recommended" | |
| 583 | + | } | |
| 584 | + | line := " " + quant.Name + detail | |
| 585 | + | if index == d.cursor { | |
| 586 | + | lines = append(lines, th.PadHighlight(line, width)) | |
| 587 | + | } else { | |
| 588 | + | lines = append(lines, th.FG256(th.Muted, line)) | |
| 589 | + | } | |
| 590 | + | } | |
| 591 | + | case llamaProgress: | |
| 592 | + | lines = append(lines, "", " "+d.selected, th.FG256(th.Muted, " "+d.progress.Message)) | |
| 593 | + | if d.progress.HasRatio { | |
| 594 | + | const cells = 32 | |
| 595 | + | filled := int(d.progress.Ratio*float64(cells) + .5) | |
| 596 | + | filled = max(0, min(cells, filled)) | |
| 597 | + | bar := strings.Repeat("#", filled) + strings.Repeat("-", cells-filled) | |
| 598 | + | lines = append(lines, th.FG256(th.Accent, fmt.Sprintf(" %s %d%%", bar, int(d.progress.Ratio*100+.5)))) | |
| 599 | + | } | |
| 600 | + | if d.progress.Detail != "" { | |
| 601 | + | lines = append(lines, th.FG256(th.Muted, " "+d.progress.Detail)) | |
| 602 | + | } | |
| 603 | + | lines = append(lines, th.FG256(th.Muted, " esc stops")) | |
| 604 | + | case llamaError: | |
| 605 | + | lines = append(lines, th.FG256(th.Error, " "+d.message), th.FG256(th.Muted, " esc closes")) | |
| 606 | + | } | |
| 607 | + | return append(lines, frameRule(th, width)) | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | // CursorPos returns the search input caret location inside the padded dialog. | |
| 611 | + | func (d *llamaDialog) CursorPos() (row, col int) { | |
| 612 | + | d.mu.Lock() | |
| 613 | + | defer d.mu.Unlock() | |
| 614 | + | if d.step != llamaSearch { | |
| 615 | + | return -1, -1 | |
| 616 | + | } | |
| 617 | + | row = 5 // header, injected frame padding, server URL, blank, prompt | |
| 618 | + | if d.message != "" { | |
| 619 | + | row++ | |
| 620 | + | } | |
| 621 | + | return row, 2 + runewidth.StringWidth(d.query) | |
| 622 | + | } | |
| 623 | + | ||
| 624 | + | func dialogWindow(cursor, count, visible int) (int, int) { | |
| 625 | + | if count <= visible { | |
| 626 | + | return 0, count | |
| 627 | + | } | |
| 628 | + | start := cursor - visible/2 | |
| 629 | + | if start < 0 { | |
| 630 | + | start = 0 | |
| 631 | + | } | |
| 632 | + | if start+visible > count { | |
| 633 | + | start = count - visible | |
| 634 | + | } | |
| 635 | + | return start, start + visible | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | func compactNumber(value int64) string { | |
| 639 | + | if value >= 1_000_000 { | |
| 640 | + | return fmt.Sprintf("%.1fM", float64(value)/1_000_000) | |
| 641 | + | } | |
| 642 | + | if value >= 1_000 { | |
| 643 | + | return fmt.Sprintf("%.1fk", float64(value)/1_000) | |
| 644 | + | } | |
| 645 | + | return fmt.Sprintf("%d", value) | |
| 646 | + | } | |
| @@ -0,0 +1,646 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "sort" | |||
| 7 | + | "strings" | |||
| 8 | + | "sync" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/mattn/go-runewidth" | |||
| 12 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 13 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type llamaDialogStep int | |||
| 17 | + | ||||
| 18 | + | const ( | |||
| 19 | + | llamaClosed llamaDialogStep = iota | |||
| 20 | + | llamaLoading | |||
| 21 | + | llamaModels | |||
| 22 | + | llamaConfirmUnload | |||
| 23 | + | llamaConfirmRemove | |||
| 24 | + | llamaSearch | |||
| 25 | + | llamaModelDetails | |||
| 26 | + | llamaQuantizations | |||
| 27 | + | llamaProgress | |||
| 28 | + | llamaError | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // llamaDialog manages models exposed by a configured llama.cpp router. Network | |||
| 32 | + | // operations run outside the input loop and update this state under mu. | |||
| 33 | + | type llamaDialog struct { | |||
| 34 | + | mu sync.Mutex | |||
| 35 | + | ||||
| 36 | + | step llamaDialogStep | |||
| 37 | + | client *provider.LlamaCPPClient | |||
| 38 | + | hf *provider.HuggingFaceClient | |||
| 39 | + | models []provider.LlamaCPPModel | |||
| 40 | + | results []provider.HuggingFaceModel | |||
| 41 | + | details provider.HuggingFaceModelDetails | |||
| 42 | + | cursor int | |||
| 43 | + | query string | |||
| 44 | + | selected string | |||
| 45 | + | message string | |||
| 46 | + | progress provider.LlamaCPPProgress | |||
| 47 | + | cancel context.CancelFunc | |||
| 48 | + | searchCancel context.CancelFunc | |||
| 49 | + | searchSeq uint64 | |||
| 50 | + | invalidate func() | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func newLlamaDialog() *llamaDialog { return &llamaDialog{} } | |||
| 54 | + | ||||
| 55 | + | func (d *llamaDialog) Active() bool { | |||
| 56 | + | d.mu.Lock() | |||
| 57 | + | defer d.mu.Unlock() | |||
| 58 | + | return d.step != llamaClosed | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (d *llamaDialog) Open(serverURL, apiKey string, invalidate func()) error { | |||
| 62 | + | if strings.TrimSpace(serverURL) == "" { | |||
| 63 | + | return fmt.Errorf("configure the llama.cpp router with /login first") | |||
| 64 | + | } | |||
| 65 | + | client, err := provider.NewLlamaCPPClient(serverURL, apiKey) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | d.mu.Lock() | |||
| 70 | + | d.step = llamaLoading | |||
| 71 | + | d.client = client | |||
| 72 | + | d.hf = provider.NewHuggingFaceClient(provider.FindHuggingFaceToken()) | |||
| 73 | + | d.cursor = 0 | |||
| 74 | + | d.query = "" | |||
| 75 | + | d.message = "Connecting to " + client.ServerURL | |||
| 76 | + | d.invalidate = invalidate | |||
| 77 | + | d.mu.Unlock() | |||
| 78 | + | go d.refresh(llamaModels, "") | |||
| 79 | + | return nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (d *llamaDialog) Close() { | |||
| 83 | + | d.mu.Lock() | |||
| 84 | + | if d.cancel != nil { | |||
| 85 | + | d.cancel() | |||
| 86 | + | d.cancel = nil | |||
| 87 | + | } | |||
| 88 | + | if d.searchCancel != nil { | |||
| 89 | + | d.searchCancel() | |||
| 90 | + | d.searchCancel = nil | |||
| 91 | + | } | |||
| 92 | + | d.step = llamaClosed | |||
| 93 | + | d.mu.Unlock() | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func (d *llamaDialog) notify() { | |||
| 97 | + | d.mu.Lock() | |||
| 98 | + | invalidate := d.invalidate | |||
| 99 | + | d.mu.Unlock() | |||
| 100 | + | if invalidate != nil { | |||
| 101 | + | invalidate() | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (d *llamaDialog) setError(err error) { | |||
| 106 | + | d.mu.Lock() | |||
| 107 | + | if d.step != llamaClosed { | |||
| 108 | + | d.step = llamaError | |||
| 109 | + | d.message = err.Error() | |||
| 110 | + | d.cancel = nil | |||
| 111 | + | } | |||
| 112 | + | d.mu.Unlock() | |||
| 113 | + | d.notify() | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func (d *llamaDialog) refresh(next llamaDialogStep, message string) { | |||
| 117 | + | d.mu.Lock() | |||
| 118 | + | client := d.client | |||
| 119 | + | d.mu.Unlock() | |||
| 120 | + | models, err := client.List(context.Background(), true) | |||
| 121 | + | if err != nil { | |||
| 122 | + | d.setError(err) | |||
| 123 | + | return | |||
| 124 | + | } | |||
| 125 | + | sort.Slice(models, func(i, j int) bool { | |||
| 126 | + | il := models[i].Status.Value == "loaded" || models[i].Status.Value == "sleeping" | |||
| 127 | + | jl := models[j].Status.Value == "loaded" || models[j].Status.Value == "sleeping" | |||
| 128 | + | if il != jl { | |||
| 129 | + | return il | |||
| 130 | + | } | |||
| 131 | + | return models[i].ID < models[j].ID | |||
| 132 | + | }) | |||
| 133 | + | provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL)) | |||
| 134 | + | d.mu.Lock() | |||
| 135 | + | if d.step != llamaClosed { | |||
| 136 | + | d.models = models | |||
| 137 | + | d.cursor = min(d.cursor, len(models)) | |||
| 138 | + | d.step = next | |||
| 139 | + | d.message = message | |||
| 140 | + | d.cancel = nil | |||
| 141 | + | } | |||
| 142 | + | d.mu.Unlock() | |||
| 143 | + | d.notify() | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func (d *llamaDialog) beginProgress(title, model string, run func(context.Context, func(provider.LlamaCPPProgress)) error) { | |||
| 147 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 148 | + | d.mu.Lock() | |||
| 149 | + | d.step = llamaProgress | |||
| 150 | + | d.selected = model | |||
| 151 | + | d.message = title | |||
| 152 | + | d.progress = provider.LlamaCPPProgress{Message: "Starting"} | |||
| 153 | + | d.cancel = cancel | |||
| 154 | + | d.mu.Unlock() | |||
| 155 | + | d.notify() | |||
| 156 | + | go func() { | |||
| 157 | + | err := run(ctx, func(progress provider.LlamaCPPProgress) { | |||
| 158 | + | d.mu.Lock() | |||
| 159 | + | if d.step == llamaProgress { | |||
| 160 | + | d.progress = progress | |||
| 161 | + | } | |||
| 162 | + | d.mu.Unlock() | |||
| 163 | + | d.notify() | |||
| 164 | + | }) | |||
| 165 | + | if err != nil { | |||
| 166 | + | if ctx.Err() != nil { | |||
| 167 | + | d.refresh(llamaModels, "operation cancelled") | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 170 | + | d.setError(err) | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 173 | + | d.refresh(llamaModels, title+" complete") | |||
| 174 | + | }() | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func (d *llamaDialog) load(model string) { | |||
| 178 | + | d.mu.Lock() | |||
| 179 | + | client := d.client | |||
| 180 | + | d.mu.Unlock() | |||
| 181 | + | d.beginProgress("Loading model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { | |||
| 182 | + | _, err := client.LoadAndWait(ctx, model, update) | |||
| 183 | + | return err | |||
| 184 | + | }) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func (d *llamaDialog) unload(model string) { | |||
| 188 | + | d.mu.Lock() | |||
| 189 | + | client := d.client | |||
| 190 | + | d.mu.Unlock() | |||
| 191 | + | d.beginProgress("Unloading model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { | |||
| 192 | + | update(provider.LlamaCPPProgress{Message: "Unloading model"}) | |||
| 193 | + | return client.UnloadAndWait(ctx, model) | |||
| 194 | + | }) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (d *llamaDialog) download(model string) { | |||
| 198 | + | d.mu.Lock() | |||
| 199 | + | client := d.client | |||
| 200 | + | d.mu.Unlock() | |||
| 201 | + | d.beginProgress("Downloading model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { | |||
| 202 | + | _, err := client.DownloadAndWait(ctx, model, update) | |||
| 203 | + | return err | |||
| 204 | + | }) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func (d *llamaDialog) remove(model string) { | |||
| 208 | + | d.mu.Lock() | |||
| 209 | + | client := d.client | |||
| 210 | + | d.mu.Unlock() | |||
| 211 | + | d.beginProgress("Removing model", model, func(ctx context.Context, update func(provider.LlamaCPPProgress)) error { | |||
| 212 | + | update(provider.LlamaCPPProgress{Message: "Removing cached files"}) | |||
| 213 | + | return client.Remove(ctx, model) | |||
| 214 | + | }) | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func llamaModelLoaded(model provider.LlamaCPPModel) bool { | |||
| 218 | + | return model.Status.Value == "loaded" || model.Status.Value == "sleeping" | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func (d *llamaDialog) scheduleSearchLocked() { | |||
| 222 | + | if d.searchCancel != nil { | |||
| 223 | + | d.searchCancel() | |||
| 224 | + | d.searchCancel = nil | |||
| 225 | + | } | |||
| 226 | + | d.searchSeq++ | |||
| 227 | + | d.results = nil | |||
| 228 | + | d.cursor = 0 | |||
| 229 | + | query := strings.TrimSpace(d.query) | |||
| 230 | + | if len(query) < 2 { | |||
| 231 | + | d.message = "type at least 2 characters" | |||
| 232 | + | return | |||
| 233 | + | } | |||
| 234 | + | d.message = "searching Hugging Face..." | |||
| 235 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 236 | + | d.searchCancel = cancel | |||
| 237 | + | seq := d.searchSeq | |||
| 238 | + | hf := d.hf | |||
| 239 | + | go func() { | |||
| 240 | + | timer := time.NewTimer(400 * time.Millisecond) | |||
| 241 | + | defer timer.Stop() | |||
| 242 | + | select { | |||
| 243 | + | case <-ctx.Done(): | |||
| 244 | + | return | |||
| 245 | + | case <-timer.C: | |||
| 246 | + | } | |||
| 247 | + | results, err := hf.Search(ctx, query) | |||
| 248 | + | if ctx.Err() != nil { | |||
| 249 | + | return | |||
| 250 | + | } | |||
| 251 | + | d.mu.Lock() | |||
| 252 | + | if d.step != llamaSearch || d.searchSeq != seq || strings.TrimSpace(d.query) != query { | |||
| 253 | + | d.mu.Unlock() | |||
| 254 | + | return | |||
| 255 | + | } | |||
| 256 | + | d.searchCancel = nil | |||
| 257 | + | if err != nil { | |||
| 258 | + | d.results = nil | |||
| 259 | + | d.message = err.Error() | |||
| 260 | + | } else { | |||
| 261 | + | d.results = results | |||
| 262 | + | d.message = "" | |||
| 263 | + | if len(results) == 0 { | |||
| 264 | + | d.message = "no GGUF models found" | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | d.mu.Unlock() | |||
| 268 | + | d.notify() | |||
| 269 | + | }() | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func (d *llamaDialog) HandleKey(k tui.Key) { | |||
| 273 | + | d.mu.Lock() | |||
| 274 | + | step := d.step | |||
| 275 | + | if k.Kind == tui.KeyCtrlC { | |||
| 276 | + | cancel := d.cancel | |||
| 277 | + | searchCancel := d.searchCancel | |||
| 278 | + | client := d.client | |||
| 279 | + | model := d.selected | |||
| 280 | + | d.step = llamaClosed | |||
| 281 | + | d.mu.Unlock() | |||
| 282 | + | if cancel != nil { | |||
| 283 | + | cancel() | |||
| 284 | + | go func() { _ = client.Unload(context.Background(), model) }() | |||
| 285 | + | } | |||
| 286 | + | if searchCancel != nil { | |||
| 287 | + | searchCancel() | |||
| 288 | + | } | |||
| 289 | + | return | |||
| 290 | + | } | |||
| 291 | + | if k.Kind == tui.KeyEsc { | |||
| 292 | + | switch step { | |||
| 293 | + | case llamaProgress: | |||
| 294 | + | cancel := d.cancel | |||
| 295 | + | client := d.client | |||
| 296 | + | model := d.selected | |||
| 297 | + | d.mu.Unlock() | |||
| 298 | + | if cancel != nil { | |||
| 299 | + | cancel() | |||
| 300 | + | go func() { _ = client.Unload(context.Background(), model) }() | |||
| 301 | + | } | |||
| 302 | + | return | |||
| 303 | + | case llamaModelDetails, llamaQuantizations: | |||
| 304 | + | d.step = llamaSearch | |||
| 305 | + | d.cursor = 0 | |||
| 306 | + | d.mu.Unlock() | |||
| 307 | + | return | |||
| 308 | + | case llamaSearch: | |||
| 309 | + | if d.searchCancel != nil { | |||
| 310 | + | d.searchCancel() | |||
| 311 | + | d.searchCancel = nil | |||
| 312 | + | } | |||
| 313 | + | d.step = llamaModels | |||
| 314 | + | d.cursor = 0 | |||
| 315 | + | d.mu.Unlock() | |||
| 316 | + | return | |||
| 317 | + | case llamaConfirmUnload, llamaConfirmRemove: | |||
| 318 | + | d.step = llamaModels | |||
| 319 | + | d.cursor = 0 | |||
| 320 | + | d.mu.Unlock() | |||
| 321 | + | return | |||
| 322 | + | case llamaError: | |||
| 323 | + | d.step = llamaClosed | |||
| 324 | + | d.mu.Unlock() | |||
| 325 | + | return | |||
| 326 | + | default: | |||
| 327 | + | d.step = llamaClosed | |||
| 328 | + | d.mu.Unlock() | |||
| 329 | + | return | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | switch step { | |||
| 334 | + | case llamaModels: | |||
| 335 | + | count := len(d.models) + 1 | |||
| 336 | + | switch k.Kind { | |||
| 337 | + | case tui.KeyUp: | |||
| 338 | + | if d.cursor > 0 { | |||
| 339 | + | d.cursor-- | |||
| 340 | + | } | |||
| 341 | + | case tui.KeyDown: | |||
| 342 | + | if d.cursor+1 < count { | |||
| 343 | + | d.cursor++ | |||
| 344 | + | } | |||
| 345 | + | case tui.KeyEnter: | |||
| 346 | + | if d.cursor == len(d.models) { | |||
| 347 | + | d.step, d.cursor, d.query, d.message = llamaSearch, 0, "", "type at least 2 characters" | |||
| 348 | + | d.results = nil | |||
| 349 | + | d.mu.Unlock() | |||
| 350 | + | return | |||
| 351 | + | } | |||
| 352 | + | if len(d.models) > 0 { | |||
| 353 | + | model := d.models[d.cursor] | |||
| 354 | + | if llamaModelLoaded(model) { | |||
| 355 | + | d.selected = model.ID | |||
| 356 | + | d.step = llamaConfirmUnload | |||
| 357 | + | d.cursor = 0 | |||
| 358 | + | d.mu.Unlock() | |||
| 359 | + | return | |||
| 360 | + | } | |||
| 361 | + | if model.Status.Value == "unloaded" { | |||
| 362 | + | id := model.ID | |||
| 363 | + | d.mu.Unlock() | |||
| 364 | + | d.load(id) | |||
| 365 | + | return | |||
| 366 | + | } | |||
| 367 | + | d.message = model.ID + " is " + model.Status.Value | |||
| 368 | + | } | |||
| 369 | + | case tui.KeyRune: | |||
| 370 | + | if (k.Rune == 'd' || k.Rune == 'D') && !k.Ctrl && !k.Alt && d.cursor < len(d.models) { | |||
| 371 | + | model := d.models[d.cursor] | |||
| 372 | + | if !model.CanRemove { | |||
| 373 | + | d.message = "only router-downloaded cache models can be removed" | |||
| 374 | + | break | |||
| 375 | + | } | |||
| 376 | + | d.selected = model.ID | |||
| 377 | + | d.step = llamaConfirmRemove | |||
| 378 | + | d.cursor = 0 | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | case llamaConfirmUnload: | |||
| 382 | + | switch k.Kind { | |||
| 383 | + | case tui.KeyUp, tui.KeyDown: | |||
| 384 | + | d.cursor = 1 - d.cursor | |||
| 385 | + | case tui.KeyEnter: | |||
| 386 | + | if d.cursor == 0 { | |||
| 387 | + | id := d.selected | |||
| 388 | + | d.mu.Unlock() | |||
| 389 | + | d.unload(id) | |||
| 390 | + | return | |||
| 391 | + | } | |||
| 392 | + | d.step = llamaModels | |||
| 393 | + | } | |||
| 394 | + | case llamaConfirmRemove: | |||
| 395 | + | switch k.Kind { | |||
| 396 | + | case tui.KeyUp, tui.KeyDown: | |||
| 397 | + | d.cursor = 1 - d.cursor | |||
| 398 | + | case tui.KeyEnter: | |||
| 399 | + | if d.cursor == 0 { | |||
| 400 | + | id := d.selected | |||
| 401 | + | d.mu.Unlock() | |||
| 402 | + | d.remove(id) | |||
| 403 | + | return | |||
| 404 | + | } | |||
| 405 | + | d.step = llamaModels | |||
| 406 | + | } | |||
| 407 | + | case llamaSearch: | |||
| 408 | + | switch k.Kind { | |||
| 409 | + | case tui.KeyBackspace: | |||
| 410 | + | runes := []rune(d.query) | |||
| 411 | + | if len(runes) > 0 { | |||
| 412 | + | d.query = string(runes[:len(runes)-1]) | |||
| 413 | + | d.scheduleSearchLocked() | |||
| 414 | + | } | |||
| 415 | + | case tui.KeyPaste: | |||
| 416 | + | d.query += strings.Join(strings.Fields(k.Paste), " ") | |||
| 417 | + | d.scheduleSearchLocked() | |||
| 418 | + | case tui.KeyRune: | |||
| 419 | + | if !k.Ctrl && !k.Alt && k.Rune >= 0x20 && k.Rune < 0x7f { | |||
| 420 | + | d.query += string(k.Rune) | |||
| 421 | + | d.scheduleSearchLocked() | |||
| 422 | + | } | |||
| 423 | + | case tui.KeyUp: | |||
| 424 | + | if d.cursor > 0 { | |||
| 425 | + | d.cursor-- | |||
| 426 | + | } | |||
| 427 | + | case tui.KeyDown: | |||
| 428 | + | if d.cursor+1 < len(d.results) { | |||
| 429 | + | d.cursor++ | |||
| 430 | + | } | |||
| 431 | + | case tui.KeyEnter: | |||
| 432 | + | if len(d.results) == 0 { | |||
| 433 | + | break | |||
| 434 | + | } | |||
| 435 | + | if d.searchCancel != nil { | |||
| 436 | + | d.searchCancel() | |||
| 437 | + | d.searchCancel = nil | |||
| 438 | + | } | |||
| 439 | + | id := d.results[d.cursor].ID | |||
| 440 | + | hf := d.hf | |||
| 441 | + | d.step, d.message = llamaLoading, "loading model details" | |||
| 442 | + | d.mu.Unlock() | |||
| 443 | + | go func() { | |||
| 444 | + | details, err := hf.Details(context.Background(), id) | |||
| 445 | + | if err != nil { | |||
| 446 | + | d.setError(err) | |||
| 447 | + | return | |||
| 448 | + | } | |||
| 449 | + | d.mu.Lock() | |||
| 450 | + | if d.step != llamaClosed { | |||
| 451 | + | d.details, d.cursor = details, 0 | |||
| 452 | + | if len(details.Quantizations) > 0 { | |||
| 453 | + | d.step = llamaQuantizations | |||
| 454 | + | } else { | |||
| 455 | + | d.step = llamaModelDetails | |||
| 456 | + | } | |||
| 457 | + | } | |||
| 458 | + | d.mu.Unlock() | |||
| 459 | + | d.notify() | |||
| 460 | + | }() | |||
| 461 | + | return | |||
| 462 | + | } | |||
| 463 | + | case llamaModelDetails: | |||
| 464 | + | if k.Kind == tui.KeyEnter { | |||
| 465 | + | id := d.details.ID | |||
| 466 | + | d.mu.Unlock() | |||
| 467 | + | d.download(id) | |||
| 468 | + | return | |||
| 469 | + | } | |||
| 470 | + | case llamaQuantizations: | |||
| 471 | + | switch k.Kind { | |||
| 472 | + | case tui.KeyUp: | |||
| 473 | + | if d.cursor > 0 { | |||
| 474 | + | d.cursor-- | |||
| 475 | + | } | |||
| 476 | + | case tui.KeyDown: | |||
| 477 | + | if d.cursor+1 < len(d.details.Quantizations) { | |||
| 478 | + | d.cursor++ | |||
| 479 | + | } | |||
| 480 | + | case tui.KeyEnter: | |||
| 481 | + | if len(d.details.Quantizations) > 0 { | |||
| 482 | + | id := d.details.ID + ":" + d.details.Quantizations[d.cursor].Name | |||
| 483 | + | d.mu.Unlock() | |||
| 484 | + | d.download(id) | |||
| 485 | + | return | |||
| 486 | + | } | |||
| 487 | + | } | |||
| 488 | + | } | |||
| 489 | + | d.mu.Unlock() | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func (d *llamaDialog) Render(th tui.Theme, width int) []string { | |||
| 493 | + | d.mu.Lock() | |||
| 494 | + | defer d.mu.Unlock() | |||
| 495 | + | if d.step == llamaClosed { | |||
| 496 | + | return nil | |||
| 497 | + | } | |||
| 498 | + | lines := []string{frameHeader(th, "llama.cpp models", width)} | |||
| 499 | + | if d.client != nil { | |||
| 500 | + | lines = append(lines, th.FG256(th.Muted, d.client.ServerURL)) | |||
| 501 | + | } | |||
| 502 | + | if d.message != "" { | |||
| 503 | + | lines = append(lines, th.FG256(th.Muted, d.message)) | |||
| 504 | + | } | |||
| 505 | + | ||||
| 506 | + | switch d.step { | |||
| 507 | + | case llamaLoading: | |||
| 508 | + | lines = append(lines, th.FG256(th.Muted, " working...")) | |||
| 509 | + | case llamaModels: | |||
| 510 | + | const visible = 12 | |||
| 511 | + | start, end := dialogWindow(d.cursor, len(d.models)+1, visible) | |||
| 512 | + | for index := start; index < end; index++ { | |||
| 513 | + | label := "Download model from Hugging Face..." | |||
| 514 | + | if index < len(d.models) { | |||
| 515 | + | model := d.models[index] | |||
| 516 | + | status := model.Status.Value | |||
| 517 | + | if status == "loaded" || status == "sleeping" { | |||
| 518 | + | status = "loaded" | |||
| 519 | + | } | |||
| 520 | + | label = fmt.Sprintf("%-10s %s", status, model.ID) | |||
| 521 | + | } | |||
| 522 | + | line := " " + label | |||
| 523 | + | if index == d.cursor { | |||
| 524 | + | lines = append(lines, th.PadHighlight(line, width)) | |||
| 525 | + | } else { | |||
| 526 | + | lines = append(lines, th.FG256(th.Muted, line)) | |||
| 527 | + | } | |||
| 528 | + | } | |||
| 529 | + | lines = append(lines, th.FG256(th.Muted, "enter loads/unloads, d removes cached model, esc closes")) | |||
| 530 | + | case llamaConfirmUnload: | |||
| 531 | + | lines = append(lines, "", " Unload "+d.selected+"?") | |||
| 532 | + | for index, label := range []string{"Yes", "No"} { | |||
| 533 | + | line := " " + label | |||
| 534 | + | if index == d.cursor { | |||
| 535 | + | lines = append(lines, th.PadHighlight(line, width)) | |||
| 536 | + | } else { | |||
| 537 | + | lines = append(lines, th.FG256(th.Muted, line)) | |||
| 538 | + | } | |||
| 539 | + | } | |||
| 540 | + | case llamaConfirmRemove: | |||
| 541 | + | lines = append(lines, "", " Remove cached model "+d.selected+"?") | |||
| 542 | + | for index, label := range []string{"Yes", "No"} { | |||
| 543 | + | line := " " + label | |||
| 544 | + | if index == d.cursor { | |||
| 545 | + | lines = append(lines, th.PadHighlight(line, width)) | |||
| 546 | + | } else { | |||
| 547 | + | lines = append(lines, th.FG256(th.Muted, line)) | |||
| 548 | + | } | |||
| 549 | + | } | |||
| 550 | + | case llamaSearch: | |||
| 551 | + | lines = append(lines, "", th.FG256(th.Muted, " Model name or owner/repository"), th.FG256(th.FG, " "+d.query), "") | |||
| 552 | + | start, end := dialogWindow(d.cursor, len(d.results), 10) | |||
| 553 | + | for index := start; index < end; index++ { | |||
| 554 | + | result := d.results[index] | |||
| 555 | + | line := fmt.Sprintf(" %s %s downloads", result.ID, compactNumber(result.Downloads)) | |||
| 556 | + | if index == d.cursor { | |||
| 557 | + | lines = append(lines, th.PadHighlight(line, width)) | |||
| 558 | + | } else { | |||
| 559 | + | lines = append(lines, th.FG256(th.Muted, line)) | |||
| 560 | + | } | |||
| 561 | + | } | |||
| 562 | + | lines = append(lines, th.FG256(th.Muted, "enter selects, esc goes back")) | |||
| 563 | + | case llamaModelDetails: | |||
| 564 | + | lines = append(lines, "", " "+d.details.ID) | |||
| 565 | + | if d.details.Gated != "" { | |||
| 566 | + | lines = append(lines, th.FG256(th.Error, " access approval and HF_TOKEN may be required")) | |||
| 567 | + | } | |||
| 568 | + | lines = append(lines, th.FG256(th.Muted, " enter downloads, esc goes back")) | |||
| 569 | + | case llamaQuantizations: | |||
| 570 | + | lines = append(lines, "", " Select quantization for "+d.details.ID) | |||
| 571 | + | if d.details.Gated != "" { | |||
| 572 | + | lines = append(lines, th.FG256(th.Error, " gated model: approve access on huggingface.co and configure HF_TOKEN on the router")) | |||
| 573 | + | } | |||
| 574 | + | start, end := dialogWindow(d.cursor, len(d.details.Quantizations), 10) | |||
| 575 | + | for index := start; index < end; index++ { | |||
| 576 | + | quant := d.details.Quantizations[index] | |||
| 577 | + | detail := "" | |||
| 578 | + | if quant.HasSize { | |||
| 579 | + | detail = " " + provider.FormatBytes(quant.Size) | |||
| 580 | + | } | |||
| 581 | + | if quant.Name == "Q4_K_M" { | |||
| 582 | + | detail += " recommended" | |||
| 583 | + | } | |||
| 584 | + | line := " " + quant.Name + detail | |||
| 585 | + | if index == d.cursor { | |||
| 586 | + | lines = append(lines, th.PadHighlight(line, width)) | |||
| 587 | + | } else { | |||
| 588 | + | lines = append(lines, th.FG256(th.Muted, line)) | |||
| 589 | + | } | |||
| 590 | + | } | |||
| 591 | + | case llamaProgress: | |||
| 592 | + | lines = append(lines, "", " "+d.selected, th.FG256(th.Muted, " "+d.progress.Message)) | |||
| 593 | + | if d.progress.HasRatio { | |||
| 594 | + | const cells = 32 | |||
| 595 | + | filled := int(d.progress.Ratio*float64(cells) + .5) | |||
| 596 | + | filled = max(0, min(cells, filled)) | |||
| 597 | + | bar := strings.Repeat("#", filled) + strings.Repeat("-", cells-filled) | |||
| 598 | + | lines = append(lines, th.FG256(th.Accent, fmt.Sprintf(" %s %d%%", bar, int(d.progress.Ratio*100+.5)))) | |||
| 599 | + | } | |||
| 600 | + | if d.progress.Detail != "" { | |||
| 601 | + | lines = append(lines, th.FG256(th.Muted, " "+d.progress.Detail)) | |||
| 602 | + | } | |||
| 603 | + | lines = append(lines, th.FG256(th.Muted, " esc stops")) | |||
| 604 | + | case llamaError: | |||
| 605 | + | lines = append(lines, th.FG256(th.Error, " "+d.message), th.FG256(th.Muted, " esc closes")) | |||
| 606 | + | } | |||
| 607 | + | return append(lines, frameRule(th, width)) | |||
| 608 | + | } | |||
| 609 | + | ||||
| 610 | + | // CursorPos returns the search input caret location inside the padded dialog. | |||
| 611 | + | func (d *llamaDialog) CursorPos() (row, col int) { | |||
| 612 | + | d.mu.Lock() | |||
| 613 | + | defer d.mu.Unlock() | |||
| 614 | + | if d.step != llamaSearch { | |||
| 615 | + | return -1, -1 | |||
| 616 | + | } | |||
| 617 | + | row = 5 // header, injected frame padding, server URL, blank, prompt | |||
| 618 | + | if d.message != "" { | |||
| 619 | + | row++ | |||
| 620 | + | } | |||
| 621 | + | return row, 2 + runewidth.StringWidth(d.query) | |||
| 622 | + | } | |||
| 623 | + | ||||
| 624 | + | func dialogWindow(cursor, count, visible int) (int, int) { | |||
| 625 | + | if count <= visible { | |||
| 626 | + | return 0, count | |||
| 627 | + | } | |||
| 628 | + | start := cursor - visible/2 | |||
| 629 | + | if start < 0 { | |||
| 630 | + | start = 0 | |||
| 631 | + | } | |||
| 632 | + | if start+visible > count { | |||
| 633 | + | start = count - visible | |||
| 634 | + | } | |||
| 635 | + | return start, start + visible | |||
| 636 | + | } | |||
| 637 | + | ||||
| 638 | + | func compactNumber(value int64) string { | |||
| 639 | + | if value >= 1_000_000 { | |||
| 640 | + | return fmt.Sprintf("%.1fM", float64(value)/1_000_000) | |||
| 641 | + | } | |||
| 642 | + | if value >= 1_000 { | |||
| 643 | + | return fmt.Sprintf("%.1fk", float64(value)/1_000) | |||
| 644 | + | } | |||
| 645 | + | return fmt.Sprintf("%d", value) | |||
| 646 | + | } | |||
A packages/agent/modes/llama_dialog_test.go +152 -0
| @@ -0,0 +1,152 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestLlamaDialogSearchesAsUserTypes(t *testing.T) { | |
| 16 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 17 | + | if r.URL.Path != "/api/models" || r.URL.Query().Get("search") != "qw" { | |
| 18 | + | http.Error(w, "unexpected request", http.StatusBadRequest) | |
| 19 | + | return | |
| 20 | + | } | |
| 21 | + | fmt.Fprint(w, `[{"id":"owner/qwen-GGUF","downloads":42}]`) | |
| 22 | + | })) | |
| 23 | + | defer server.Close() | |
| 24 | + | ||
| 25 | + | hf := provider.NewHuggingFaceClient("") | |
| 26 | + | hf.BaseURL = server.URL | |
| 27 | + | updated := make(chan struct{}, 1) | |
| 28 | + | d := newLlamaDialog() | |
| 29 | + | d.step = llamaSearch | |
| 30 | + | d.hf = hf | |
| 31 | + | d.invalidate = func() { | |
| 32 | + | select { | |
| 33 | + | case updated <- struct{}{}: | |
| 34 | + | default: | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'q'}) | |
| 38 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'w'}) | |
| 39 | + | ||
| 40 | + | select { | |
| 41 | + | case <-updated: | |
| 42 | + | case <-time.After(2 * time.Second): | |
| 43 | + | t.Fatal("timed out waiting for live search") | |
| 44 | + | } | |
| 45 | + | d.mu.Lock() | |
| 46 | + | defer d.mu.Unlock() | |
| 47 | + | if len(d.results) != 1 || d.results[0].ID != "owner/qwen-GGUF" { | |
| 48 | + | t.Fatalf("results = %#v", d.results) | |
| 49 | + | } | |
| 50 | + | if d.message != "" { | |
| 51 | + | t.Fatalf("message = %q", d.message) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestLlamaDialogSearchAcceptsPastedText(t *testing.T) { | |
| 56 | + | d := newLlamaDialog() | |
| 57 | + | d.step = llamaSearch | |
| 58 | + | ||
| 59 | + | d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "unsloth/Qwen3.5-4B-GGUF\n"}) | |
| 60 | + | defer d.Close() | |
| 61 | + | ||
| 62 | + | if d.query != "unsloth/Qwen3.5-4B-GGUF" { | |
| 63 | + | t.Fatalf("query = %q", d.query) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestLlamaDialogSearchCursorTracksQuery(t *testing.T) { | |
| 68 | + | d := newLlamaDialog() | |
| 69 | + | d.step = llamaSearch | |
| 70 | + | d.query = "qwen" | |
| 71 | + | row, col := d.CursorPos() | |
| 72 | + | if row != 5 || col != 6 { | |
| 73 | + | t.Fatalf("cursor = (%d, %d)", row, col) | |
| 74 | + | } | |
| 75 | + | d.message = "searching Hugging Face..." | |
| 76 | + | row, _ = d.CursorPos() | |
| 77 | + | if row != 6 { | |
| 78 | + | t.Fatalf("cursor row with status = %d", row) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestLlamaDialogLoadedModelRequiresUnloadConfirmation(t *testing.T) { | |
| 83 | + | d := newLlamaDialog() | |
| 84 | + | var loaded provider.LlamaCPPModel | |
| 85 | + | loaded.ID = "local-model" | |
| 86 | + | loaded.Status.Value = "loaded" | |
| 87 | + | d.step = llamaModels | |
| 88 | + | d.models = []provider.LlamaCPPModel{loaded} | |
| 89 | + | ||
| 90 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 91 | + | if d.step != llamaConfirmUnload || d.selected != "local-model" { | |
| 92 | + | t.Fatalf("step = %v, selected = %q", d.step, d.selected) | |
| 93 | + | } | |
| 94 | + | lines := d.Render(tui.Theme{}, 80) | |
| 95 | + | if !strings.Contains(strings.Join(lines, "\n"), "Unload local-model?") { | |
| 96 | + | t.Fatalf("render = %q", lines) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestLlamaDialogRemovableModelRequiresDiskRemovalConfirmation(t *testing.T) { | |
| 101 | + | d := newLlamaDialog() | |
| 102 | + | var cached provider.LlamaCPPModel | |
| 103 | + | cached.ID = "owner/model:Q4_K_M" | |
| 104 | + | cached.Status.Value = "unloaded" | |
| 105 | + | cached.CanRemove = true | |
| 106 | + | d.step = llamaModels | |
| 107 | + | d.models = []provider.LlamaCPPModel{cached} | |
| 108 | + | ||
| 109 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) | |
| 110 | + | if d.step != llamaConfirmRemove || d.selected != cached.ID { | |
| 111 | + | t.Fatalf("step = %v, selected = %q", d.step, d.selected) | |
| 112 | + | } | |
| 113 | + | lines := d.Render(tui.Theme{}, 80) | |
| 114 | + | if !strings.Contains(strings.Join(lines, "\n"), "Remove cached model "+cached.ID+"?") { | |
| 115 | + | t.Fatalf("render = %q", lines) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 119 | + | if d.step != llamaModels { | |
| 120 | + | t.Fatalf("step after escape = %v", d.step) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestLlamaDialogDoesNotRemoveModelsDirEntry(t *testing.T) { | |
| 125 | + | d := newLlamaDialog() | |
| 126 | + | var local provider.LlamaCPPModel | |
| 127 | + | local.ID = "local-model" | |
| 128 | + | local.Status.Value = "unloaded" | |
| 129 | + | d.step = llamaModels | |
| 130 | + | d.models = []provider.LlamaCPPModel{local} | |
| 131 | + | ||
| 132 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) | |
| 133 | + | if d.step != llamaModels { | |
| 134 | + | t.Fatalf("step = %v", d.step) | |
| 135 | + | } | |
| 136 | + | if !strings.Contains(d.message, "only router-downloaded cache models") { | |
| 137 | + | t.Fatalf("message = %q", d.message) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestLlamaDialogDownloadSearchReturnsToModels(t *testing.T) { | |
| 142 | + | d := newLlamaDialog() | |
| 143 | + | d.step = llamaModels | |
| 144 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 145 | + | if d.step != llamaSearch { | |
| 146 | + | t.Fatalf("step = %v", d.step) | |
| 147 | + | } | |
| 148 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |
| 149 | + | if d.step != llamaModels { | |
| 150 | + | t.Fatalf("step after escape = %v", d.step) | |
| 151 | + | } | |
| 152 | + | } | |
| @@ -0,0 +1,152 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 12 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestLlamaDialogSearchesAsUserTypes(t *testing.T) { | |||
| 16 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 17 | + | if r.URL.Path != "/api/models" || r.URL.Query().Get("search") != "qw" { | |||
| 18 | + | http.Error(w, "unexpected request", http.StatusBadRequest) | |||
| 19 | + | return | |||
| 20 | + | } | |||
| 21 | + | fmt.Fprint(w, `[{"id":"owner/qwen-GGUF","downloads":42}]`) | |||
| 22 | + | })) | |||
| 23 | + | defer server.Close() | |||
| 24 | + | ||||
| 25 | + | hf := provider.NewHuggingFaceClient("") | |||
| 26 | + | hf.BaseURL = server.URL | |||
| 27 | + | updated := make(chan struct{}, 1) | |||
| 28 | + | d := newLlamaDialog() | |||
| 29 | + | d.step = llamaSearch | |||
| 30 | + | d.hf = hf | |||
| 31 | + | d.invalidate = func() { | |||
| 32 | + | select { | |||
| 33 | + | case updated <- struct{}{}: | |||
| 34 | + | default: | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'q'}) | |||
| 38 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'w'}) | |||
| 39 | + | ||||
| 40 | + | select { | |||
| 41 | + | case <-updated: | |||
| 42 | + | case <-time.After(2 * time.Second): | |||
| 43 | + | t.Fatal("timed out waiting for live search") | |||
| 44 | + | } | |||
| 45 | + | d.mu.Lock() | |||
| 46 | + | defer d.mu.Unlock() | |||
| 47 | + | if len(d.results) != 1 || d.results[0].ID != "owner/qwen-GGUF" { | |||
| 48 | + | t.Fatalf("results = %#v", d.results) | |||
| 49 | + | } | |||
| 50 | + | if d.message != "" { | |||
| 51 | + | t.Fatalf("message = %q", d.message) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestLlamaDialogSearchAcceptsPastedText(t *testing.T) { | |||
| 56 | + | d := newLlamaDialog() | |||
| 57 | + | d.step = llamaSearch | |||
| 58 | + | ||||
| 59 | + | d.HandleKey(tui.Key{Kind: tui.KeyPaste, Paste: "unsloth/Qwen3.5-4B-GGUF\n"}) | |||
| 60 | + | defer d.Close() | |||
| 61 | + | ||||
| 62 | + | if d.query != "unsloth/Qwen3.5-4B-GGUF" { | |||
| 63 | + | t.Fatalf("query = %q", d.query) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestLlamaDialogSearchCursorTracksQuery(t *testing.T) { | |||
| 68 | + | d := newLlamaDialog() | |||
| 69 | + | d.step = llamaSearch | |||
| 70 | + | d.query = "qwen" | |||
| 71 | + | row, col := d.CursorPos() | |||
| 72 | + | if row != 5 || col != 6 { | |||
| 73 | + | t.Fatalf("cursor = (%d, %d)", row, col) | |||
| 74 | + | } | |||
| 75 | + | d.message = "searching Hugging Face..." | |||
| 76 | + | row, _ = d.CursorPos() | |||
| 77 | + | if row != 6 { | |||
| 78 | + | t.Fatalf("cursor row with status = %d", row) | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestLlamaDialogLoadedModelRequiresUnloadConfirmation(t *testing.T) { | |||
| 83 | + | d := newLlamaDialog() | |||
| 84 | + | var loaded provider.LlamaCPPModel | |||
| 85 | + | loaded.ID = "local-model" | |||
| 86 | + | loaded.Status.Value = "loaded" | |||
| 87 | + | d.step = llamaModels | |||
| 88 | + | d.models = []provider.LlamaCPPModel{loaded} | |||
| 89 | + | ||||
| 90 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 91 | + | if d.step != llamaConfirmUnload || d.selected != "local-model" { | |||
| 92 | + | t.Fatalf("step = %v, selected = %q", d.step, d.selected) | |||
| 93 | + | } | |||
| 94 | + | lines := d.Render(tui.Theme{}, 80) | |||
| 95 | + | if !strings.Contains(strings.Join(lines, "\n"), "Unload local-model?") { | |||
| 96 | + | t.Fatalf("render = %q", lines) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestLlamaDialogRemovableModelRequiresDiskRemovalConfirmation(t *testing.T) { | |||
| 101 | + | d := newLlamaDialog() | |||
| 102 | + | var cached provider.LlamaCPPModel | |||
| 103 | + | cached.ID = "owner/model:Q4_K_M" | |||
| 104 | + | cached.Status.Value = "unloaded" | |||
| 105 | + | cached.CanRemove = true | |||
| 106 | + | d.step = llamaModels | |||
| 107 | + | d.models = []provider.LlamaCPPModel{cached} | |||
| 108 | + | ||||
| 109 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) | |||
| 110 | + | if d.step != llamaConfirmRemove || d.selected != cached.ID { | |||
| 111 | + | t.Fatalf("step = %v, selected = %q", d.step, d.selected) | |||
| 112 | + | } | |||
| 113 | + | lines := d.Render(tui.Theme{}, 80) | |||
| 114 | + | if !strings.Contains(strings.Join(lines, "\n"), "Remove cached model "+cached.ID+"?") { | |||
| 115 | + | t.Fatalf("render = %q", lines) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |||
| 119 | + | if d.step != llamaModels { | |||
| 120 | + | t.Fatalf("step after escape = %v", d.step) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestLlamaDialogDoesNotRemoveModelsDirEntry(t *testing.T) { | |||
| 125 | + | d := newLlamaDialog() | |||
| 126 | + | var local provider.LlamaCPPModel | |||
| 127 | + | local.ID = "local-model" | |||
| 128 | + | local.Status.Value = "unloaded" | |||
| 129 | + | d.step = llamaModels | |||
| 130 | + | d.models = []provider.LlamaCPPModel{local} | |||
| 131 | + | ||||
| 132 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'd'}) | |||
| 133 | + | if d.step != llamaModels { | |||
| 134 | + | t.Fatalf("step = %v", d.step) | |||
| 135 | + | } | |||
| 136 | + | if !strings.Contains(d.message, "only router-downloaded cache models") { | |||
| 137 | + | t.Fatalf("message = %q", d.message) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestLlamaDialogDownloadSearchReturnsToModels(t *testing.T) { | |||
| 142 | + | d := newLlamaDialog() | |||
| 143 | + | d.step = llamaModels | |||
| 144 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 145 | + | if d.step != llamaSearch { | |||
| 146 | + | t.Fatalf("step = %v", d.step) | |||
| 147 | + | } | |||
| 148 | + | d.HandleKey(tui.Key{Kind: tui.KeyEsc}) | |||
| 149 | + | if d.step != llamaModels { | |||
| 150 | + | t.Fatalf("step after escape = %v", d.step) | |||
| 151 | + | } | |||
| 152 | + | } | |||
M packages/agent/modes/login_dialog.go +144 -22
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "sort" | |
| 7 | + | "strings" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | 10 | "github.com/patriceckhart/zot/packages/provider/auth" | |
| @@ -20,6 +21,8 @@ const ( | |||
| 20 | 21 | loginStepClosed loginStep = iota | |
| 21 | 22 | loginStepMethod // pick apikey vs subscription | |
| 22 | 23 | loginStepProvider // pick anthropic vs openai vs kimi | |
| 24 | + | loginStepLlamaURL // enter and validate llama.cpp router URL | |
| 25 | + | loginStepLlamaKey // enter optional llama.cpp API key | |
| 23 | 26 | loginStepWaiting // browser open, waiting for callback | |
| 24 | 27 | loginStepPasteCode // user pastes the auth code here | |
| 25 | 28 | loginStepInfo // informational setup guidance | |
| @@ -31,16 +34,19 @@ const loginProviderPageSize = 8 | |||
| 31 | 34 | // loginDialog is a tiny inline dialog rendered above the editor while | |
| 32 | 35 | // the user picks their login method and provider. | |
| 33 | 36 | type loginDialog struct { | |
| 34 | - | step loginStep | |
| 35 | - | method string // "apikey" | "oauth" | |
| 36 | - | provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google" | |
| 37 | - | message string | |
| 38 | - | success bool | |
| 39 | - | url string | |
| 40 | - | cursor int | |
| 41 | - | codeEd *tui.Editor | |
| 42 | - | infoTitle string | |
| 43 | - | infoLines []string | |
| 37 | + | step loginStep | |
| 38 | + | method string // "apikey" | "oauth" | |
| 39 | + | provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google" | |
| 40 | + | message string | |
| 41 | + | success bool | |
| 42 | + | url string | |
| 43 | + | cursor int | |
| 44 | + | providerQuery string | |
| 45 | + | codeEd *tui.Editor | |
| 46 | + | llamaEd *tui.Editor | |
| 47 | + | llamaURL string | |
| 48 | + | infoTitle string | |
| 49 | + | infoLines []string | |
| 44 | 50 | ||
| 45 | 51 | // status is a snapshot of the current login state for each | |
| 46 | 52 | // provider, captured when Open() runs. Rendered above the | |
| @@ -72,6 +78,9 @@ func (d *loginDialog) Open(zotHome string) { | |||
| 72 | 78 | d.success = false | |
| 73 | 79 | d.url = "" | |
| 74 | 80 | d.cursor = 0 | |
| 81 | + | d.providerQuery = "" | |
| 82 | + | d.llamaEd = nil | |
| 83 | + | d.llamaURL = "" | |
| 75 | 84 | d.infoTitle = "" | |
| 76 | 85 | d.infoLines = nil | |
| 77 | 86 | d.status = map[string]string{} | |
| @@ -139,12 +148,19 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 139 | 148 | } | |
| 140 | 149 | lines = append(lines, frameRule(th, width)) | |
| 141 | 150 | case loginStepProvider: | |
| 142 | - | opts := providersForMethod(d.method) | |
| 151 | + | opts := d.providerOptions() | |
| 143 | 152 | lines = append(lines, frameHeader(th, "login - "+d.method, width)) | |
| 144 | 153 | for _, l := range d.renderStatusLines(th) { | |
| 145 | 154 | lines = append(lines, l) | |
| 146 | 155 | } | |
| 147 | - | lines = append(lines, th.FG256(th.Muted, "pick a provider (↑/↓, enter, esc to cancel)")) | |
| 156 | + | hint := "pick a provider (↑/↓, enter, esc to cancel) - type to filter" | |
| 157 | + | if d.providerQuery != "" { | |
| 158 | + | hint = fmt.Sprintf("filter: %s (%d match)", d.providerQuery, len(opts)) | |
| 159 | + | if len(opts) != 1 { | |
| 160 | + | hint = fmt.Sprintf("filter: %s (%d matches)", d.providerQuery, len(opts)) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | lines = append(lines, th.FG256(th.Muted, hint)) | |
| 148 | 164 | start, end := d.providerPage(len(opts)) | |
| 149 | 165 | for i := start; i < end; i++ { | |
| 150 | 166 | o := opts[i] | |
| @@ -161,10 +177,34 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 161 | 177 | lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag)) | |
| 162 | 178 | } | |
| 163 | 179 | } | |
| 164 | - | if len(opts) > loginProviderPageSize { | |
| 180 | + | if len(opts) == 0 { | |
| 181 | + | lines = append(lines, th.FG256(th.Muted, " no providers match "+fmt.Sprintf("%q", d.providerQuery))) | |
| 182 | + | } else if len(opts) > loginProviderPageSize { | |
| 165 | 183 | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts)))) | |
| 166 | 184 | } | |
| 167 | 185 | lines = append(lines, frameRule(th, width)) | |
| 186 | + | case loginStepLlamaURL: | |
| 187 | + | lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width)) | |
| 188 | + | lines = append(lines, th.FG256(th.Muted, "llama.cpp router URL:")) | |
| 189 | + | if d.llamaEd == nil { | |
| 190 | + | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 191 | + | } | |
| 192 | + | edLines, _, _ := d.llamaEd.Render(width - 2) | |
| 193 | + | lines = append(lines, edLines...) | |
| 194 | + | if d.message != "" { | |
| 195 | + | lines = append(lines, th.FG256(th.Error, d.message)) | |
| 196 | + | } | |
| 197 | + | lines = append(lines, "", th.FG256(th.Muted, "enter continues - esc cancels"), frameRule(th, width)) | |
| 198 | + | case loginStepLlamaKey: | |
| 199 | + | lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width)) | |
| 200 | + | lines = append(lines, th.FG256(th.Muted, "router: "+d.llamaURL), "", th.FG256(th.Muted, "API key (optional):")) | |
| 201 | + | if d.llamaEd == nil { | |
| 202 | + | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 203 | + | } | |
| 204 | + | d.llamaEd.Mask = true | |
| 205 | + | edLines, _, _ := d.llamaEd.Render(width - 2) | |
| 206 | + | lines = append(lines, edLines...) | |
| 207 | + | lines = append(lines, "", th.FG256(th.Muted, "enter saves - esc cancels"), frameRule(th, width)) | |
| 168 | 208 | case loginStepWaiting: | |
| 169 | 209 | lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width)) | |
| 170 | 210 | lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:")) | |
| @@ -276,6 +316,21 @@ func providersForMethod(method string) []string { | |||
| 276 | 316 | // providerLabel returns the user-facing label for a provider id. | |
| 277 | 317 | func providerLabel(id string) string { return provider.ProviderLabel(id) } | |
| 278 | 318 | ||
| 319 | + | func (d *loginDialog) providerOptions() []string { | |
| 320 | + | providers := providersForMethod(d.method) | |
| 321 | + | needle := normalizeModelQuery(d.providerQuery) | |
| 322 | + | if needle == "" { | |
| 323 | + | return providers | |
| 324 | + | } | |
| 325 | + | filtered := make([]string, 0, len(providers)) | |
| 326 | + | for _, id := range providers { | |
| 327 | + | if strings.Contains(normalizeModelQuery(id+" "+providerLabel(id)), needle) { | |
| 328 | + | filtered = append(filtered, id) | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | return filtered | |
| 332 | + | } | |
| 333 | + | ||
| 279 | 334 | func providerPickerTag(method, status string) string { | |
| 280 | 335 | switch method { | |
| 281 | 336 | case "apikey": | |
| @@ -377,7 +432,10 @@ type loginDialogAction struct { | |||
| 377 | 432 | StartAPIKey bool | |
| 378 | 433 | StartOAuth bool | |
| 379 | 434 | StartManual bool | |
| 435 | + | SaveLlama bool | |
| 380 | 436 | Provider string | |
| 437 | + | LlamaURL string | |
| 438 | + | LlamaAPIKey string | |
| 381 | 439 | Close bool | |
| 382 | 440 | SubmitCode string | |
| 383 | 441 | } | |
| @@ -389,6 +447,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction { | |||
| 389 | 447 | return d.handleMethodKey(k) | |
| 390 | 448 | case loginStepProvider: | |
| 391 | 449 | return d.handleProviderKey(k) | |
| 450 | + | case loginStepLlamaURL, loginStepLlamaKey: | |
| 451 | + | return d.handleLlamaKey(k) | |
| 392 | 452 | case loginStepWaiting: | |
| 393 | 453 | return d.handleWaitingKey(k) | |
| 394 | 454 | case loginStepPasteCode: | |
| @@ -425,6 +485,7 @@ func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction { | |||
| 425 | 485 | } | |
| 426 | 486 | d.step = loginStepProvider | |
| 427 | 487 | d.cursor = 0 | |
| 488 | + | d.providerQuery = "" | |
| 428 | 489 | } | |
| 429 | 490 | return loginDialogAction{} | |
| 430 | 491 | } | |
| @@ -436,7 +497,7 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 436 | 497 | d.cursor-- | |
| 437 | 498 | } | |
| 438 | 499 | case tui.KeyDown: | |
| 439 | - | providers := providersForMethod(d.method) | |
| 500 | + | providers := d.providerOptions() | |
| 440 | 501 | if d.cursor < len(providers)-1 { | |
| 441 | 502 | d.cursor++ | |
| 442 | 503 | } | |
| @@ -446,20 +507,39 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 446 | 507 | d.cursor = 0 | |
| 447 | 508 | } | |
| 448 | 509 | case tui.KeyPageDown: | |
| 449 | - | providers := providersForMethod(d.method) | |
| 450 | - | d.cursor += loginProviderPageSize | |
| 451 | - | if d.cursor >= len(providers) { | |
| 452 | - | d.cursor = len(providers) - 1 | |
| 510 | + | providers := d.providerOptions() | |
| 511 | + | if len(providers) > 0 { | |
| 512 | + | d.cursor += loginProviderPageSize | |
| 513 | + | if d.cursor >= len(providers) { | |
| 514 | + | d.cursor = len(providers) - 1 | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | case tui.KeyBackspace: | |
| 518 | + | if len(d.providerQuery) > 0 { | |
| 519 | + | runes := []rune(d.providerQuery) | |
| 520 | + | d.providerQuery = string(runes[:len(runes)-1]) | |
| 521 | + | d.cursor = 0 | |
| 522 | + | } | |
| 523 | + | case tui.KeyRune: | |
| 524 | + | if !k.Alt && !k.Ctrl && k.Rune >= 0x20 && k.Rune < 0x7f { | |
| 525 | + | d.providerQuery += string(k.Rune) | |
| 526 | + | d.cursor = 0 | |
| 453 | 527 | } | |
| 454 | 528 | case tui.KeyEsc: | |
| 455 | 529 | d.Close() | |
| 456 | 530 | return loginDialogAction{Close: true} | |
| 457 | 531 | case tui.KeyEnter: | |
| 458 | - | providers := providersForMethod(d.method) | |
| 532 | + | providers := d.providerOptions() | |
| 459 | 533 | if d.cursor < 0 || d.cursor >= len(providers) { | |
| 460 | 534 | return loginDialogAction{} | |
| 461 | 535 | } | |
| 462 | 536 | d.provider = providers[d.cursor] | |
| 537 | + | if d.method == "apikey" && d.provider == provider.LlamaCPPProviderID { | |
| 538 | + | d.step = loginStepLlamaURL | |
| 539 | + | d.llamaEd = nil | |
| 540 | + | d.message = "" | |
| 541 | + | return loginDialogAction{} | |
| 542 | + | } | |
| 463 | 543 | d.step = loginStepWaiting | |
| 464 | 544 | if d.method == "apikey" { | |
| 465 | 545 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | |
| @@ -469,6 +549,37 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 469 | 549 | return loginDialogAction{} | |
| 470 | 550 | } | |
| 471 | 551 | ||
| 552 | + | func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | |
| 553 | + | if k.Kind == tui.KeyEsc { | |
| 554 | + | d.Close() | |
| 555 | + | return loginDialogAction{Close: true} | |
| 556 | + | } | |
| 557 | + | if d.llamaEd == nil { | |
| 558 | + | d.llamaEd = tui.NewEditor("") | |
| 559 | + | } | |
| 560 | + | if !d.llamaEd.HandleKey(k) { | |
| 561 | + | return loginDialogAction{} | |
| 562 | + | } | |
| 563 | + | value := strings.TrimSpace(d.llamaEd.SubmitValue()) | |
| 564 | + | d.llamaEd.Clear() | |
| 565 | + | if d.step == loginStepLlamaURL { | |
| 566 | + | normalized, err := provider.NormalizeLlamaCPPURL(value) | |
| 567 | + | if err != nil { | |
| 568 | + | d.message = err.Error() | |
| 569 | + | return loginDialogAction{} | |
| 570 | + | } | |
| 571 | + | d.llamaURL = normalized | |
| 572 | + | d.message = "" | |
| 573 | + | d.llamaEd.Mask = true | |
| 574 | + | d.step = loginStepLlamaKey | |
| 575 | + | return loginDialogAction{} | |
| 576 | + | } | |
| 577 | + | return loginDialogAction{ | |
| 578 | + | SaveLlama: true, Provider: provider.LlamaCPPProviderID, | |
| 579 | + | LlamaURL: d.llamaURL, LlamaAPIKey: value, | |
| 580 | + | } | |
| 581 | + | } | |
| 582 | + | ||
| 472 | 583 | // ShowWaiting transitions to the waiting state with the given URL. | |
| 473 | 584 | // No-op if the user has already dismissed the dialog. | |
| 474 | 585 | func (d *loginDialog) ShowWaiting(url string) { | |
| @@ -534,10 +645,21 @@ func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction { | |||
| 534 | 645 | } | |
| 535 | 646 | ||
| 536 | 647 | // CursorPos returns the absolute row/col inside the dialog where the | |
| 537 | - | // terminal cursor should sit (paste-code step). Returns -1, -1 if the | |
| 538 | - | // dialog is not in an input-expecting state. The host uses this to | |
| 539 | - | // place the real blinking cursor on the code input. | |
| 648 | + | // terminal cursor should sit for code, URL, and API-key input. Returns -1, | |
| 649 | + | // -1 if the dialog is not in an input-expecting state. The host uses this to | |
| 650 | + | // place the real blinking cursor on the active input. | |
| 540 | 651 | func (d *loginDialog) CursorPos(width int) (row, col int) { | |
| 652 | + | if d.step == loginStepLlamaURL || d.step == loginStepLlamaKey { | |
| 653 | + | if d.llamaEd == nil { | |
| 654 | + | return -1, -1 | |
| 655 | + | } | |
| 656 | + | _, eRow, eCol := d.llamaEd.Render(width - 2) | |
| 657 | + | baseOffset := 1 /*frameHeader*/ + 1 /*padDialogFrame blank*/ + 1 /*prompt*/ | |
| 658 | + | if d.step == loginStepLlamaKey { | |
| 659 | + | baseOffset += 2 // router line + blank | |
| 660 | + | } | |
| 661 | + | return baseOffset + eRow, eCol | |
| 662 | + | } | |
| 541 | 663 | if d.codeEd == nil { | |
| 542 | 664 | return -1, -1 | |
| 543 | 665 | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | "sort" | 6 | "sort" | ||
| 7 | + | "strings" | |||
| 7 | 8 | ||||
| 8 | "github.com/patriceckhart/zot/packages/provider" | 9 | "github.com/patriceckhart/zot/packages/provider" | ||
| 9 | "github.com/patriceckhart/zot/packages/provider/auth" | 10 | "github.com/patriceckhart/zot/packages/provider/auth" | ||
| @@ -20,6 +21,8 @@ const ( | |||||
| 20 | loginStepClosed loginStep = iota | 21 | loginStepClosed loginStep = iota | ||
| 21 | loginStepMethod // pick apikey vs subscription | 22 | loginStepMethod // pick apikey vs subscription | ||
| 22 | loginStepProvider // pick anthropic vs openai vs kimi | 23 | loginStepProvider // pick anthropic vs openai vs kimi | ||
| 24 | + | loginStepLlamaURL // enter and validate llama.cpp router URL | |||
| 25 | + | loginStepLlamaKey // enter optional llama.cpp API key | |||
| 23 | loginStepWaiting // browser open, waiting for callback | 26 | loginStepWaiting // browser open, waiting for callback | ||
| 24 | loginStepPasteCode // user pastes the auth code here | 27 | loginStepPasteCode // user pastes the auth code here | ||
| 25 | loginStepInfo // informational setup guidance | 28 | loginStepInfo // informational setup guidance | ||
| @@ -31,16 +34,19 @@ const loginProviderPageSize = 8 | |||||
| 31 | // loginDialog is a tiny inline dialog rendered above the editor while | 34 | // loginDialog is a tiny inline dialog rendered above the editor while | ||
| 32 | // the user picks their login method and provider. | 35 | // the user picks their login method and provider. | ||
| 33 | type loginDialog struct { | 36 | type loginDialog struct { | ||
| 34 | - | step loginStep | 37 | + | step loginStep |
| 35 | - | method string // "apikey" | "oauth" | 38 | + | method string // "apikey" | "oauth" |
| 36 | - | provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google" | 39 | + | provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google" |
| 37 | - | message string | 40 | + | message string |
| 38 | - | success bool | 41 | + | success bool |
| 39 | - | url string | 42 | + | url string |
| 40 | - | cursor int | 43 | + | cursor int |
| 41 | - | codeEd *tui.Editor | 44 | + | providerQuery string |
| 42 | - | infoTitle string | 45 | + | codeEd *tui.Editor |
| 43 | - | infoLines []string | 46 | + | llamaEd *tui.Editor |
| 47 | + | llamaURL string | |||
| 48 | + | infoTitle string | |||
| 49 | + | infoLines []string | |||
| 44 | 50 | ||||
| 45 | // status is a snapshot of the current login state for each | 51 | // status is a snapshot of the current login state for each | ||
| 46 | // provider, captured when Open() runs. Rendered above the | 52 | // provider, captured when Open() runs. Rendered above the | ||
| @@ -72,6 +78,9 @@ func (d *loginDialog) Open(zotHome string) { | |||||
| 72 | d.success = false | 78 | d.success = false | ||
| 73 | d.url = "" | 79 | d.url = "" | ||
| 74 | d.cursor = 0 | 80 | d.cursor = 0 | ||
| 81 | + | d.providerQuery = "" | |||
| 82 | + | d.llamaEd = nil | |||
| 83 | + | d.llamaURL = "" | |||
| 75 | d.infoTitle = "" | 84 | d.infoTitle = "" | ||
| 76 | d.infoLines = nil | 85 | d.infoLines = nil | ||
| 77 | d.status = map[string]string{} | 86 | d.status = map[string]string{} | ||
| @@ -139,12 +148,19 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 139 | } | 148 | } | ||
| 140 | lines = append(lines, frameRule(th, width)) | 149 | lines = append(lines, frameRule(th, width)) | ||
| 141 | case loginStepProvider: | 150 | case loginStepProvider: | ||
| 142 | - | opts := providersForMethod(d.method) | 151 | + | opts := d.providerOptions() |
| 143 | lines = append(lines, frameHeader(th, "login - "+d.method, width)) | 152 | lines = append(lines, frameHeader(th, "login - "+d.method, width)) | ||
| 144 | for _, l := range d.renderStatusLines(th) { | 153 | for _, l := range d.renderStatusLines(th) { | ||
| 145 | lines = append(lines, l) | 154 | lines = append(lines, l) | ||
| 146 | } | 155 | } | ||
| 147 | - | lines = append(lines, th.FG256(th.Muted, "pick a provider (↑/↓, enter, esc to cancel)")) | 156 | + | hint := "pick a provider (↑/↓, enter, esc to cancel) - type to filter" |
| 157 | + | if d.providerQuery != "" { | |||
| 158 | + | hint = fmt.Sprintf("filter: %s (%d match)", d.providerQuery, len(opts)) | |||
| 159 | + | if len(opts) != 1 { | |||
| 160 | + | hint = fmt.Sprintf("filter: %s (%d matches)", d.providerQuery, len(opts)) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | lines = append(lines, th.FG256(th.Muted, hint)) | |||
| 148 | start, end := d.providerPage(len(opts)) | 164 | start, end := d.providerPage(len(opts)) | ||
| 149 | for i := start; i < end; i++ { | 165 | for i := start; i < end; i++ { | ||
| 150 | o := opts[i] | 166 | o := opts[i] | ||
| @@ -161,10 +177,34 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 161 | lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag)) | 177 | lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag)) | ||
| 162 | } | 178 | } | ||
| 163 | } | 179 | } | ||
| 164 | - | if len(opts) > loginProviderPageSize { | 180 | + | if len(opts) == 0 { |
| 181 | + | lines = append(lines, th.FG256(th.Muted, " no providers match "+fmt.Sprintf("%q", d.providerQuery))) | |||
| 182 | + | } else if len(opts) > loginProviderPageSize { | |||
| 165 | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts)))) | 183 | lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts)))) | ||
| 166 | } | 184 | } | ||
| 167 | lines = append(lines, frameRule(th, width)) | 185 | lines = append(lines, frameRule(th, width)) | ||
| 186 | + | case loginStepLlamaURL: | |||
| 187 | + | lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width)) | |||
| 188 | + | lines = append(lines, th.FG256(th.Muted, "llama.cpp router URL:")) | |||
| 189 | + | if d.llamaEd == nil { | |||
| 190 | + | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | |||
| 191 | + | } | |||
| 192 | + | edLines, _, _ := d.llamaEd.Render(width - 2) | |||
| 193 | + | lines = append(lines, edLines...) | |||
| 194 | + | if d.message != "" { | |||
| 195 | + | lines = append(lines, th.FG256(th.Error, d.message)) | |||
| 196 | + | } | |||
| 197 | + | lines = append(lines, "", th.FG256(th.Muted, "enter continues - esc cancels"), frameRule(th, width)) | |||
| 198 | + | case loginStepLlamaKey: | |||
| 199 | + | lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width)) | |||
| 200 | + | lines = append(lines, th.FG256(th.Muted, "router: "+d.llamaURL), "", th.FG256(th.Muted, "API key (optional):")) | |||
| 201 | + | if d.llamaEd == nil { | |||
| 202 | + | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | |||
| 203 | + | } | |||
| 204 | + | d.llamaEd.Mask = true | |||
| 205 | + | edLines, _, _ := d.llamaEd.Render(width - 2) | |||
| 206 | + | lines = append(lines, edLines...) | |||
| 207 | + | lines = append(lines, "", th.FG256(th.Muted, "enter saves - esc cancels"), frameRule(th, width)) | |||
| 168 | case loginStepWaiting: | 208 | case loginStepWaiting: | ||
| 169 | lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width)) | 209 | lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width)) | ||
| 170 | lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:")) | 210 | lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:")) | ||
| @@ -276,6 +316,21 @@ func providersForMethod(method string) []string { | |||||
| 276 | // providerLabel returns the user-facing label for a provider id. | 316 | // providerLabel returns the user-facing label for a provider id. | ||
| 277 | func providerLabel(id string) string { return provider.ProviderLabel(id) } | 317 | func providerLabel(id string) string { return provider.ProviderLabel(id) } | ||
| 278 | 318 | ||||
| 319 | + | func (d *loginDialog) providerOptions() []string { | |||
| 320 | + | providers := providersForMethod(d.method) | |||
| 321 | + | needle := normalizeModelQuery(d.providerQuery) | |||
| 322 | + | if needle == "" { | |||
| 323 | + | return providers | |||
| 324 | + | } | |||
| 325 | + | filtered := make([]string, 0, len(providers)) | |||
| 326 | + | for _, id := range providers { | |||
| 327 | + | if strings.Contains(normalizeModelQuery(id+" "+providerLabel(id)), needle) { | |||
| 328 | + | filtered = append(filtered, id) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | return filtered | |||
| 332 | + | } | |||
| 333 | + | ||||
| 279 | func providerPickerTag(method, status string) string { | 334 | func providerPickerTag(method, status string) string { | ||
| 280 | switch method { | 335 | switch method { | ||
| 281 | case "apikey": | 336 | case "apikey": | ||
| @@ -377,7 +432,10 @@ type loginDialogAction struct { | |||||
| 377 | StartAPIKey bool | 432 | StartAPIKey bool | ||
| 378 | StartOAuth bool | 433 | StartOAuth bool | ||
| 379 | StartManual bool | 434 | StartManual bool | ||
| 435 | + | SaveLlama bool | |||
| 380 | Provider string | 436 | Provider string | ||
| 437 | + | LlamaURL string | |||
| 438 | + | LlamaAPIKey string | |||
| 381 | Close bool | 439 | Close bool | ||
| 382 | SubmitCode string | 440 | SubmitCode string | ||
| 383 | } | 441 | } | ||
| @@ -389,6 +447,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction { | |||||
| 389 | return d.handleMethodKey(k) | 447 | return d.handleMethodKey(k) | ||
| 390 | case loginStepProvider: | 448 | case loginStepProvider: | ||
| 391 | return d.handleProviderKey(k) | 449 | return d.handleProviderKey(k) | ||
| 450 | + | case loginStepLlamaURL, loginStepLlamaKey: | |||
| 451 | + | return d.handleLlamaKey(k) | |||
| 392 | case loginStepWaiting: | 452 | case loginStepWaiting: | ||
| 393 | return d.handleWaitingKey(k) | 453 | return d.handleWaitingKey(k) | ||
| 394 | case loginStepPasteCode: | 454 | case loginStepPasteCode: | ||
| @@ -425,6 +485,7 @@ func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction { | |||||
| 425 | } | 485 | } | ||
| 426 | d.step = loginStepProvider | 486 | d.step = loginStepProvider | ||
| 427 | d.cursor = 0 | 487 | d.cursor = 0 | ||
| 488 | + | d.providerQuery = "" | |||
| 428 | } | 489 | } | ||
| 429 | return loginDialogAction{} | 490 | return loginDialogAction{} | ||
| 430 | } | 491 | } | ||
| @@ -436,7 +497,7 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||||
| 436 | d.cursor-- | 497 | d.cursor-- | ||
| 437 | } | 498 | } | ||
| 438 | case tui.KeyDown: | 499 | case tui.KeyDown: | ||
| 439 | - | providers := providersForMethod(d.method) | 500 | + | providers := d.providerOptions() |
| 440 | if d.cursor < len(providers)-1 { | 501 | if d.cursor < len(providers)-1 { | ||
| 441 | d.cursor++ | 502 | d.cursor++ | ||
| 442 | } | 503 | } | ||
| @@ -446,20 +507,39 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||||
| 446 | d.cursor = 0 | 507 | d.cursor = 0 | ||
| 447 | } | 508 | } | ||
| 448 | case tui.KeyPageDown: | 509 | case tui.KeyPageDown: | ||
| 449 | - | providers := providersForMethod(d.method) | 510 | + | providers := d.providerOptions() |
| 450 | - | d.cursor += loginProviderPageSize | 511 | + | if len(providers) > 0 { |
| 451 | - | if d.cursor >= len(providers) { | 512 | + | d.cursor += loginProviderPageSize |
| 452 | - | d.cursor = len(providers) - 1 | 513 | + | if d.cursor >= len(providers) { |
| 514 | + | d.cursor = len(providers) - 1 | |||
| 515 | + | } | |||
| 516 | + | } | |||
| 517 | + | case tui.KeyBackspace: | |||
| 518 | + | if len(d.providerQuery) > 0 { | |||
| 519 | + | runes := []rune(d.providerQuery) | |||
| 520 | + | d.providerQuery = string(runes[:len(runes)-1]) | |||
| 521 | + | d.cursor = 0 | |||
| 522 | + | } | |||
| 523 | + | case tui.KeyRune: | |||
| 524 | + | if !k.Alt && !k.Ctrl && k.Rune >= 0x20 && k.Rune < 0x7f { | |||
| 525 | + | d.providerQuery += string(k.Rune) | |||
| 526 | + | d.cursor = 0 | |||
| 453 | } | 527 | } | ||
| 454 | case tui.KeyEsc: | 528 | case tui.KeyEsc: | ||
| 455 | d.Close() | 529 | d.Close() | ||
| 456 | return loginDialogAction{Close: true} | 530 | return loginDialogAction{Close: true} | ||
| 457 | case tui.KeyEnter: | 531 | case tui.KeyEnter: | ||
| 458 | - | providers := providersForMethod(d.method) | 532 | + | providers := d.providerOptions() |
| 459 | if d.cursor < 0 || d.cursor >= len(providers) { | 533 | if d.cursor < 0 || d.cursor >= len(providers) { | ||
| 460 | return loginDialogAction{} | 534 | return loginDialogAction{} | ||
| 461 | } | 535 | } | ||
| 462 | d.provider = providers[d.cursor] | 536 | d.provider = providers[d.cursor] | ||
| 537 | + | if d.method == "apikey" && d.provider == provider.LlamaCPPProviderID { | |||
| 538 | + | d.step = loginStepLlamaURL | |||
| 539 | + | d.llamaEd = nil | |||
| 540 | + | d.message = "" | |||
| 541 | + | return loginDialogAction{} | |||
| 542 | + | } | |||
| 463 | d.step = loginStepWaiting | 543 | d.step = loginStepWaiting | ||
| 464 | if d.method == "apikey" { | 544 | if d.method == "apikey" { | ||
| 465 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | 545 | return loginDialogAction{StartAPIKey: true, Provider: d.provider} | ||
| @@ -469,6 +549,37 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||||
| 469 | return loginDialogAction{} | 549 | return loginDialogAction{} | ||
| 470 | } | 550 | } | ||
| 471 | 551 | ||||
| 552 | + | func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | |||
| 553 | + | if k.Kind == tui.KeyEsc { | |||
| 554 | + | d.Close() | |||
| 555 | + | return loginDialogAction{Close: true} | |||
| 556 | + | } | |||
| 557 | + | if d.llamaEd == nil { | |||
| 558 | + | d.llamaEd = tui.NewEditor("") | |||
| 559 | + | } | |||
| 560 | + | if !d.llamaEd.HandleKey(k) { | |||
| 561 | + | return loginDialogAction{} | |||
| 562 | + | } | |||
| 563 | + | value := strings.TrimSpace(d.llamaEd.SubmitValue()) | |||
| 564 | + | d.llamaEd.Clear() | |||
| 565 | + | if d.step == loginStepLlamaURL { | |||
| 566 | + | normalized, err := provider.NormalizeLlamaCPPURL(value) | |||
| 567 | + | if err != nil { | |||
| 568 | + | d.message = err.Error() | |||
| 569 | + | return loginDialogAction{} | |||
| 570 | + | } | |||
| 571 | + | d.llamaURL = normalized | |||
| 572 | + | d.message = "" | |||
| 573 | + | d.llamaEd.Mask = true | |||
| 574 | + | d.step = loginStepLlamaKey | |||
| 575 | + | return loginDialogAction{} | |||
| 576 | + | } | |||
| 577 | + | return loginDialogAction{ | |||
| 578 | + | SaveLlama: true, Provider: provider.LlamaCPPProviderID, | |||
| 579 | + | LlamaURL: d.llamaURL, LlamaAPIKey: value, | |||
| 580 | + | } | |||
| 581 | + | } | |||
| 582 | + | ||||
| 472 | // ShowWaiting transitions to the waiting state with the given URL. | 583 | // ShowWaiting transitions to the waiting state with the given URL. | ||
| 473 | // No-op if the user has already dismissed the dialog. | 584 | // No-op if the user has already dismissed the dialog. | ||
| 474 | func (d *loginDialog) ShowWaiting(url string) { | 585 | func (d *loginDialog) ShowWaiting(url string) { | ||
| @@ -534,10 +645,21 @@ func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction { | |||||
| 534 | } | 645 | } | ||
| 535 | 646 | ||||
| 536 | // CursorPos returns the absolute row/col inside the dialog where the | 647 | // CursorPos returns the absolute row/col inside the dialog where the | ||
| 537 | - | // terminal cursor should sit (paste-code step). Returns -1, -1 if the | 648 | + | // terminal cursor should sit for code, URL, and API-key input. Returns -1, |
| 538 | - | // dialog is not in an input-expecting state. The host uses this to | 649 | + | // -1 if the dialog is not in an input-expecting state. The host uses this to |
| 539 | - | // place the real blinking cursor on the code input. | 650 | + | // place the real blinking cursor on the active input. |
| 540 | func (d *loginDialog) CursorPos(width int) (row, col int) { | 651 | func (d *loginDialog) CursorPos(width int) (row, col int) { | ||
| 652 | + | if d.step == loginStepLlamaURL || d.step == loginStepLlamaKey { | |||
| 653 | + | if d.llamaEd == nil { | |||
| 654 | + | return -1, -1 | |||
| 655 | + | } | |||
| 656 | + | _, eRow, eCol := d.llamaEd.Render(width - 2) | |||
| 657 | + | baseOffset := 1 /*frameHeader*/ + 1 /*padDialogFrame blank*/ + 1 /*prompt*/ | |||
| 658 | + | if d.step == loginStepLlamaKey { | |||
| 659 | + | baseOffset += 2 // router line + blank | |||
| 660 | + | } | |||
| 661 | + | return baseOffset + eRow, eCol | |||
| 662 | + | } | |||
| 541 | if d.codeEd == nil { | 663 | if d.codeEd == nil { | ||
| 542 | return -1, -1 | 664 | return -1, -1 | ||
| 543 | } | 665 | } | ||
M packages/agent/modes/login_dialog_test.go +62 -0
| @@ -7,6 +7,68 @@ import ( | |||
| 7 | 7 | "github.com/patriceckhart/zot/packages/tui" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestLoginDialogProviderPickerFiltersLikeModelPicker(t *testing.T) { | |
| 11 | + | d := newLoginDialog() | |
| 12 | + | d.step = loginStepProvider | |
| 13 | + | d.method = "apikey" | |
| 14 | + | d.status = map[string]string{} | |
| 15 | + | for _, r := range "llamacpp" { | |
| 16 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |
| 17 | + | } | |
| 18 | + | options := d.providerOptions() | |
| 19 | + | if len(options) != 1 || options[0] != "llama.cpp" { | |
| 20 | + | t.Fatalf("options = %v", options) | |
| 21 | + | } | |
| 22 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 23 | + | if d.step != loginStepLlamaURL { | |
| 24 | + | t.Fatalf("step = %v", d.step) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestLoginDialogProviderPickerShowsNoMatches(t *testing.T) { | |
| 29 | + | d := newLoginDialog() | |
| 30 | + | d.step = loginStepProvider | |
| 31 | + | d.method = "apikey" | |
| 32 | + | d.status = map[string]string{} | |
| 33 | + | d.providerQuery = "not-a-provider" | |
| 34 | + | if options := d.providerOptions(); len(options) != 0 { | |
| 35 | + | t.Fatalf("options = %v", options) | |
| 36 | + | } | |
| 37 | + | d.HandleKey(tui.Key{Kind: tui.KeyPageDown}) | |
| 38 | + | if d.cursor != 0 { | |
| 39 | + | t.Fatalf("cursor = %d", d.cursor) | |
| 40 | + | } | |
| 41 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action != (loginDialogAction{}) { | |
| 42 | + | t.Fatalf("action = %+v", action) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestLoginDialogLlamaCPPValidatesURLAndAcceptsOptionalKey(t *testing.T) { | |
| 47 | + | d := newLoginDialog() | |
| 48 | + | d.step = loginStepLlamaURL | |
| 49 | + | d.provider = "llama.cpp" | |
| 50 | + | d.method = "apikey" | |
| 51 | + | d.llamaEd = tui.NewEditor("") | |
| 52 | + | ||
| 53 | + | d.llamaEd.SetValue("file:///tmp/router") | |
| 54 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SaveLlama || d.step != loginStepLlamaURL { | |
| 55 | + | t.Fatalf("invalid URL advanced dialog: action=%+v step=%v", action, d.step) | |
| 56 | + | } | |
| 57 | + | if !strings.Contains(d.message, "http or https") { | |
| 58 | + | t.Fatalf("validation message = %q", d.message) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | d.llamaEd.SetValue("http://127.0.0.1:8080/v1/") | |
| 62 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 63 | + | if d.step != loginStepLlamaKey || d.llamaURL != "http://127.0.0.1:8080" { | |
| 64 | + | t.Fatalf("step=%v URL=%q", d.step, d.llamaURL) | |
| 65 | + | } | |
| 66 | + | action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 67 | + | if !action.SaveLlama || action.LlamaURL != "http://127.0.0.1:8080" || action.LlamaAPIKey != "" { | |
| 68 | + | t.Fatalf("action = %+v", action) | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 10 | 72 | func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) { | |
| 11 | 73 | d := newLoginDialog() | |
| 12 | 74 | d.Open(t.TempDir()) | |
| @@ -7,6 +7,68 @@ import ( | |||||
| 7 | "github.com/patriceckhart/zot/packages/tui" | 7 | "github.com/patriceckhart/zot/packages/tui" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | func TestLoginDialogProviderPickerFiltersLikeModelPicker(t *testing.T) { | |||
| 11 | + | d := newLoginDialog() | |||
| 12 | + | d.step = loginStepProvider | |||
| 13 | + | d.method = "apikey" | |||
| 14 | + | d.status = map[string]string{} | |||
| 15 | + | for _, r := range "llamacpp" { | |||
| 16 | + | d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) | |||
| 17 | + | } | |||
| 18 | + | options := d.providerOptions() | |||
| 19 | + | if len(options) != 1 || options[0] != "llama.cpp" { | |||
| 20 | + | t.Fatalf("options = %v", options) | |||
| 21 | + | } | |||
| 22 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 23 | + | if d.step != loginStepLlamaURL { | |||
| 24 | + | t.Fatalf("step = %v", d.step) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestLoginDialogProviderPickerShowsNoMatches(t *testing.T) { | |||
| 29 | + | d := newLoginDialog() | |||
| 30 | + | d.step = loginStepProvider | |||
| 31 | + | d.method = "apikey" | |||
| 32 | + | d.status = map[string]string{} | |||
| 33 | + | d.providerQuery = "not-a-provider" | |||
| 34 | + | if options := d.providerOptions(); len(options) != 0 { | |||
| 35 | + | t.Fatalf("options = %v", options) | |||
| 36 | + | } | |||
| 37 | + | d.HandleKey(tui.Key{Kind: tui.KeyPageDown}) | |||
| 38 | + | if d.cursor != 0 { | |||
| 39 | + | t.Fatalf("cursor = %d", d.cursor) | |||
| 40 | + | } | |||
| 41 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action != (loginDialogAction{}) { | |||
| 42 | + | t.Fatalf("action = %+v", action) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestLoginDialogLlamaCPPValidatesURLAndAcceptsOptionalKey(t *testing.T) { | |||
| 47 | + | d := newLoginDialog() | |||
| 48 | + | d.step = loginStepLlamaURL | |||
| 49 | + | d.provider = "llama.cpp" | |||
| 50 | + | d.method = "apikey" | |||
| 51 | + | d.llamaEd = tui.NewEditor("") | |||
| 52 | + | ||||
| 53 | + | d.llamaEd.SetValue("file:///tmp/router") | |||
| 54 | + | if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SaveLlama || d.step != loginStepLlamaURL { | |||
| 55 | + | t.Fatalf("invalid URL advanced dialog: action=%+v step=%v", action, d.step) | |||
| 56 | + | } | |||
| 57 | + | if !strings.Contains(d.message, "http or https") { | |||
| 58 | + | t.Fatalf("validation message = %q", d.message) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | d.llamaEd.SetValue("http://127.0.0.1:8080/v1/") | |||
| 62 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 63 | + | if d.step != loginStepLlamaKey || d.llamaURL != "http://127.0.0.1:8080" { | |||
| 64 | + | t.Fatalf("step=%v URL=%q", d.step, d.llamaURL) | |||
| 65 | + | } | |||
| 66 | + | action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 67 | + | if !action.SaveLlama || action.LlamaURL != "http://127.0.0.1:8080" || action.LlamaAPIKey != "" { | |||
| 68 | + | t.Fatalf("action = %+v", action) | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 10 | func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) { | 72 | func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) { | ||
| 11 | d := newLoginDialog() | 73 | d := newLoginDialog() | ||
| 12 | d.Open(t.TempDir()) | 74 | d.Open(t.TempDir()) | ||
M packages/agent/modes/logout_dialog.go +1 -1
| @@ -90,7 +90,7 @@ func (d *logoutDialog) Render(th tui.Theme, width int) []string { | |||
| 90 | 90 | for i, it := range d.items { | |
| 91 | 91 | plain := " " + it.label | |
| 92 | 92 | if it.method != "" { | |
| 93 | - | plain += " " + th.FG256(th.Muted, "("+it.method+")") | |
| 93 | + | plain += " (" + it.method + ")" | |
| 94 | 94 | } | |
| 95 | 95 | if i == d.cursor { | |
| 96 | 96 | lines = append(lines, th.PadHighlight(plain, width)) | |
| @@ -90,7 +90,7 @@ func (d *logoutDialog) Render(th tui.Theme, width int) []string { | |||||
| 90 | for i, it := range d.items { | 90 | for i, it := range d.items { | ||
| 91 | plain := " " + it.label | 91 | plain := " " + it.label | ||
| 92 | if it.method != "" { | 92 | if it.method != "" { | ||
| 93 | - | plain += " " + th.FG256(th.Muted, "("+it.method+")") | 93 | + | plain += " (" + it.method + ")" |
| 94 | } | 94 | } | ||
| 95 | if i == d.cursor { | 95 | if i == d.cursor { | ||
| 96 | lines = append(lines, th.PadHighlight(plain, width)) | 96 | lines = append(lines, th.PadHighlight(plain, width)) | ||
A packages/agent/modes/logout_dialog_test.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/mattn/go-runewidth" | |
| 8 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestLogoutDialogSelectionBackgroundSpansFullWidth(t *testing.T) { | |
| 12 | + | d := newLogoutDialog() | |
| 13 | + | d.Open([]logoutItem{{label: "Google", target: "google", method: "api key"}}) | |
| 14 | + | const width = 60 | |
| 15 | + | lines := d.Render(tui.Theme{SelectionFG: 15, SelectionBG: 4, Muted: 8}, width) | |
| 16 | + | selected := lines[2] | |
| 17 | + | if got := runewidth.StringWidth(stripANSIBytes(selected)); got != width { | |
| 18 | + | t.Fatalf("selected row width = %d, want %d", got, width) | |
| 19 | + | } | |
| 20 | + | if got := strings.Count(selected, "\x1b[0m"); got != 1 { | |
| 21 | + | t.Fatalf("selected row contains %d resets, want one final reset: %q", got, selected) | |
| 22 | + | } | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/mattn/go-runewidth" | |||
| 8 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestLogoutDialogSelectionBackgroundSpansFullWidth(t *testing.T) { | |||
| 12 | + | d := newLogoutDialog() | |||
| 13 | + | d.Open([]logoutItem{{label: "Google", target: "google", method: "api key"}}) | |||
| 14 | + | const width = 60 | |||
| 15 | + | lines := d.Render(tui.Theme{SelectionFG: 15, SelectionBG: 4, Muted: 8}, width) | |||
| 16 | + | selected := lines[2] | |||
| 17 | + | if got := runewidth.StringWidth(stripANSIBytes(selected)); got != width { | |||
| 18 | + | t.Fatalf("selected row width = %d, want %d", got, width) | |||
| 19 | + | } | |||
| 20 | + | if got := strings.Count(selected, "\x1b[0m"); got != 1 { | |||
| 21 | + | t.Fatalf("selected row contains %d resets, want one final reset: %q", got, selected) | |||
| 22 | + | } | |||
| 23 | + | } | |||
M packages/agent/modes/slash_suggest.go +10 -5
| @@ -25,7 +25,7 @@ type slashCommand struct { | |||
| 25 | 25 | // the streaming response without trouble. | |
| 26 | 26 | func slashCancelsTurn(head string) bool { | |
| 27 | 27 | switch head { | |
| 28 | - | case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext", "/cd": | |
| 28 | + | case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd": | |
| 29 | 29 | return true | |
| 30 | 30 | } | |
| 31 | 31 | return false | |
| @@ -38,6 +38,7 @@ var slashCatalog = []slashCommand{ | |||
| 38 | 38 | {Name: "/login", Desc: "log in via api key or subscription"}, | |
| 39 | 39 | {Name: "/logout", Desc: "clear a provider's credentials"}, | |
| 40 | 40 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | |
| 41 | + | {Name: "/llama", Desc: "manage llama.cpp router models"}, | |
| 41 | 42 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | |
| 42 | 43 | {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"}, | |
| 43 | 44 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | |
| @@ -62,9 +63,9 @@ const slashSuggestPageSize = 8 | |||
| 62 | 63 | type slashSuggester struct { | |
| 63 | 64 | cursor int | |
| 64 | 65 | ||
| 65 | - | // jailed tracks whether the sandbox is currently locked. It is used | |
| 66 | - | // to hide state-dependent commands from the autocomplete popup. | |
| 67 | - | jailed bool | |
| 66 | + | // jailed and llamaConfigured drive state-dependent command visibility. | |
| 67 | + | jailed bool | |
| 68 | + | llamaConfigured bool | |
| 68 | 69 | ||
| 69 | 70 | // extra are commands contributed by extensions, refreshed each | |
| 70 | 71 | // frame from the extension manager. Empty when no extensions | |
| @@ -93,6 +94,10 @@ func (s *slashSuggester) SetExtra(cmds []slashCommand) { | |||
| 93 | 94 | // so state-dependent commands can appear/disappear immediately. | |
| 94 | 95 | func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed } | |
| 95 | 96 | ||
| 97 | + | // SetLlamaConfigured shows the management command only when a router login is | |
| 98 | + | // available. | |
| 99 | + | func (s *slashSuggester) SetLlamaConfigured(configured bool) { s.llamaConfigured = configured } | |
| 100 | + | ||
| 96 | 101 | // allCatalog returns slashCatalog plus the current extra commands | |
| 97 | 102 | // (extension-registered) with a header divider between the two | |
| 98 | 103 | // groups. Extra entries are only kept if they don't collide with | |
| @@ -133,7 +138,7 @@ func (s *slashSuggester) baseCatalog() []slashCommand { | |||
| 133 | 138 | } | |
| 134 | 139 | out := make([]slashCommand, 0, len(slashCatalog)-1) | |
| 135 | 140 | for _, c := range slashCatalog { | |
| 136 | - | if c.Name == hide { | |
| 141 | + | if c.Name == hide || (c.Name == "/llama" && !s.llamaConfigured) { | |
| 137 | 142 | continue | |
| 138 | 143 | } | |
| 139 | 144 | out = append(out, c) | |
| @@ -25,7 +25,7 @@ type slashCommand struct { | |||||
| 25 | // the streaming response without trouble. | 25 | // the streaming response without trouble. | ||
| 26 | func slashCancelsTurn(head string) bool { | 26 | func slashCancelsTurn(head string) bool { | ||
| 27 | switch head { | 27 | switch head { | ||
| 28 | - | case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext", "/cd": | 28 | + | case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd": |
| 29 | return true | 29 | return true | ||
| 30 | } | 30 | } | ||
| 31 | return false | 31 | return false | ||
| @@ -38,6 +38,7 @@ var slashCatalog = []slashCommand{ | |||||
| 38 | {Name: "/login", Desc: "log in via api key or subscription"}, | 38 | {Name: "/login", Desc: "log in via api key or subscription"}, | ||
| 39 | {Name: "/logout", Desc: "clear a provider's credentials"}, | 39 | {Name: "/logout", Desc: "clear a provider's credentials"}, | ||
| 40 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | 40 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | ||
| 41 | + | {Name: "/llama", Desc: "manage llama.cpp router models"}, | |||
| 41 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | 42 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | ||
| 42 | {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"}, | 43 | {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"}, | ||
| 43 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | 44 | {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"}, | ||
| @@ -62,9 +63,9 @@ const slashSuggestPageSize = 8 | |||||
| 62 | type slashSuggester struct { | 63 | type slashSuggester struct { | ||
| 63 | cursor int | 64 | cursor int | ||
| 64 | 65 | ||||
| 65 | - | // jailed tracks whether the sandbox is currently locked. It is used | 66 | + | // jailed and llamaConfigured drive state-dependent command visibility. |
| 66 | - | // to hide state-dependent commands from the autocomplete popup. | 67 | + | jailed bool |
| 67 | - | jailed bool | 68 | + | llamaConfigured bool |
| 68 | 69 | ||||
| 69 | // extra are commands contributed by extensions, refreshed each | 70 | // extra are commands contributed by extensions, refreshed each | ||
| 70 | // frame from the extension manager. Empty when no extensions | 71 | // frame from the extension manager. Empty when no extensions | ||
| @@ -93,6 +94,10 @@ func (s *slashSuggester) SetExtra(cmds []slashCommand) { | |||||
| 93 | // so state-dependent commands can appear/disappear immediately. | 94 | // so state-dependent commands can appear/disappear immediately. | ||
| 94 | func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed } | 95 | func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed } | ||
| 95 | 96 | ||||
| 97 | + | // SetLlamaConfigured shows the management command only when a router login is | |||
| 98 | + | // available. | |||
| 99 | + | func (s *slashSuggester) SetLlamaConfigured(configured bool) { s.llamaConfigured = configured } | |||
| 100 | + | ||||
| 96 | // allCatalog returns slashCatalog plus the current extra commands | 101 | // allCatalog returns slashCatalog plus the current extra commands | ||
| 97 | // (extension-registered) with a header divider between the two | 102 | // (extension-registered) with a header divider between the two | ||
| 98 | // groups. Extra entries are only kept if they don't collide with | 103 | // groups. Extra entries are only kept if they don't collide with | ||
| @@ -133,7 +138,7 @@ func (s *slashSuggester) baseCatalog() []slashCommand { | |||||
| 133 | } | 138 | } | ||
| 134 | out := make([]slashCommand, 0, len(slashCatalog)-1) | 139 | out := make([]slashCommand, 0, len(slashCatalog)-1) | ||
| 135 | for _, c := range slashCatalog { | 140 | for _, c := range slashCatalog { | ||
| 136 | - | if c.Name == hide { | 141 | + | if c.Name == hide || (c.Name == "/llama" && !s.llamaConfigured) { |
| 137 | continue | 142 | continue | ||
| 138 | } | 143 | } | ||
| 139 | out = append(out, c) | 144 | out = append(out, c) | ||
M packages/agent/modes/slash_suggest_test.go +11 -0
| @@ -21,6 +21,17 @@ func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) { | |||
| 21 | 21 | } | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | + | func TestSlashSuggesterShowsLlamaOnlyWhenConfigured(t *testing.T) { | |
| 25 | + | s := newSlashSuggester() | |
| 26 | + | if got := commandNames(s.matches("/llama")); contains(got, "/llama") { | |
| 27 | + | t.Fatalf("/llama visible without login: %v", got) | |
| 28 | + | } | |
| 29 | + | s.SetLlamaConfigured(true) | |
| 30 | + | if got := commandNames(s.matches("/llama")); !contains(got, "/llama") { | |
| 31 | + | t.Fatalf("/llama missing with login: %v", got) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 24 | 35 | func TestSlashSuggesterHasSwarm(t *testing.T) { | |
| 25 | 36 | s := newSlashSuggester() | |
| 26 | 37 | if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") { | |
| @@ -21,6 +21,17 @@ func TestSlashSuggesterHidesUnjailUntilJailed(t *testing.T) { | |||||
| 21 | } | 21 | } | ||
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | + | func TestSlashSuggesterShowsLlamaOnlyWhenConfigured(t *testing.T) { | |||
| 25 | + | s := newSlashSuggester() | |||
| 26 | + | if got := commandNames(s.matches("/llama")); contains(got, "/llama") { | |||
| 27 | + | t.Fatalf("/llama visible without login: %v", got) | |||
| 28 | + | } | |||
| 29 | + | s.SetLlamaConfigured(true) | |||
| 30 | + | if got := commandNames(s.matches("/llama")); !contains(got, "/llama") { | |||
| 31 | + | t.Fatalf("/llama missing with login: %v", got) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 24 | func TestSlashSuggesterHasSwarm(t *testing.T) { | 35 | func TestSlashSuggesterHasSwarm(t *testing.T) { | ||
| 25 | s := newSlashSuggester() | 36 | s := newSlashSuggester() | ||
| 26 | if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") { | 37 | if got := commandNames(s.matches("/sw")); !contains(got, "/swarm") { | ||
M packages/provider/auth/server.go +1 -1
| @@ -136,7 +136,7 @@ func apiKeyProviderMessage() string { | |||
| 136 | 136 | // APIKeyProviders is the ordered list shown by /login -> api key. | |
| 137 | 137 | func APIKeyProviders() []string { | |
| 138 | 138 | return []string{ | |
| 139 | - | "anthropic", "openai", "kimi", "deepseek", "google", | |
| 139 | + | "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp", | |
| 140 | 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | |
| 141 | 141 | "huggingface", "openrouter", "mistral", "zai", | |
| 142 | 142 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | |
| @@ -136,7 +136,7 @@ func apiKeyProviderMessage() string { | |||||
| 136 | // APIKeyProviders is the ordered list shown by /login -> api key. | 136 | // APIKeyProviders is the ordered list shown by /login -> api key. | ||
| 137 | func APIKeyProviders() []string { | 137 | func APIKeyProviders() []string { | ||
| 138 | return []string{ | 138 | return []string{ | ||
| 139 | - | "anthropic", "openai", "kimi", "deepseek", "google", | 139 | + | "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp", |
| 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | ||
| 141 | "huggingface", "openrouter", "mistral", "zai", | 141 | "huggingface", "openrouter", "mistral", "zai", | ||
| 142 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | 142 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | ||
M packages/provider/auth/store.go +32 -5
| @@ -29,8 +29,9 @@ type Credentials struct { | |||
| 29 | 29 | // use either APIKey or OAuth; OpenAI may store both so the public API | |
| 30 | 30 | // route and ChatGPT/Codex subscription route can coexist. | |
| 31 | 31 | type ProviderCreds struct { | |
| 32 | - | APIKey string `json:"api_key,omitempty"` | |
| 33 | - | OAuth *OAuthToken `json:"oauth,omitempty"` | |
| 32 | + | APIKey string `json:"api_key,omitempty"` | |
| 33 | + | BaseURL string `json:"base_url,omitempty"` | |
| 34 | + | OAuth *OAuthToken `json:"oauth,omitempty"` | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | // OAuthToken is an OAuth 2 token set with refresh support. | |
| @@ -62,7 +63,7 @@ func (t *OAuthToken) Expired() bool { | |||
| 62 | 63 | // Has reports whether at least one credential is present for provider. | |
| 63 | 64 | func (c *Credentials) Has(provider string) bool { | |
| 64 | 65 | p := c.get(provider) | |
| 65 | - | return p != nil && (p.APIKey != "" || p.OAuth != nil) | |
| 66 | + | return p != nil && (p.APIKey != "" || p.BaseURL != "" || p.OAuth != nil) | |
| 66 | 67 | } | |
| 67 | 68 | ||
| 68 | 69 | // Method returns "apikey", "oauth", or "" for the given provider. | |
| @@ -71,7 +72,7 @@ func (c *Credentials) Method(provider string) string { | |||
| 71 | 72 | if p == nil { | |
| 72 | 73 | return "" | |
| 73 | 74 | } | |
| 74 | - | if p.APIKey != "" { | |
| 75 | + | if p.APIKey != "" || p.BaseURL != "" { | |
| 75 | 76 | return "apikey" | |
| 76 | 77 | } | |
| 77 | 78 | if p.OAuth != nil { | |
| @@ -107,7 +108,7 @@ func (c *Credentials) setAdditional(provider string, p ProviderCreds) { | |||
| 107 | 108 | if c.AdditionalAPIKeyCreds == nil { | |
| 108 | 109 | c.AdditionalAPIKeyCreds = map[string]ProviderCreds{} | |
| 109 | 110 | } | |
| 110 | - | if p.APIKey == "" && p.OAuth == nil { | |
| 111 | + | if p.APIKey == "" && p.BaseURL == "" && p.OAuth == nil { | |
| 111 | 112 | delete(c.AdditionalAPIKeyCreds, provider) | |
| 112 | 113 | if len(c.AdditionalAPIKeyCreds) == 0 { | |
| 113 | 114 | c.AdditionalAPIKeyCreds = nil | |
| @@ -175,6 +176,32 @@ func (s *Store) SetAPIKey(provider, key string) error { | |||
| 175 | 176 | return s.saveLocked(c) | |
| 176 | 177 | } | |
| 177 | 178 | ||
| 179 | + | // SetEndpointCredential stores a base URL and optional API key for a | |
| 180 | + | // self-hosted provider. | |
| 181 | + | func (s *Store) SetEndpointCredential(provider, baseURL, key string) error { | |
| 182 | + | s.mu.Lock() | |
| 183 | + | defer s.mu.Unlock() | |
| 184 | + | c, err := s.loadLocked() | |
| 185 | + | if err != nil { | |
| 186 | + | return err | |
| 187 | + | } | |
| 188 | + | c.setAdditional(provider, ProviderCreds{APIKey: key, BaseURL: baseURL}) | |
| 189 | + | return s.saveLocked(c) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // EndpointCredential returns a stored base URL and optional API key. | |
| 193 | + | func (s *Store) EndpointCredential(provider string) (baseURL, key string, err error) { | |
| 194 | + | c, err := s.Load() | |
| 195 | + | if err != nil { | |
| 196 | + | return "", "", err | |
| 197 | + | } | |
| 198 | + | p := c.get(provider) | |
| 199 | + | if p == nil { | |
| 200 | + | return "", "", nil | |
| 201 | + | } | |
| 202 | + | return p.BaseURL, p.APIKey, nil | |
| 203 | + | } | |
| 204 | + | ||
| 178 | 205 | // SetOAuth replaces the OAuth token for provider and saves to disk. | |
| 179 | 206 | func (s *Store) SetOAuth(provider string, tok OAuthToken) error { | |
| 180 | 207 | s.mu.Lock() | |
| @@ -29,8 +29,9 @@ type Credentials struct { | |||||
| 29 | // use either APIKey or OAuth; OpenAI may store both so the public API | 29 | // use either APIKey or OAuth; OpenAI may store both so the public API | ||
| 30 | // route and ChatGPT/Codex subscription route can coexist. | 30 | // route and ChatGPT/Codex subscription route can coexist. | ||
| 31 | type ProviderCreds struct { | 31 | type ProviderCreds struct { | ||
| 32 | - | APIKey string `json:"api_key,omitempty"` | 32 | + | APIKey string `json:"api_key,omitempty"` |
| 33 | - | OAuth *OAuthToken `json:"oauth,omitempty"` | 33 | + | BaseURL string `json:"base_url,omitempty"` |
| 34 | + | OAuth *OAuthToken `json:"oauth,omitempty"` | |||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | // OAuthToken is an OAuth 2 token set with refresh support. | 37 | // OAuthToken is an OAuth 2 token set with refresh support. | ||
| @@ -62,7 +63,7 @@ func (t *OAuthToken) Expired() bool { | |||||
| 62 | // Has reports whether at least one credential is present for provider. | 63 | // Has reports whether at least one credential is present for provider. | ||
| 63 | func (c *Credentials) Has(provider string) bool { | 64 | func (c *Credentials) Has(provider string) bool { | ||
| 64 | p := c.get(provider) | 65 | p := c.get(provider) | ||
| 65 | - | return p != nil && (p.APIKey != "" || p.OAuth != nil) | 66 | + | return p != nil && (p.APIKey != "" || p.BaseURL != "" || p.OAuth != nil) |
| 66 | } | 67 | } | ||
| 67 | 68 | ||||
| 68 | // Method returns "apikey", "oauth", or "" for the given provider. | 69 | // Method returns "apikey", "oauth", or "" for the given provider. | ||
| @@ -71,7 +72,7 @@ func (c *Credentials) Method(provider string) string { | |||||
| 71 | if p == nil { | 72 | if p == nil { | ||
| 72 | return "" | 73 | return "" | ||
| 73 | } | 74 | } | ||
| 74 | - | if p.APIKey != "" { | 75 | + | if p.APIKey != "" || p.BaseURL != "" { |
| 75 | return "apikey" | 76 | return "apikey" | ||
| 76 | } | 77 | } | ||
| 77 | if p.OAuth != nil { | 78 | if p.OAuth != nil { | ||
| @@ -107,7 +108,7 @@ func (c *Credentials) setAdditional(provider string, p ProviderCreds) { | |||||
| 107 | if c.AdditionalAPIKeyCreds == nil { | 108 | if c.AdditionalAPIKeyCreds == nil { | ||
| 108 | c.AdditionalAPIKeyCreds = map[string]ProviderCreds{} | 109 | c.AdditionalAPIKeyCreds = map[string]ProviderCreds{} | ||
| 109 | } | 110 | } | ||
| 110 | - | if p.APIKey == "" && p.OAuth == nil { | 111 | + | if p.APIKey == "" && p.BaseURL == "" && p.OAuth == nil { |
| 111 | delete(c.AdditionalAPIKeyCreds, provider) | 112 | delete(c.AdditionalAPIKeyCreds, provider) | ||
| 112 | if len(c.AdditionalAPIKeyCreds) == 0 { | 113 | if len(c.AdditionalAPIKeyCreds) == 0 { | ||
| 113 | c.AdditionalAPIKeyCreds = nil | 114 | c.AdditionalAPIKeyCreds = nil | ||
| @@ -175,6 +176,32 @@ func (s *Store) SetAPIKey(provider, key string) error { | |||||
| 175 | return s.saveLocked(c) | 176 | return s.saveLocked(c) | ||
| 176 | } | 177 | } | ||
| 177 | 178 | ||||
| 179 | + | // SetEndpointCredential stores a base URL and optional API key for a | |||
| 180 | + | // self-hosted provider. | |||
| 181 | + | func (s *Store) SetEndpointCredential(provider, baseURL, key string) error { | |||
| 182 | + | s.mu.Lock() | |||
| 183 | + | defer s.mu.Unlock() | |||
| 184 | + | c, err := s.loadLocked() | |||
| 185 | + | if err != nil { | |||
| 186 | + | return err | |||
| 187 | + | } | |||
| 188 | + | c.setAdditional(provider, ProviderCreds{APIKey: key, BaseURL: baseURL}) | |||
| 189 | + | return s.saveLocked(c) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // EndpointCredential returns a stored base URL and optional API key. | |||
| 193 | + | func (s *Store) EndpointCredential(provider string) (baseURL, key string, err error) { | |||
| 194 | + | c, err := s.Load() | |||
| 195 | + | if err != nil { | |||
| 196 | + | return "", "", err | |||
| 197 | + | } | |||
| 198 | + | p := c.get(provider) | |||
| 199 | + | if p == nil { | |||
| 200 | + | return "", "", nil | |||
| 201 | + | } | |||
| 202 | + | return p.BaseURL, p.APIKey, nil | |||
| 203 | + | } | |||
| 204 | + | ||||
| 178 | // SetOAuth replaces the OAuth token for provider and saves to disk. | 205 | // SetOAuth replaces the OAuth token for provider and saves to disk. | ||
| 179 | func (s *Store) SetOAuth(provider string, tok OAuthToken) error { | 206 | func (s *Store) SetOAuth(provider string, tok OAuthToken) error { | ||
| 180 | s.mu.Lock() | 207 | s.mu.Lock() | ||
M packages/provider/auth/store_additional_test.go +21 -0
| @@ -5,6 +5,27 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | + | func TestStoreEndpointCredentialAllowsOptionalAPIKey(t *testing.T) { | |
| 9 | + | store := NewStore(filepath.Join(t.TempDir(), "auth.json")) | |
| 10 | + | if err := store.SetEndpointCredential("llama.cpp", "http://127.0.0.1:8080", ""); err != nil { | |
| 11 | + | t.Fatal(err) | |
| 12 | + | } | |
| 13 | + | baseURL, key, err := store.EndpointCredential("llama.cpp") | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | if baseURL != "http://127.0.0.1:8080" || key != "" { | |
| 18 | + | t.Fatalf("endpoint = (%q, %q)", baseURL, key) | |
| 19 | + | } | |
| 20 | + | creds, err := store.Load() | |
| 21 | + | if err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | if !creds.Has("llama.cpp") || creds.Method("llama.cpp") != "apikey" { | |
| 25 | + | t.Fatalf("stored credentials not recognized: %+v", creds.AdditionalAPIKeyCreds) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 8 | 29 | func TestStoreAdditionalAPIKeyClear(t *testing.T) { | |
| 9 | 30 | store := NewStore(filepath.Join(t.TempDir(), "auth.json")) | |
| 10 | 31 | if err := store.SetAPIKey("groq", "gsk_test"); err != nil { | |
| @@ -5,6 +5,27 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | + | func TestStoreEndpointCredentialAllowsOptionalAPIKey(t *testing.T) { | |||
| 9 | + | store := NewStore(filepath.Join(t.TempDir(), "auth.json")) | |||
| 10 | + | if err := store.SetEndpointCredential("llama.cpp", "http://127.0.0.1:8080", ""); err != nil { | |||
| 11 | + | t.Fatal(err) | |||
| 12 | + | } | |||
| 13 | + | baseURL, key, err := store.EndpointCredential("llama.cpp") | |||
| 14 | + | if err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | if baseURL != "http://127.0.0.1:8080" || key != "" { | |||
| 18 | + | t.Fatalf("endpoint = (%q, %q)", baseURL, key) | |||
| 19 | + | } | |||
| 20 | + | creds, err := store.Load() | |||
| 21 | + | if err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | if !creds.Has("llama.cpp") || creds.Method("llama.cpp") != "apikey" { | |||
| 25 | + | t.Fatalf("stored credentials not recognized: %+v", creds.AdditionalAPIKeyCreds) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | ||||
| 8 | func TestStoreAdditionalAPIKeyClear(t *testing.T) { | 29 | func TestStoreAdditionalAPIKeyClear(t *testing.T) { | ||
| 9 | store := NewStore(filepath.Join(t.TempDir(), "auth.json")) | 30 | store := NewStore(filepath.Join(t.TempDir(), "auth.json")) | ||
| 10 | if err := store.SetAPIKey("groq", "gsk_test"); err != nil { | 31 | if err := store.SetAPIKey("groq", "gsk_test"); err != nil { | ||
M packages/provider/labels.go +2 -0
| @@ -73,6 +73,8 @@ func ProviderLabel(id string) string { | |||
| 73 | 73 | return "Cloudflare AI Gateway" | |
| 74 | 74 | case "ollama": | |
| 75 | 75 | return "Ollama" | |
| 76 | + | case LlamaCPPProviderID: | |
| 77 | + | return "llama.cpp" | |
| 76 | 78 | } | |
| 77 | 79 | return titleProviderID(id) | |
| 78 | 80 | } | |
| @@ -73,6 +73,8 @@ func ProviderLabel(id string) string { | |||||
| 73 | return "Cloudflare AI Gateway" | 73 | return "Cloudflare AI Gateway" | ||
| 74 | case "ollama": | 74 | case "ollama": | ||
| 75 | return "Ollama" | 75 | return "Ollama" | ||
| 76 | + | case LlamaCPPProviderID: | |||
| 77 | + | return "llama.cpp" | |||
| 76 | } | 78 | } | ||
| 77 | return titleProviderID(id) | 79 | return titleProviderID(id) | ||
| 78 | } | 80 | } | ||
A packages/provider/llamacpp.go +631 -0
| @@ -0,0 +1,631 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "net/http" | |
| 11 | + | "net/url" | |
| 12 | + | "os" | |
| 13 | + | "path/filepath" | |
| 14 | + | "regexp" | |
| 15 | + | "sort" | |
| 16 | + | "strconv" | |
| 17 | + | "strings" | |
| 18 | + | "time" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | LlamaCPPProviderID = "llama.cpp" | |
| 23 | + | defaultHuggingFaceURL = "https://huggingface.co" | |
| 24 | + | llamaCPPRequestTimeout = 15 * time.Second | |
| 25 | + | llamaCPPProgressPollTime = 250 * time.Millisecond | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // LlamaCPPModel is one entry returned by a llama.cpp router's /models endpoint. | |
| 29 | + | type LlamaCPPModel struct { | |
| 30 | + | ID string `json:"id"` | |
| 31 | + | Aliases []string `json:"aliases,omitempty"` | |
| 32 | + | Source string `json:"source,omitempty"` | |
| 33 | + | CanRemove bool `json:"can_remove,omitempty"` | |
| 34 | + | Status struct { | |
| 35 | + | Value string `json:"value"` | |
| 36 | + | Args []string `json:"args,omitempty"` | |
| 37 | + | Failed bool `json:"failed,omitempty"` | |
| 38 | + | ExitCode *int `json:"exit_code,omitempty"` | |
| 39 | + | Progress map[string]LlamaCPPBytes `json:"progress,omitempty"` | |
| 40 | + | } `json:"status"` | |
| 41 | + | Architecture struct { | |
| 42 | + | InputModalities []string `json:"input_modalities,omitempty"` | |
| 43 | + | } `json:"architecture,omitempty"` | |
| 44 | + | Meta struct { | |
| 45 | + | Context int `json:"n_ctx,omitempty"` | |
| 46 | + | TrainContext int `json:"n_ctx_train,omitempty"` | |
| 47 | + | Size int64 `json:"size,omitempty"` | |
| 48 | + | FileType string `json:"ftype,omitempty"` | |
| 49 | + | } `json:"meta,omitempty"` | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // LlamaCPPBytes describes byte progress for one downloaded file. | |
| 53 | + | type LlamaCPPBytes struct { | |
| 54 | + | Done int64 `json:"done"` | |
| 55 | + | Total int64 `json:"total"` | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // LlamaCPPProgress is a user-facing load or download progress update. | |
| 59 | + | type LlamaCPPProgress struct { | |
| 60 | + | Message string | |
| 61 | + | Ratio float64 | |
| 62 | + | HasRatio bool | |
| 63 | + | Detail string | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // LlamaCPPClient talks to the model-management API exposed by llama-server | |
| 67 | + | // when it is running in router mode. | |
| 68 | + | type LlamaCPPClient struct { | |
| 69 | + | ServerURL string | |
| 70 | + | APIKey string | |
| 71 | + | HTTP *http.Client | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // NormalizeLlamaCPPURL validates a router URL and removes a trailing /v1, | |
| 75 | + | // which belongs to the inference API rather than the management API. | |
| 76 | + | func NormalizeLlamaCPPURL(value string) (string, error) { | |
| 77 | + | u, err := url.Parse(strings.TrimSpace(value)) | |
| 78 | + | if err != nil { | |
| 79 | + | return "", fmt.Errorf("parse llama.cpp server URL: %w", err) | |
| 80 | + | } | |
| 81 | + | if u.Scheme != "http" && u.Scheme != "https" { | |
| 82 | + | return "", errors.New("llama.cpp server URL must use http or https") | |
| 83 | + | } | |
| 84 | + | if u.Host == "" { | |
| 85 | + | return "", errors.New("llama.cpp server URL is missing a host") | |
| 86 | + | } | |
| 87 | + | u.RawQuery = "" | |
| 88 | + | u.Fragment = "" | |
| 89 | + | u.Path = strings.TrimSuffix(strings.TrimRight(u.Path, "/"), "/v1") | |
| 90 | + | return strings.TrimRight(u.String(), "/"), nil | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // LlamaCPPInferenceURL returns the OpenAI-compatible inference base URL. | |
| 94 | + | func LlamaCPPInferenceURL(serverURL string) (string, error) { | |
| 95 | + | normalized, err := NormalizeLlamaCPPURL(serverURL) | |
| 96 | + | if err != nil { | |
| 97 | + | return "", err | |
| 98 | + | } | |
| 99 | + | return normalized + "/v1", nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func NewLlamaCPPClient(serverURL, apiKey string) (*LlamaCPPClient, error) { | |
| 103 | + | normalized, err := NormalizeLlamaCPPURL(serverURL) | |
| 104 | + | if err != nil { | |
| 105 | + | return nil, err | |
| 106 | + | } | |
| 107 | + | return &LlamaCPPClient{ServerURL: normalized, APIKey: apiKey, HTTP: &http.Client{Timeout: llamaCPPRequestTimeout}}, nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (c *LlamaCPPClient) request(ctx context.Context, method, path string, body io.Reader, out any) error { | |
| 111 | + | req, err := http.NewRequestWithContext(ctx, method, c.ServerURL+path, body) | |
| 112 | + | if err != nil { | |
| 113 | + | return err | |
| 114 | + | } | |
| 115 | + | if body != nil { | |
| 116 | + | req.Header.Set("content-type", "application/json") | |
| 117 | + | } | |
| 118 | + | if c.APIKey != "" { | |
| 119 | + | req.Header.Set("authorization", "Bearer "+c.APIKey) | |
| 120 | + | } | |
| 121 | + | resp, err := c.HTTP.Do(req) | |
| 122 | + | if err != nil { | |
| 123 | + | return err | |
| 124 | + | } | |
| 125 | + | defer resp.Body.Close() | |
| 126 | + | data, err := io.ReadAll(resp.Body) | |
| 127 | + | if err != nil { | |
| 128 | + | return err | |
| 129 | + | } | |
| 130 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |
| 131 | + | var payload struct { | |
| 132 | + | Error struct { | |
| 133 | + | Message string `json:"message"` | |
| 134 | + | } `json:"error"` | |
| 135 | + | } | |
| 136 | + | _ = json.Unmarshal(data, &payload) | |
| 137 | + | if payload.Error.Message != "" { | |
| 138 | + | return errors.New(payload.Error.Message) | |
| 139 | + | } | |
| 140 | + | return fmt.Errorf("llama.cpp returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) | |
| 141 | + | } | |
| 142 | + | if out != nil && len(data) > 0 { | |
| 143 | + | if err := json.Unmarshal(data, out); err != nil { | |
| 144 | + | return fmt.Errorf("parse llama.cpp response: %w", err) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | return nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (c *LlamaCPPClient) List(ctx context.Context, reload bool) ([]LlamaCPPModel, error) { | |
| 151 | + | path := "/models" | |
| 152 | + | if reload { | |
| 153 | + | path += "?reload=1" | |
| 154 | + | } | |
| 155 | + | var response struct { | |
| 156 | + | Data []LlamaCPPModel `json:"data"` | |
| 157 | + | } | |
| 158 | + | if err := c.request(ctx, http.MethodGet, path, nil, &response); err != nil { | |
| 159 | + | return nil, err | |
| 160 | + | } | |
| 161 | + | for _, model := range response.Data { | |
| 162 | + | if model.ID == "" || model.Status.Value == "" { | |
| 163 | + | return nil, errors.New("server is not running in llama.cpp router mode") | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | return response.Data, nil | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (c *LlamaCPPClient) modelAction(ctx context.Context, path, model string) error { | |
| 170 | + | body := strings.NewReader(`{"model":` + strconv.Quote(model) + `}`) | |
| 171 | + | return c.request(ctx, http.MethodPost, path, body, nil) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func (c *LlamaCPPClient) Load(ctx context.Context, model string) error { | |
| 175 | + | return c.modelAction(ctx, "/models/load", model) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (c *LlamaCPPClient) Unload(ctx context.Context, model string) error { | |
| 179 | + | return c.modelAction(ctx, "/models/unload", model) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (c *LlamaCPPClient) Download(ctx context.Context, model string) error { | |
| 183 | + | return c.modelAction(ctx, "/models", model) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Remove deletes a router-managed cache model from disk. Models discovered | |
| 187 | + | // from --models-dir or presets are not removable through the router API. | |
| 188 | + | func (c *LlamaCPPClient) Remove(ctx context.Context, model string) error { | |
| 189 | + | path := "/models?" + url.Values{"model": {model}}.Encode() | |
| 190 | + | return c.request(ctx, http.MethodDelete, path, nil, nil) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | type llamaCPPEvent struct { | |
| 194 | + | Model string `json:"model"` | |
| 195 | + | Event string `json:"event"` | |
| 196 | + | Data json.RawMessage `json:"data"` | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (c *LlamaCPPClient) watch(ctx context.Context, events chan<- llamaCPPEvent) error { | |
| 200 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ServerURL+"/models/sse", nil) | |
| 201 | + | if err != nil { | |
| 202 | + | return err | |
| 203 | + | } | |
| 204 | + | if c.APIKey != "" { | |
| 205 | + | req.Header.Set("authorization", "Bearer "+c.APIKey) | |
| 206 | + | } | |
| 207 | + | resp, err := c.HTTP.Do(req) | |
| 208 | + | if err != nil { | |
| 209 | + | return err | |
| 210 | + | } | |
| 211 | + | defer resp.Body.Close() | |
| 212 | + | if resp.StatusCode != http.StatusOK { | |
| 213 | + | return fmt.Errorf("llama.cpp SSE returned HTTP %d", resp.StatusCode) | |
| 214 | + | } | |
| 215 | + | scanner := bufio.NewScanner(resp.Body) | |
| 216 | + | for scanner.Scan() { | |
| 217 | + | line := scanner.Text() | |
| 218 | + | if !strings.HasPrefix(line, "data:") { | |
| 219 | + | continue | |
| 220 | + | } | |
| 221 | + | var event llamaCPPEvent | |
| 222 | + | if json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))), &event) == nil { | |
| 223 | + | select { | |
| 224 | + | case events <- event: | |
| 225 | + | case <-ctx.Done(): | |
| 226 | + | return ctx.Err() | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | return scanner.Err() | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func loadProgress(data json.RawMessage) (LlamaCPPProgress, bool) { | |
| 234 | + | var payload struct { | |
| 235 | + | Progress struct { | |
| 236 | + | Stages []string `json:"stages"` | |
| 237 | + | Current string `json:"current"` | |
| 238 | + | Stage string `json:"stage"` | |
| 239 | + | Value *float64 `json:"value"` | |
| 240 | + | } `json:"progress"` | |
| 241 | + | } | |
| 242 | + | if json.Unmarshal(data, &payload) != nil { | |
| 243 | + | return LlamaCPPProgress{}, false | |
| 244 | + | } | |
| 245 | + | stage := payload.Progress.Current | |
| 246 | + | if stage == "" { | |
| 247 | + | stage = payload.Progress.Stage | |
| 248 | + | } | |
| 249 | + | if stage == "" && payload.Progress.Value == nil { | |
| 250 | + | return LlamaCPPProgress{}, false | |
| 251 | + | } | |
| 252 | + | progress := LlamaCPPProgress{Message: "Loading model"} | |
| 253 | + | if stage != "" { | |
| 254 | + | progress.Message = "Loading " + strings.ReplaceAll(stage, "_", " ") | |
| 255 | + | } | |
| 256 | + | if payload.Progress.Value != nil { | |
| 257 | + | ratio := max(0, min(1, *payload.Progress.Value)) | |
| 258 | + | if stage != "" && len(payload.Progress.Stages) > 0 { | |
| 259 | + | for index, candidate := range payload.Progress.Stages { | |
| 260 | + | if candidate == stage { | |
| 261 | + | ratio = (float64(index) + ratio) / float64(len(payload.Progress.Stages)) | |
| 262 | + | break | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | progress.Ratio, progress.HasRatio = ratio, true | |
| 267 | + | } | |
| 268 | + | return progress, true | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func downloadProgress(files map[string]LlamaCPPBytes) (LlamaCPPProgress, bool) { | |
| 272 | + | var done, total int64 | |
| 273 | + | for _, file := range files { | |
| 274 | + | done += file.Done | |
| 275 | + | total += file.Total | |
| 276 | + | } | |
| 277 | + | if total <= 0 { | |
| 278 | + | return LlamaCPPProgress{}, false | |
| 279 | + | } | |
| 280 | + | return LlamaCPPProgress{ | |
| 281 | + | Message: "Downloading model", | |
| 282 | + | Ratio: float64(done) / float64(total), HasRatio: true, | |
| 283 | + | Detail: FormatBytes(done) + " / " + FormatBytes(total), | |
| 284 | + | }, true | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func (c *LlamaCPPClient) LoadAndWait(ctx context.Context, model string, update func(LlamaCPPProgress)) (LlamaCPPModel, error) { | |
| 288 | + | watchCtx, cancel := context.WithCancel(ctx) | |
| 289 | + | defer cancel() | |
| 290 | + | events := make(chan llamaCPPEvent, 8) | |
| 291 | + | go func() { _ = c.watch(watchCtx, events) }() | |
| 292 | + | if err := c.Load(ctx, model); err != nil { | |
| 293 | + | return LlamaCPPModel{}, err | |
| 294 | + | } | |
| 295 | + | update(LlamaCPPProgress{Message: "Loading model"}) | |
| 296 | + | ticker := time.NewTicker(llamaCPPProgressPollTime) | |
| 297 | + | defer ticker.Stop() | |
| 298 | + | for { | |
| 299 | + | select { | |
| 300 | + | case <-ctx.Done(): | |
| 301 | + | return LlamaCPPModel{}, ctx.Err() | |
| 302 | + | case event := <-events: | |
| 303 | + | if event.Model == model { | |
| 304 | + | if progress, ok := loadProgress(event.Data); ok { | |
| 305 | + | update(progress) | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | case <-ticker.C: | |
| 309 | + | models, err := c.List(ctx, false) | |
| 310 | + | if err != nil { | |
| 311 | + | return LlamaCPPModel{}, err | |
| 312 | + | } | |
| 313 | + | for _, entry := range models { | |
| 314 | + | if entry.ID != model { | |
| 315 | + | continue | |
| 316 | + | } | |
| 317 | + | if entry.Status.Value == "loaded" || entry.Status.Value == "sleeping" { | |
| 318 | + | return entry, nil | |
| 319 | + | } | |
| 320 | + | if entry.Status.Failed { | |
| 321 | + | if entry.Status.ExitCode != nil { | |
| 322 | + | return LlamaCPPModel{}, fmt.Errorf("model exited with code %d", *entry.Status.ExitCode) | |
| 323 | + | } | |
| 324 | + | return LlamaCPPModel{}, errors.New("model failed to load") | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func (c *LlamaCPPClient) UnloadAndWait(ctx context.Context, model string) error { | |
| 332 | + | if err := c.Unload(ctx, model); err != nil { | |
| 333 | + | return err | |
| 334 | + | } | |
| 335 | + | ticker := time.NewTicker(100 * time.Millisecond) | |
| 336 | + | defer ticker.Stop() | |
| 337 | + | for { | |
| 338 | + | models, err := c.List(ctx, false) | |
| 339 | + | if err != nil { | |
| 340 | + | return err | |
| 341 | + | } | |
| 342 | + | found := false | |
| 343 | + | for _, entry := range models { | |
| 344 | + | if entry.ID == model { | |
| 345 | + | found = true | |
| 346 | + | if entry.Status.Value == "unloaded" { | |
| 347 | + | return nil | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | if !found { | |
| 352 | + | return nil | |
| 353 | + | } | |
| 354 | + | select { | |
| 355 | + | case <-ctx.Done(): | |
| 356 | + | return ctx.Err() | |
| 357 | + | case <-ticker.C: | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func (c *LlamaCPPClient) DownloadAndWait(ctx context.Context, model string, update func(LlamaCPPProgress)) ([]LlamaCPPModel, error) { | |
| 363 | + | watchCtx, cancel := context.WithCancel(ctx) | |
| 364 | + | defer cancel() | |
| 365 | + | events := make(chan llamaCPPEvent, 8) | |
| 366 | + | go func() { _ = c.watch(watchCtx, events) }() | |
| 367 | + | if err := c.Download(ctx, model); err != nil { | |
| 368 | + | return nil, err | |
| 369 | + | } | |
| 370 | + | update(LlamaCPPProgress{Message: "Downloading model"}) | |
| 371 | + | ticker := time.NewTicker(500 * time.Millisecond) | |
| 372 | + | defer ticker.Stop() | |
| 373 | + | sawDownload := false | |
| 374 | + | polls := 0 | |
| 375 | + | for { | |
| 376 | + | select { | |
| 377 | + | case <-ctx.Done(): | |
| 378 | + | return nil, ctx.Err() | |
| 379 | + | case event := <-events: | |
| 380 | + | if event.Model != model { | |
| 381 | + | continue | |
| 382 | + | } | |
| 383 | + | switch event.Event { | |
| 384 | + | case "download_progress": | |
| 385 | + | sawDownload = true | |
| 386 | + | var payload struct { | |
| 387 | + | Progress map[string]LlamaCPPBytes `json:"progress"` | |
| 388 | + | } | |
| 389 | + | if json.Unmarshal(event.Data, &payload) == nil { | |
| 390 | + | if progress, ok := downloadProgress(payload.Progress); ok { | |
| 391 | + | update(progress) | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | case "download_failed": | |
| 395 | + | return nil, errors.New("model download failed") | |
| 396 | + | case "download_finished": | |
| 397 | + | return c.List(ctx, true) | |
| 398 | + | } | |
| 399 | + | case <-ticker.C: | |
| 400 | + | models, err := c.List(ctx, false) | |
| 401 | + | if err != nil { | |
| 402 | + | return nil, err | |
| 403 | + | } | |
| 404 | + | polls++ | |
| 405 | + | for _, entry := range models { | |
| 406 | + | if entry.ID != model { | |
| 407 | + | continue | |
| 408 | + | } | |
| 409 | + | if entry.Status.Value == "downloading" { | |
| 410 | + | sawDownload = true | |
| 411 | + | if progress, ok := downloadProgress(entry.Status.Progress); ok { | |
| 412 | + | update(progress) | |
| 413 | + | } | |
| 414 | + | } else if sawDownload || polls >= 2 { | |
| 415 | + | return c.List(ctx, true) | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | // LlamaCPPModels converts loaded router entries into zot model metadata. | |
| 423 | + | func LlamaCPPModels(models []LlamaCPPModel, serverURL string) []Model { | |
| 424 | + | baseURL, err := LlamaCPPInferenceURL(serverURL) | |
| 425 | + | if err != nil { | |
| 426 | + | return nil | |
| 427 | + | } | |
| 428 | + | var out []Model | |
| 429 | + | for _, entry := range models { | |
| 430 | + | if entry.Status.Value != "loaded" && entry.Status.Value != "sleeping" { | |
| 431 | + | continue | |
| 432 | + | } | |
| 433 | + | contextWindow := entry.Meta.Context | |
| 434 | + | if contextWindow <= 0 { | |
| 435 | + | contextWindow = entry.Meta.TrainContext | |
| 436 | + | } | |
| 437 | + | if contextWindow <= 0 { | |
| 438 | + | contextWindow = 128000 | |
| 439 | + | } | |
| 440 | + | maxOutput := min(16384, contextWindow) | |
| 441 | + | out = append(out, Model{ | |
| 442 | + | Provider: LlamaCPPProviderID, ID: entry.ID, DisplayName: entry.ID, | |
| 443 | + | ContextWindow: contextWindow, MaxOutput: maxOutput, | |
| 444 | + | BaseURL: baseURL, Source: "live", | |
| 445 | + | }) | |
| 446 | + | } | |
| 447 | + | return out | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // FormatBytes renders byte counts using binary units. | |
| 451 | + | func FormatBytes(bytes int64) string { | |
| 452 | + | if bytes < 1024 { | |
| 453 | + | return fmt.Sprintf("%d B", bytes) | |
| 454 | + | } | |
| 455 | + | units := []string{"KiB", "MiB", "GiB", "TiB"} | |
| 456 | + | value := float64(bytes) / 1024 | |
| 457 | + | unit := units[0] | |
| 458 | + | for index := 1; index < len(units) && value >= 1024; index++ { | |
| 459 | + | value /= 1024 | |
| 460 | + | unit = units[index] | |
| 461 | + | } | |
| 462 | + | if value >= 10 { | |
| 463 | + | return fmt.Sprintf("%.1f %s", value, unit) | |
| 464 | + | } | |
| 465 | + | return fmt.Sprintf("%.2f %s", value, unit) | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | // HuggingFaceModel is one GGUF repository search result. | |
| 469 | + | type HuggingFaceModel struct { | |
| 470 | + | ID string `json:"id"` | |
| 471 | + | Downloads int64 `json:"downloads"` | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | type HuggingFaceQuantization struct { | |
| 475 | + | Name string | |
| 476 | + | Size int64 | |
| 477 | + | HasSize bool | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | type HuggingFaceModelDetails struct { | |
| 481 | + | ID string | |
| 482 | + | Gated string | |
| 483 | + | Quantizations []HuggingFaceQuantization | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | type HuggingFaceClient struct { | |
| 487 | + | Token string | |
| 488 | + | BaseURL string | |
| 489 | + | HTTP *http.Client | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func NewHuggingFaceClient(token string) *HuggingFaceClient { | |
| 493 | + | return &HuggingFaceClient{Token: token, BaseURL: defaultHuggingFaceURL, HTTP: &http.Client{Timeout: llamaCPPRequestTimeout}} | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | func (c *HuggingFaceClient) request(ctx context.Context, path string, out any) error { | |
| 497 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.BaseURL, "/")+path, nil) | |
| 498 | + | if err != nil { | |
| 499 | + | return err | |
| 500 | + | } | |
| 501 | + | if c.Token != "" { | |
| 502 | + | req.Header.Set("authorization", "Bearer "+c.Token) | |
| 503 | + | } | |
| 504 | + | resp, err := c.HTTP.Do(req) | |
| 505 | + | if err != nil { | |
| 506 | + | return err | |
| 507 | + | } | |
| 508 | + | defer resp.Body.Close() | |
| 509 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |
| 510 | + | if resp.StatusCode == http.StatusTooManyRequests { | |
| 511 | + | return errors.New("Hugging Face rate limit reached") | |
| 512 | + | } | |
| 513 | + | return fmt.Errorf("Hugging Face returned HTTP %d", resp.StatusCode) | |
| 514 | + | } | |
| 515 | + | if err := json.NewDecoder(resp.Body).Decode(out); err != nil { | |
| 516 | + | return fmt.Errorf("parse Hugging Face response: %w", err) | |
| 517 | + | } | |
| 518 | + | return nil | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | func (c *HuggingFaceClient) Search(ctx context.Context, query string) ([]HuggingFaceModel, error) { | |
| 522 | + | params := url.Values{"search": {query}, "filter": {"gguf"}, "sort": {"downloads"}, "direction": {"-1"}, "limit": {"20"}} | |
| 523 | + | var models []HuggingFaceModel | |
| 524 | + | if err := c.request(ctx, "/api/models?"+params.Encode(), &models); err != nil { | |
| 525 | + | return nil, err | |
| 526 | + | } | |
| 527 | + | return models, nil | |
| 528 | + | } | |
| 529 | + | ||
| 530 | + | var quantizationPattern = regexp.MustCompile(`(?i)(?:^|[-_.])((?:UD-)?(?:IQ[0-9](?:_[A-Z0-9]+)+|Q[0-9](?:_[A-Z0-9]+)+|BF16|F16|F32|MXFP[0-9](?:_[A-Z0-9]+)*))$`) | |
| 531 | + | var shardSuffixPattern = regexp.MustCompile(`-[0-9]{5}-of-[0-9]{5}$`) | |
| 532 | + | ||
| 533 | + | func (c *HuggingFaceClient) Details(ctx context.Context, id string) (HuggingFaceModelDetails, error) { | |
| 534 | + | parts := strings.Split(id, "/") | |
| 535 | + | for i := range parts { | |
| 536 | + | parts[i] = url.PathEscape(parts[i]) | |
| 537 | + | } | |
| 538 | + | encoded := strings.Join(parts, "/") | |
| 539 | + | var payload struct { | |
| 540 | + | ID string `json:"id"` | |
| 541 | + | Gated json.RawMessage `json:"gated"` | |
| 542 | + | Siblings []struct { | |
| 543 | + | Filename string `json:"rfilename"` | |
| 544 | + | Size *int64 `json:"size"` | |
| 545 | + | } `json:"siblings"` | |
| 546 | + | } | |
| 547 | + | if err := c.request(ctx, "/api/models/"+encoded+"?blobs=true", &payload); err != nil { | |
| 548 | + | return HuggingFaceModelDetails{}, err | |
| 549 | + | } | |
| 550 | + | type quantSize struct { | |
| 551 | + | total int64 | |
| 552 | + | complete bool | |
| 553 | + | } | |
| 554 | + | sizes := map[string]quantSize{} | |
| 555 | + | for _, file := range payload.Siblings { | |
| 556 | + | name := filepath.Base(file.Filename) | |
| 557 | + | if !strings.HasSuffix(strings.ToLower(name), ".gguf") || strings.HasPrefix(strings.ToLower(name), "mmproj") { | |
| 558 | + | continue | |
| 559 | + | } | |
| 560 | + | stem := shardSuffixPattern.ReplaceAllString(name[:len(name)-5], "") | |
| 561 | + | match := quantizationPattern.FindStringSubmatch(stem) | |
| 562 | + | if len(match) < 2 { | |
| 563 | + | continue | |
| 564 | + | } | |
| 565 | + | quant := strings.ToUpper(match[1]) | |
| 566 | + | current, ok := sizes[quant] | |
| 567 | + | if !ok { | |
| 568 | + | current.complete = true | |
| 569 | + | } | |
| 570 | + | if file.Size == nil { | |
| 571 | + | current.complete = false | |
| 572 | + | } else { | |
| 573 | + | current.total += *file.Size | |
| 574 | + | } | |
| 575 | + | sizes[quant] = current | |
| 576 | + | } | |
| 577 | + | var quantizations []HuggingFaceQuantization | |
| 578 | + | for name, size := range sizes { | |
| 579 | + | quantizations = append(quantizations, HuggingFaceQuantization{Name: name, Size: size.total, HasSize: size.complete}) | |
| 580 | + | } | |
| 581 | + | sort.Slice(quantizations, func(i, j int) bool { | |
| 582 | + | if quantizations[i].Name == "Q4_K_M" { | |
| 583 | + | return true | |
| 584 | + | } | |
| 585 | + | if quantizations[j].Name == "Q4_K_M" { | |
| 586 | + | return false | |
| 587 | + | } | |
| 588 | + | if quantizations[i].HasSize != quantizations[j].HasSize { | |
| 589 | + | return quantizations[i].HasSize | |
| 590 | + | } | |
| 591 | + | if quantizations[i].Size != quantizations[j].Size { | |
| 592 | + | return quantizations[i].Size < quantizations[j].Size | |
| 593 | + | } | |
| 594 | + | return quantizations[i].Name < quantizations[j].Name | |
| 595 | + | }) | |
| 596 | + | gated := "" | |
| 597 | + | _ = json.Unmarshal(payload.Gated, &gated) | |
| 598 | + | if payload.ID == "" { | |
| 599 | + | payload.ID = id | |
| 600 | + | } | |
| 601 | + | return HuggingFaceModelDetails{ID: payload.ID, Gated: gated, Quantizations: quantizations}, nil | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | // FindHuggingFaceToken follows the standard Hugging Face token locations. | |
| 605 | + | func FindHuggingFaceToken() string { | |
| 606 | + | if token := strings.TrimSpace(os.Getenv("HF_TOKEN")); token != "" { | |
| 607 | + | return token | |
| 608 | + | } | |
| 609 | + | home, _ := os.UserHomeDir() | |
| 610 | + | paths := []string{os.Getenv("HF_TOKEN_PATH")} | |
| 611 | + | if value := os.Getenv("HF_HOME"); value != "" { | |
| 612 | + | paths = append(paths, filepath.Join(value, "token")) | |
| 613 | + | } | |
| 614 | + | if value := os.Getenv("XDG_CACHE_HOME"); value != "" { | |
| 615 | + | paths = append(paths, filepath.Join(value, "huggingface", "token")) | |
| 616 | + | } | |
| 617 | + | if home != "" { | |
| 618 | + | paths = append(paths, filepath.Join(home, ".cache", "huggingface", "token")) | |
| 619 | + | } | |
| 620 | + | for _, path := range paths { | |
| 621 | + | if path == "" { | |
| 622 | + | continue | |
| 623 | + | } | |
| 624 | + | if data, err := os.ReadFile(path); err == nil { | |
| 625 | + | if token := strings.TrimSpace(string(data)); token != "" { | |
| 626 | + | return token | |
| 627 | + | } | |
| 628 | + | } | |
| 629 | + | } | |
| 630 | + | return "" | |
| 631 | + | } | |
| @@ -0,0 +1,631 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "net/http" | |||
| 11 | + | "net/url" | |||
| 12 | + | "os" | |||
| 13 | + | "path/filepath" | |||
| 14 | + | "regexp" | |||
| 15 | + | "sort" | |||
| 16 | + | "strconv" | |||
| 17 | + | "strings" | |||
| 18 | + | "time" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | LlamaCPPProviderID = "llama.cpp" | |||
| 23 | + | defaultHuggingFaceURL = "https://huggingface.co" | |||
| 24 | + | llamaCPPRequestTimeout = 15 * time.Second | |||
| 25 | + | llamaCPPProgressPollTime = 250 * time.Millisecond | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // LlamaCPPModel is one entry returned by a llama.cpp router's /models endpoint. | |||
| 29 | + | type LlamaCPPModel struct { | |||
| 30 | + | ID string `json:"id"` | |||
| 31 | + | Aliases []string `json:"aliases,omitempty"` | |||
| 32 | + | Source string `json:"source,omitempty"` | |||
| 33 | + | CanRemove bool `json:"can_remove,omitempty"` | |||
| 34 | + | Status struct { | |||
| 35 | + | Value string `json:"value"` | |||
| 36 | + | Args []string `json:"args,omitempty"` | |||
| 37 | + | Failed bool `json:"failed,omitempty"` | |||
| 38 | + | ExitCode *int `json:"exit_code,omitempty"` | |||
| 39 | + | Progress map[string]LlamaCPPBytes `json:"progress,omitempty"` | |||
| 40 | + | } `json:"status"` | |||
| 41 | + | Architecture struct { | |||
| 42 | + | InputModalities []string `json:"input_modalities,omitempty"` | |||
| 43 | + | } `json:"architecture,omitempty"` | |||
| 44 | + | Meta struct { | |||
| 45 | + | Context int `json:"n_ctx,omitempty"` | |||
| 46 | + | TrainContext int `json:"n_ctx_train,omitempty"` | |||
| 47 | + | Size int64 `json:"size,omitempty"` | |||
| 48 | + | FileType string `json:"ftype,omitempty"` | |||
| 49 | + | } `json:"meta,omitempty"` | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // LlamaCPPBytes describes byte progress for one downloaded file. | |||
| 53 | + | type LlamaCPPBytes struct { | |||
| 54 | + | Done int64 `json:"done"` | |||
| 55 | + | Total int64 `json:"total"` | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // LlamaCPPProgress is a user-facing load or download progress update. | |||
| 59 | + | type LlamaCPPProgress struct { | |||
| 60 | + | Message string | |||
| 61 | + | Ratio float64 | |||
| 62 | + | HasRatio bool | |||
| 63 | + | Detail string | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // LlamaCPPClient talks to the model-management API exposed by llama-server | |||
| 67 | + | // when it is running in router mode. | |||
| 68 | + | type LlamaCPPClient struct { | |||
| 69 | + | ServerURL string | |||
| 70 | + | APIKey string | |||
| 71 | + | HTTP *http.Client | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // NormalizeLlamaCPPURL validates a router URL and removes a trailing /v1, | |||
| 75 | + | // which belongs to the inference API rather than the management API. | |||
| 76 | + | func NormalizeLlamaCPPURL(value string) (string, error) { | |||
| 77 | + | u, err := url.Parse(strings.TrimSpace(value)) | |||
| 78 | + | if err != nil { | |||
| 79 | + | return "", fmt.Errorf("parse llama.cpp server URL: %w", err) | |||
| 80 | + | } | |||
| 81 | + | if u.Scheme != "http" && u.Scheme != "https" { | |||
| 82 | + | return "", errors.New("llama.cpp server URL must use http or https") | |||
| 83 | + | } | |||
| 84 | + | if u.Host == "" { | |||
| 85 | + | return "", errors.New("llama.cpp server URL is missing a host") | |||
| 86 | + | } | |||
| 87 | + | u.RawQuery = "" | |||
| 88 | + | u.Fragment = "" | |||
| 89 | + | u.Path = strings.TrimSuffix(strings.TrimRight(u.Path, "/"), "/v1") | |||
| 90 | + | return strings.TrimRight(u.String(), "/"), nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // LlamaCPPInferenceURL returns the OpenAI-compatible inference base URL. | |||
| 94 | + | func LlamaCPPInferenceURL(serverURL string) (string, error) { | |||
| 95 | + | normalized, err := NormalizeLlamaCPPURL(serverURL) | |||
| 96 | + | if err != nil { | |||
| 97 | + | return "", err | |||
| 98 | + | } | |||
| 99 | + | return normalized + "/v1", nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func NewLlamaCPPClient(serverURL, apiKey string) (*LlamaCPPClient, error) { | |||
| 103 | + | normalized, err := NormalizeLlamaCPPURL(serverURL) | |||
| 104 | + | if err != nil { | |||
| 105 | + | return nil, err | |||
| 106 | + | } | |||
| 107 | + | return &LlamaCPPClient{ServerURL: normalized, APIKey: apiKey, HTTP: &http.Client{Timeout: llamaCPPRequestTimeout}}, nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (c *LlamaCPPClient) request(ctx context.Context, method, path string, body io.Reader, out any) error { | |||
| 111 | + | req, err := http.NewRequestWithContext(ctx, method, c.ServerURL+path, body) | |||
| 112 | + | if err != nil { | |||
| 113 | + | return err | |||
| 114 | + | } | |||
| 115 | + | if body != nil { | |||
| 116 | + | req.Header.Set("content-type", "application/json") | |||
| 117 | + | } | |||
| 118 | + | if c.APIKey != "" { | |||
| 119 | + | req.Header.Set("authorization", "Bearer "+c.APIKey) | |||
| 120 | + | } | |||
| 121 | + | resp, err := c.HTTP.Do(req) | |||
| 122 | + | if err != nil { | |||
| 123 | + | return err | |||
| 124 | + | } | |||
| 125 | + | defer resp.Body.Close() | |||
| 126 | + | data, err := io.ReadAll(resp.Body) | |||
| 127 | + | if err != nil { | |||
| 128 | + | return err | |||
| 129 | + | } | |||
| 130 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |||
| 131 | + | var payload struct { | |||
| 132 | + | Error struct { | |||
| 133 | + | Message string `json:"message"` | |||
| 134 | + | } `json:"error"` | |||
| 135 | + | } | |||
| 136 | + | _ = json.Unmarshal(data, &payload) | |||
| 137 | + | if payload.Error.Message != "" { | |||
| 138 | + | return errors.New(payload.Error.Message) | |||
| 139 | + | } | |||
| 140 | + | return fmt.Errorf("llama.cpp returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data))) | |||
| 141 | + | } | |||
| 142 | + | if out != nil && len(data) > 0 { | |||
| 143 | + | if err := json.Unmarshal(data, out); err != nil { | |||
| 144 | + | return fmt.Errorf("parse llama.cpp response: %w", err) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | return nil | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (c *LlamaCPPClient) List(ctx context.Context, reload bool) ([]LlamaCPPModel, error) { | |||
| 151 | + | path := "/models" | |||
| 152 | + | if reload { | |||
| 153 | + | path += "?reload=1" | |||
| 154 | + | } | |||
| 155 | + | var response struct { | |||
| 156 | + | Data []LlamaCPPModel `json:"data"` | |||
| 157 | + | } | |||
| 158 | + | if err := c.request(ctx, http.MethodGet, path, nil, &response); err != nil { | |||
| 159 | + | return nil, err | |||
| 160 | + | } | |||
| 161 | + | for _, model := range response.Data { | |||
| 162 | + | if model.ID == "" || model.Status.Value == "" { | |||
| 163 | + | return nil, errors.New("server is not running in llama.cpp router mode") | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | return response.Data, nil | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (c *LlamaCPPClient) modelAction(ctx context.Context, path, model string) error { | |||
| 170 | + | body := strings.NewReader(`{"model":` + strconv.Quote(model) + `}`) | |||
| 171 | + | return c.request(ctx, http.MethodPost, path, body, nil) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func (c *LlamaCPPClient) Load(ctx context.Context, model string) error { | |||
| 175 | + | return c.modelAction(ctx, "/models/load", model) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (c *LlamaCPPClient) Unload(ctx context.Context, model string) error { | |||
| 179 | + | return c.modelAction(ctx, "/models/unload", model) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func (c *LlamaCPPClient) Download(ctx context.Context, model string) error { | |||
| 183 | + | return c.modelAction(ctx, "/models", model) | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // Remove deletes a router-managed cache model from disk. Models discovered | |||
| 187 | + | // from --models-dir or presets are not removable through the router API. | |||
| 188 | + | func (c *LlamaCPPClient) Remove(ctx context.Context, model string) error { | |||
| 189 | + | path := "/models?" + url.Values{"model": {model}}.Encode() | |||
| 190 | + | return c.request(ctx, http.MethodDelete, path, nil, nil) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | type llamaCPPEvent struct { | |||
| 194 | + | Model string `json:"model"` | |||
| 195 | + | Event string `json:"event"` | |||
| 196 | + | Data json.RawMessage `json:"data"` | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func (c *LlamaCPPClient) watch(ctx context.Context, events chan<- llamaCPPEvent) error { | |||
| 200 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.ServerURL+"/models/sse", nil) | |||
| 201 | + | if err != nil { | |||
| 202 | + | return err | |||
| 203 | + | } | |||
| 204 | + | if c.APIKey != "" { | |||
| 205 | + | req.Header.Set("authorization", "Bearer "+c.APIKey) | |||
| 206 | + | } | |||
| 207 | + | resp, err := c.HTTP.Do(req) | |||
| 208 | + | if err != nil { | |||
| 209 | + | return err | |||
| 210 | + | } | |||
| 211 | + | defer resp.Body.Close() | |||
| 212 | + | if resp.StatusCode != http.StatusOK { | |||
| 213 | + | return fmt.Errorf("llama.cpp SSE returned HTTP %d", resp.StatusCode) | |||
| 214 | + | } | |||
| 215 | + | scanner := bufio.NewScanner(resp.Body) | |||
| 216 | + | for scanner.Scan() { | |||
| 217 | + | line := scanner.Text() | |||
| 218 | + | if !strings.HasPrefix(line, "data:") { | |||
| 219 | + | continue | |||
| 220 | + | } | |||
| 221 | + | var event llamaCPPEvent | |||
| 222 | + | if json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))), &event) == nil { | |||
| 223 | + | select { | |||
| 224 | + | case events <- event: | |||
| 225 | + | case <-ctx.Done(): | |||
| 226 | + | return ctx.Err() | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | return scanner.Err() | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func loadProgress(data json.RawMessage) (LlamaCPPProgress, bool) { | |||
| 234 | + | var payload struct { | |||
| 235 | + | Progress struct { | |||
| 236 | + | Stages []string `json:"stages"` | |||
| 237 | + | Current string `json:"current"` | |||
| 238 | + | Stage string `json:"stage"` | |||
| 239 | + | Value *float64 `json:"value"` | |||
| 240 | + | } `json:"progress"` | |||
| 241 | + | } | |||
| 242 | + | if json.Unmarshal(data, &payload) != nil { | |||
| 243 | + | return LlamaCPPProgress{}, false | |||
| 244 | + | } | |||
| 245 | + | stage := payload.Progress.Current | |||
| 246 | + | if stage == "" { | |||
| 247 | + | stage = payload.Progress.Stage | |||
| 248 | + | } | |||
| 249 | + | if stage == "" && payload.Progress.Value == nil { | |||
| 250 | + | return LlamaCPPProgress{}, false | |||
| 251 | + | } | |||
| 252 | + | progress := LlamaCPPProgress{Message: "Loading model"} | |||
| 253 | + | if stage != "" { | |||
| 254 | + | progress.Message = "Loading " + strings.ReplaceAll(stage, "_", " ") | |||
| 255 | + | } | |||
| 256 | + | if payload.Progress.Value != nil { | |||
| 257 | + | ratio := max(0, min(1, *payload.Progress.Value)) | |||
| 258 | + | if stage != "" && len(payload.Progress.Stages) > 0 { | |||
| 259 | + | for index, candidate := range payload.Progress.Stages { | |||
| 260 | + | if candidate == stage { | |||
| 261 | + | ratio = (float64(index) + ratio) / float64(len(payload.Progress.Stages)) | |||
| 262 | + | break | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | progress.Ratio, progress.HasRatio = ratio, true | |||
| 267 | + | } | |||
| 268 | + | return progress, true | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func downloadProgress(files map[string]LlamaCPPBytes) (LlamaCPPProgress, bool) { | |||
| 272 | + | var done, total int64 | |||
| 273 | + | for _, file := range files { | |||
| 274 | + | done += file.Done | |||
| 275 | + | total += file.Total | |||
| 276 | + | } | |||
| 277 | + | if total <= 0 { | |||
| 278 | + | return LlamaCPPProgress{}, false | |||
| 279 | + | } | |||
| 280 | + | return LlamaCPPProgress{ | |||
| 281 | + | Message: "Downloading model", | |||
| 282 | + | Ratio: float64(done) / float64(total), HasRatio: true, | |||
| 283 | + | Detail: FormatBytes(done) + " / " + FormatBytes(total), | |||
| 284 | + | }, true | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func (c *LlamaCPPClient) LoadAndWait(ctx context.Context, model string, update func(LlamaCPPProgress)) (LlamaCPPModel, error) { | |||
| 288 | + | watchCtx, cancel := context.WithCancel(ctx) | |||
| 289 | + | defer cancel() | |||
| 290 | + | events := make(chan llamaCPPEvent, 8) | |||
| 291 | + | go func() { _ = c.watch(watchCtx, events) }() | |||
| 292 | + | if err := c.Load(ctx, model); err != nil { | |||
| 293 | + | return LlamaCPPModel{}, err | |||
| 294 | + | } | |||
| 295 | + | update(LlamaCPPProgress{Message: "Loading model"}) | |||
| 296 | + | ticker := time.NewTicker(llamaCPPProgressPollTime) | |||
| 297 | + | defer ticker.Stop() | |||
| 298 | + | for { | |||
| 299 | + | select { | |||
| 300 | + | case <-ctx.Done(): | |||
| 301 | + | return LlamaCPPModel{}, ctx.Err() | |||
| 302 | + | case event := <-events: | |||
| 303 | + | if event.Model == model { | |||
| 304 | + | if progress, ok := loadProgress(event.Data); ok { | |||
| 305 | + | update(progress) | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | case <-ticker.C: | |||
| 309 | + | models, err := c.List(ctx, false) | |||
| 310 | + | if err != nil { | |||
| 311 | + | return LlamaCPPModel{}, err | |||
| 312 | + | } | |||
| 313 | + | for _, entry := range models { | |||
| 314 | + | if entry.ID != model { | |||
| 315 | + | continue | |||
| 316 | + | } | |||
| 317 | + | if entry.Status.Value == "loaded" || entry.Status.Value == "sleeping" { | |||
| 318 | + | return entry, nil | |||
| 319 | + | } | |||
| 320 | + | if entry.Status.Failed { | |||
| 321 | + | if entry.Status.ExitCode != nil { | |||
| 322 | + | return LlamaCPPModel{}, fmt.Errorf("model exited with code %d", *entry.Status.ExitCode) | |||
| 323 | + | } | |||
| 324 | + | return LlamaCPPModel{}, errors.New("model failed to load") | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func (c *LlamaCPPClient) UnloadAndWait(ctx context.Context, model string) error { | |||
| 332 | + | if err := c.Unload(ctx, model); err != nil { | |||
| 333 | + | return err | |||
| 334 | + | } | |||
| 335 | + | ticker := time.NewTicker(100 * time.Millisecond) | |||
| 336 | + | defer ticker.Stop() | |||
| 337 | + | for { | |||
| 338 | + | models, err := c.List(ctx, false) | |||
| 339 | + | if err != nil { | |||
| 340 | + | return err | |||
| 341 | + | } | |||
| 342 | + | found := false | |||
| 343 | + | for _, entry := range models { | |||
| 344 | + | if entry.ID == model { | |||
| 345 | + | found = true | |||
| 346 | + | if entry.Status.Value == "unloaded" { | |||
| 347 | + | return nil | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | if !found { | |||
| 352 | + | return nil | |||
| 353 | + | } | |||
| 354 | + | select { | |||
| 355 | + | case <-ctx.Done(): | |||
| 356 | + | return ctx.Err() | |||
| 357 | + | case <-ticker.C: | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | func (c *LlamaCPPClient) DownloadAndWait(ctx context.Context, model string, update func(LlamaCPPProgress)) ([]LlamaCPPModel, error) { | |||
| 363 | + | watchCtx, cancel := context.WithCancel(ctx) | |||
| 364 | + | defer cancel() | |||
| 365 | + | events := make(chan llamaCPPEvent, 8) | |||
| 366 | + | go func() { _ = c.watch(watchCtx, events) }() | |||
| 367 | + | if err := c.Download(ctx, model); err != nil { | |||
| 368 | + | return nil, err | |||
| 369 | + | } | |||
| 370 | + | update(LlamaCPPProgress{Message: "Downloading model"}) | |||
| 371 | + | ticker := time.NewTicker(500 * time.Millisecond) | |||
| 372 | + | defer ticker.Stop() | |||
| 373 | + | sawDownload := false | |||
| 374 | + | polls := 0 | |||
| 375 | + | for { | |||
| 376 | + | select { | |||
| 377 | + | case <-ctx.Done(): | |||
| 378 | + | return nil, ctx.Err() | |||
| 379 | + | case event := <-events: | |||
| 380 | + | if event.Model != model { | |||
| 381 | + | continue | |||
| 382 | + | } | |||
| 383 | + | switch event.Event { | |||
| 384 | + | case "download_progress": | |||
| 385 | + | sawDownload = true | |||
| 386 | + | var payload struct { | |||
| 387 | + | Progress map[string]LlamaCPPBytes `json:"progress"` | |||
| 388 | + | } | |||
| 389 | + | if json.Unmarshal(event.Data, &payload) == nil { | |||
| 390 | + | if progress, ok := downloadProgress(payload.Progress); ok { | |||
| 391 | + | update(progress) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | case "download_failed": | |||
| 395 | + | return nil, errors.New("model download failed") | |||
| 396 | + | case "download_finished": | |||
| 397 | + | return c.List(ctx, true) | |||
| 398 | + | } | |||
| 399 | + | case <-ticker.C: | |||
| 400 | + | models, err := c.List(ctx, false) | |||
| 401 | + | if err != nil { | |||
| 402 | + | return nil, err | |||
| 403 | + | } | |||
| 404 | + | polls++ | |||
| 405 | + | for _, entry := range models { | |||
| 406 | + | if entry.ID != model { | |||
| 407 | + | continue | |||
| 408 | + | } | |||
| 409 | + | if entry.Status.Value == "downloading" { | |||
| 410 | + | sawDownload = true | |||
| 411 | + | if progress, ok := downloadProgress(entry.Status.Progress); ok { | |||
| 412 | + | update(progress) | |||
| 413 | + | } | |||
| 414 | + | } else if sawDownload || polls >= 2 { | |||
| 415 | + | return c.List(ctx, true) | |||
| 416 | + | } | |||
| 417 | + | } | |||
| 418 | + | } | |||
| 419 | + | } | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | // LlamaCPPModels converts loaded router entries into zot model metadata. | |||
| 423 | + | func LlamaCPPModels(models []LlamaCPPModel, serverURL string) []Model { | |||
| 424 | + | baseURL, err := LlamaCPPInferenceURL(serverURL) | |||
| 425 | + | if err != nil { | |||
| 426 | + | return nil | |||
| 427 | + | } | |||
| 428 | + | var out []Model | |||
| 429 | + | for _, entry := range models { | |||
| 430 | + | if entry.Status.Value != "loaded" && entry.Status.Value != "sleeping" { | |||
| 431 | + | continue | |||
| 432 | + | } | |||
| 433 | + | contextWindow := entry.Meta.Context | |||
| 434 | + | if contextWindow <= 0 { | |||
| 435 | + | contextWindow = entry.Meta.TrainContext | |||
| 436 | + | } | |||
| 437 | + | if contextWindow <= 0 { | |||
| 438 | + | contextWindow = 128000 | |||
| 439 | + | } | |||
| 440 | + | maxOutput := min(16384, contextWindow) | |||
| 441 | + | out = append(out, Model{ | |||
| 442 | + | Provider: LlamaCPPProviderID, ID: entry.ID, DisplayName: entry.ID, | |||
| 443 | + | ContextWindow: contextWindow, MaxOutput: maxOutput, | |||
| 444 | + | BaseURL: baseURL, Source: "live", | |||
| 445 | + | }) | |||
| 446 | + | } | |||
| 447 | + | return out | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | // FormatBytes renders byte counts using binary units. | |||
| 451 | + | func FormatBytes(bytes int64) string { | |||
| 452 | + | if bytes < 1024 { | |||
| 453 | + | return fmt.Sprintf("%d B", bytes) | |||
| 454 | + | } | |||
| 455 | + | units := []string{"KiB", "MiB", "GiB", "TiB"} | |||
| 456 | + | value := float64(bytes) / 1024 | |||
| 457 | + | unit := units[0] | |||
| 458 | + | for index := 1; index < len(units) && value >= 1024; index++ { | |||
| 459 | + | value /= 1024 | |||
| 460 | + | unit = units[index] | |||
| 461 | + | } | |||
| 462 | + | if value >= 10 { | |||
| 463 | + | return fmt.Sprintf("%.1f %s", value, unit) | |||
| 464 | + | } | |||
| 465 | + | return fmt.Sprintf("%.2f %s", value, unit) | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | // HuggingFaceModel is one GGUF repository search result. | |||
| 469 | + | type HuggingFaceModel struct { | |||
| 470 | + | ID string `json:"id"` | |||
| 471 | + | Downloads int64 `json:"downloads"` | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | type HuggingFaceQuantization struct { | |||
| 475 | + | Name string | |||
| 476 | + | Size int64 | |||
| 477 | + | HasSize bool | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | type HuggingFaceModelDetails struct { | |||
| 481 | + | ID string | |||
| 482 | + | Gated string | |||
| 483 | + | Quantizations []HuggingFaceQuantization | |||
| 484 | + | } | |||
| 485 | + | ||||
| 486 | + | type HuggingFaceClient struct { | |||
| 487 | + | Token string | |||
| 488 | + | BaseURL string | |||
| 489 | + | HTTP *http.Client | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func NewHuggingFaceClient(token string) *HuggingFaceClient { | |||
| 493 | + | return &HuggingFaceClient{Token: token, BaseURL: defaultHuggingFaceURL, HTTP: &http.Client{Timeout: llamaCPPRequestTimeout}} | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | func (c *HuggingFaceClient) request(ctx context.Context, path string, out any) error { | |||
| 497 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.BaseURL, "/")+path, nil) | |||
| 498 | + | if err != nil { | |||
| 499 | + | return err | |||
| 500 | + | } | |||
| 501 | + | if c.Token != "" { | |||
| 502 | + | req.Header.Set("authorization", "Bearer "+c.Token) | |||
| 503 | + | } | |||
| 504 | + | resp, err := c.HTTP.Do(req) | |||
| 505 | + | if err != nil { | |||
| 506 | + | return err | |||
| 507 | + | } | |||
| 508 | + | defer resp.Body.Close() | |||
| 509 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { | |||
| 510 | + | if resp.StatusCode == http.StatusTooManyRequests { | |||
| 511 | + | return errors.New("Hugging Face rate limit reached") | |||
| 512 | + | } | |||
| 513 | + | return fmt.Errorf("Hugging Face returned HTTP %d", resp.StatusCode) | |||
| 514 | + | } | |||
| 515 | + | if err := json.NewDecoder(resp.Body).Decode(out); err != nil { | |||
| 516 | + | return fmt.Errorf("parse Hugging Face response: %w", err) | |||
| 517 | + | } | |||
| 518 | + | return nil | |||
| 519 | + | } | |||
| 520 | + | ||||
| 521 | + | func (c *HuggingFaceClient) Search(ctx context.Context, query string) ([]HuggingFaceModel, error) { | |||
| 522 | + | params := url.Values{"search": {query}, "filter": {"gguf"}, "sort": {"downloads"}, "direction": {"-1"}, "limit": {"20"}} | |||
| 523 | + | var models []HuggingFaceModel | |||
| 524 | + | if err := c.request(ctx, "/api/models?"+params.Encode(), &models); err != nil { | |||
| 525 | + | return nil, err | |||
| 526 | + | } | |||
| 527 | + | return models, nil | |||
| 528 | + | } | |||
| 529 | + | ||||
| 530 | + | var quantizationPattern = regexp.MustCompile(`(?i)(?:^|[-_.])((?:UD-)?(?:IQ[0-9](?:_[A-Z0-9]+)+|Q[0-9](?:_[A-Z0-9]+)+|BF16|F16|F32|MXFP[0-9](?:_[A-Z0-9]+)*))$`) | |||
| 531 | + | var shardSuffixPattern = regexp.MustCompile(`-[0-9]{5}-of-[0-9]{5}$`) | |||
| 532 | + | ||||
| 533 | + | func (c *HuggingFaceClient) Details(ctx context.Context, id string) (HuggingFaceModelDetails, error) { | |||
| 534 | + | parts := strings.Split(id, "/") | |||
| 535 | + | for i := range parts { | |||
| 536 | + | parts[i] = url.PathEscape(parts[i]) | |||
| 537 | + | } | |||
| 538 | + | encoded := strings.Join(parts, "/") | |||
| 539 | + | var payload struct { | |||
| 540 | + | ID string `json:"id"` | |||
| 541 | + | Gated json.RawMessage `json:"gated"` | |||
| 542 | + | Siblings []struct { | |||
| 543 | + | Filename string `json:"rfilename"` | |||
| 544 | + | Size *int64 `json:"size"` | |||
| 545 | + | } `json:"siblings"` | |||
| 546 | + | } | |||
| 547 | + | if err := c.request(ctx, "/api/models/"+encoded+"?blobs=true", &payload); err != nil { | |||
| 548 | + | return HuggingFaceModelDetails{}, err | |||
| 549 | + | } | |||
| 550 | + | type quantSize struct { | |||
| 551 | + | total int64 | |||
| 552 | + | complete bool | |||
| 553 | + | } | |||
| 554 | + | sizes := map[string]quantSize{} | |||
| 555 | + | for _, file := range payload.Siblings { | |||
| 556 | + | name := filepath.Base(file.Filename) | |||
| 557 | + | if !strings.HasSuffix(strings.ToLower(name), ".gguf") || strings.HasPrefix(strings.ToLower(name), "mmproj") { | |||
| 558 | + | continue | |||
| 559 | + | } | |||
| 560 | + | stem := shardSuffixPattern.ReplaceAllString(name[:len(name)-5], "") | |||
| 561 | + | match := quantizationPattern.FindStringSubmatch(stem) | |||
| 562 | + | if len(match) < 2 { | |||
| 563 | + | continue | |||
| 564 | + | } | |||
| 565 | + | quant := strings.ToUpper(match[1]) | |||
| 566 | + | current, ok := sizes[quant] | |||
| 567 | + | if !ok { | |||
| 568 | + | current.complete = true | |||
| 569 | + | } | |||
| 570 | + | if file.Size == nil { | |||
| 571 | + | current.complete = false | |||
| 572 | + | } else { | |||
| 573 | + | current.total += *file.Size | |||
| 574 | + | } | |||
| 575 | + | sizes[quant] = current | |||
| 576 | + | } | |||
| 577 | + | var quantizations []HuggingFaceQuantization | |||
| 578 | + | for name, size := range sizes { | |||
| 579 | + | quantizations = append(quantizations, HuggingFaceQuantization{Name: name, Size: size.total, HasSize: size.complete}) | |||
| 580 | + | } | |||
| 581 | + | sort.Slice(quantizations, func(i, j int) bool { | |||
| 582 | + | if quantizations[i].Name == "Q4_K_M" { | |||
| 583 | + | return true | |||
| 584 | + | } | |||
| 585 | + | if quantizations[j].Name == "Q4_K_M" { | |||
| 586 | + | return false | |||
| 587 | + | } | |||
| 588 | + | if quantizations[i].HasSize != quantizations[j].HasSize { | |||
| 589 | + | return quantizations[i].HasSize | |||
| 590 | + | } | |||
| 591 | + | if quantizations[i].Size != quantizations[j].Size { | |||
| 592 | + | return quantizations[i].Size < quantizations[j].Size | |||
| 593 | + | } | |||
| 594 | + | return quantizations[i].Name < quantizations[j].Name | |||
| 595 | + | }) | |||
| 596 | + | gated := "" | |||
| 597 | + | _ = json.Unmarshal(payload.Gated, &gated) | |||
| 598 | + | if payload.ID == "" { | |||
| 599 | + | payload.ID = id | |||
| 600 | + | } | |||
| 601 | + | return HuggingFaceModelDetails{ID: payload.ID, Gated: gated, Quantizations: quantizations}, nil | |||
| 602 | + | } | |||
| 603 | + | ||||
| 604 | + | // FindHuggingFaceToken follows the standard Hugging Face token locations. | |||
| 605 | + | func FindHuggingFaceToken() string { | |||
| 606 | + | if token := strings.TrimSpace(os.Getenv("HF_TOKEN")); token != "" { | |||
| 607 | + | return token | |||
| 608 | + | } | |||
| 609 | + | home, _ := os.UserHomeDir() | |||
| 610 | + | paths := []string{os.Getenv("HF_TOKEN_PATH")} | |||
| 611 | + | if value := os.Getenv("HF_HOME"); value != "" { | |||
| 612 | + | paths = append(paths, filepath.Join(value, "token")) | |||
| 613 | + | } | |||
| 614 | + | if value := os.Getenv("XDG_CACHE_HOME"); value != "" { | |||
| 615 | + | paths = append(paths, filepath.Join(value, "huggingface", "token")) | |||
| 616 | + | } | |||
| 617 | + | if home != "" { | |||
| 618 | + | paths = append(paths, filepath.Join(home, ".cache", "huggingface", "token")) | |||
| 619 | + | } | |||
| 620 | + | for _, path := range paths { | |||
| 621 | + | if path == "" { | |||
| 622 | + | continue | |||
| 623 | + | } | |||
| 624 | + | if data, err := os.ReadFile(path); err == nil { | |||
| 625 | + | if token := strings.TrimSpace(string(data)); token != "" { | |||
| 626 | + | return token | |||
| 627 | + | } | |||
| 628 | + | } | |||
| 629 | + | } | |||
| 630 | + | return "" | |||
| 631 | + | } | |||
A packages/provider/llamacpp_test.go +204 -0
| @@ -0,0 +1,204 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestNormalizeLlamaCPPURL(t *testing.T) { | |
| 16 | + | got, err := NormalizeLlamaCPPURL("http://127.0.0.1:8080/v1/") | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | if got != "http://127.0.0.1:8080" { | |
| 21 | + | t.Fatalf("got %q", got) | |
| 22 | + | } | |
| 23 | + | if _, err := NormalizeLlamaCPPURL("file:///tmp/router"); err == nil { | |
| 24 | + | t.Fatal("expected non-http URL to fail") | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestLlamaCPPLoadProgressStages(t *testing.T) { | |
| 29 | + | progress, ok := loadProgress(json.RawMessage(`{"progress":{"stages":["text_model","mmproj_model"],"current":"text_model","value":0.5}}`)) | |
| 30 | + | if !ok || !progress.HasRatio || progress.Ratio != 0.25 || progress.Message != "Loading text model" { | |
| 31 | + | t.Fatalf("progress = %#v, ok = %v", progress, ok) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestLlamaCPPClientLoadAndDownloadProgress(t *testing.T) { | |
| 36 | + | var mu sync.Mutex | |
| 37 | + | status := "unloaded" | |
| 38 | + | events := make(chan string, 8) | |
| 39 | + | watchDone := make(chan struct{}, 2) | |
| 40 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 41 | + | if got := r.Header.Get("Authorization"); got != "Bearer secret" { | |
| 42 | + | t.Errorf("authorization = %q", got) | |
| 43 | + | } | |
| 44 | + | switch { | |
| 45 | + | case r.URL.Path == "/models/sse": | |
| 46 | + | defer func() { watchDone <- struct{}{} }() | |
| 47 | + | w.Header().Set("content-type", "text/event-stream") | |
| 48 | + | flusher := w.(http.Flusher) | |
| 49 | + | for { | |
| 50 | + | select { | |
| 51 | + | case line := <-events: | |
| 52 | + | fmt.Fprintf(w, "data: %s\n\n", line) | |
| 53 | + | flusher.Flush() | |
| 54 | + | mu.Lock() | |
| 55 | + | if strings.Contains(line, `"event":"status_change"`) { | |
| 56 | + | status = "loaded" | |
| 57 | + | } | |
| 58 | + | if strings.Contains(line, `"event":"download_finished"`) { | |
| 59 | + | status = "unloaded" | |
| 60 | + | } | |
| 61 | + | mu.Unlock() | |
| 62 | + | case <-r.Context().Done(): | |
| 63 | + | return | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | case r.URL.Path == "/models/load": | |
| 67 | + | mu.Lock() | |
| 68 | + | status = "loading" | |
| 69 | + | mu.Unlock() | |
| 70 | + | w.Header().Set("content-type", "application/json") | |
| 71 | + | fmt.Fprint(w, `{"success":true}`) | |
| 72 | + | events <- `{"model":"test","event":"status_change","data":{"status":"loading","progress":{"stages":["text_model"],"current":"text_model","value":0.5}}}` | |
| 73 | + | case r.URL.Path == "/models" && r.Method == http.MethodPost: | |
| 74 | + | mu.Lock() | |
| 75 | + | status = "downloading" | |
| 76 | + | mu.Unlock() | |
| 77 | + | fmt.Fprint(w, `{"success":true}`) | |
| 78 | + | events <- `{"model":"test","event":"download_progress","data":{"progress":{"model.gguf":{"done":512,"total":1024}}}}` | |
| 79 | + | events <- `{"model":"test","event":"download_finished","data":{}}` | |
| 80 | + | case r.URL.Path == "/models": | |
| 81 | + | mu.Lock() | |
| 82 | + | current := status | |
| 83 | + | mu.Unlock() | |
| 84 | + | modelStatus := map[string]any{"value": current} | |
| 85 | + | if current == "downloading" { | |
| 86 | + | modelStatus["progress"] = map[string]any{"model.gguf": map[string]any{"done": 512, "total": 1024}} | |
| 87 | + | } | |
| 88 | + | _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": "test", "status": modelStatus}}}) | |
| 89 | + | default: | |
| 90 | + | http.NotFound(w, r) | |
| 91 | + | } | |
| 92 | + | })) | |
| 93 | + | defer server.Close() | |
| 94 | + | ||
| 95 | + | client, err := NewLlamaCPPClient(server.URL, "secret") | |
| 96 | + | if err != nil { | |
| 97 | + | t.Fatal(err) | |
| 98 | + | } | |
| 99 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 100 | + | defer cancel() | |
| 101 | + | var loadProgress []LlamaCPPProgress | |
| 102 | + | model, err := client.LoadAndWait(ctx, "test", func(progress LlamaCPPProgress) { loadProgress = append(loadProgress, progress) }) | |
| 103 | + | if err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | if model.Status.Value != "loaded" { | |
| 107 | + | t.Fatalf("status = %q", model.Status.Value) | |
| 108 | + | } | |
| 109 | + | if len(loadProgress) == 0 { | |
| 110 | + | t.Fatal("expected load progress updates") | |
| 111 | + | } | |
| 112 | + | select { | |
| 113 | + | case <-watchDone: | |
| 114 | + | case <-ctx.Done(): | |
| 115 | + | t.Fatal("load progress watcher did not stop") | |
| 116 | + | } | |
| 117 | + | var download []LlamaCPPProgress | |
| 118 | + | if _, err := client.DownloadAndWait(ctx, "test", func(progress LlamaCPPProgress) { download = append(download, progress) }); err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | found := false | |
| 122 | + | for _, progress := range download { | |
| 123 | + | if progress.HasRatio && progress.Ratio == 0.5 && strings.Contains(progress.Detail, "512 B") { | |
| 124 | + | found = true | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | if !found { | |
| 128 | + | t.Fatalf("download progress = %#v", download) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestLlamaCPPClientRemoveUsesDeleteWithModelQuery(t *testing.T) { | |
| 133 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 134 | + | if r.Method != http.MethodDelete || r.URL.Path != "/models" { | |
| 135 | + | t.Errorf("request = %s %s", r.Method, r.URL.Path) | |
| 136 | + | } | |
| 137 | + | if got := r.URL.Query().Get("model"); got != "owner/model:Q4_K_M" { | |
| 138 | + | t.Errorf("model = %q", got) | |
| 139 | + | } | |
| 140 | + | if got := r.Header.Get("Authorization"); got != "Bearer secret" { | |
| 141 | + | t.Errorf("authorization = %q", got) | |
| 142 | + | } | |
| 143 | + | fmt.Fprint(w, `{"success":true}`) | |
| 144 | + | })) | |
| 145 | + | defer server.Close() | |
| 146 | + | ||
| 147 | + | client, err := NewLlamaCPPClient(server.URL, "secret") | |
| 148 | + | if err != nil { | |
| 149 | + | t.Fatal(err) | |
| 150 | + | } | |
| 151 | + | if err := client.Remove(context.Background(), "owner/model:Q4_K_M"); err != nil { | |
| 152 | + | t.Fatal(err) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestHuggingFaceDetailsCollectsGGUFQuantizations(t *testing.T) { | |
| 157 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 158 | + | if r.URL.Path == "/api/models" { | |
| 159 | + | if r.URL.Query().Get("filter") != "gguf" || r.URL.Query().Get("search") != "coder" { | |
| 160 | + | t.Errorf("query = %s", r.URL.RawQuery) | |
| 161 | + | } | |
| 162 | + | fmt.Fprint(w, `[{"id":"owner/model-GGUF","downloads":1200}]`) | |
| 163 | + | return | |
| 164 | + | } | |
| 165 | + | if r.URL.Path == "/api/models/owner/model-GGUF" { | |
| 166 | + | fmt.Fprint(w, `{"id":"owner/model-GGUF","gated":"manual","siblings":[{"rfilename":"model-Q5_K_M.gguf","size":6000},{"rfilename":"model-Q4_K_M-00001-of-00002.gguf","size":2000},{"rfilename":"model-Q4_K_M-00002-of-00002.gguf","size":3000},{"rfilename":"mmproj-F16.gguf","size":1000}]}`) | |
| 167 | + | return | |
| 168 | + | } | |
| 169 | + | http.NotFound(w, r) | |
| 170 | + | })) | |
| 171 | + | defer server.Close() | |
| 172 | + | ||
| 173 | + | client := NewHuggingFaceClient("token") | |
| 174 | + | client.BaseURL = server.URL | |
| 175 | + | models, err := client.Search(context.Background(), "coder") | |
| 176 | + | if err != nil || len(models) != 1 { | |
| 177 | + | t.Fatalf("search: models=%#v err=%v", models, err) | |
| 178 | + | } | |
| 179 | + | details, err := client.Details(context.Background(), models[0].ID) | |
| 180 | + | if err != nil { | |
| 181 | + | t.Fatal(err) | |
| 182 | + | } | |
| 183 | + | if details.Gated != "manual" || len(details.Quantizations) != 2 { | |
| 184 | + | t.Fatalf("details = %#v", details) | |
| 185 | + | } | |
| 186 | + | if details.Quantizations[0].Name != "Q4_K_M" || details.Quantizations[0].Size != 5000 { | |
| 187 | + | t.Fatalf("first quantization = %#v", details.Quantizations[0]) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestSetManagedModelsDoesNotReplaceDiscoveredCatalog(t *testing.T) { | |
| 192 | + | SetLiveModels([]Model{{Provider: "openrouter", ID: "vendor/live", Source: "live"}}) | |
| 193 | + | SetManagedModels([]Model{{Provider: LlamaCPPProviderID, ID: "local", Source: "live"}}) | |
| 194 | + | t.Cleanup(func() { | |
| 195 | + | SetLiveModels(nil) | |
| 196 | + | SetManagedModels(nil) | |
| 197 | + | }) | |
| 198 | + | if _, err := FindModel("openrouter", "vendor/live"); err != nil { | |
| 199 | + | t.Fatal(err) | |
| 200 | + | } | |
| 201 | + | if _, err := FindModel(LlamaCPPProviderID, "local"); err != nil { | |
| 202 | + | t.Fatal(err) | |
| 203 | + | } | |
| 204 | + | } | |
| @@ -0,0 +1,204 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestNormalizeLlamaCPPURL(t *testing.T) { | |||
| 16 | + | got, err := NormalizeLlamaCPPURL("http://127.0.0.1:8080/v1/") | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | if got != "http://127.0.0.1:8080" { | |||
| 21 | + | t.Fatalf("got %q", got) | |||
| 22 | + | } | |||
| 23 | + | if _, err := NormalizeLlamaCPPURL("file:///tmp/router"); err == nil { | |||
| 24 | + | t.Fatal("expected non-http URL to fail") | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestLlamaCPPLoadProgressStages(t *testing.T) { | |||
| 29 | + | progress, ok := loadProgress(json.RawMessage(`{"progress":{"stages":["text_model","mmproj_model"],"current":"text_model","value":0.5}}`)) | |||
| 30 | + | if !ok || !progress.HasRatio || progress.Ratio != 0.25 || progress.Message != "Loading text model" { | |||
| 31 | + | t.Fatalf("progress = %#v, ok = %v", progress, ok) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestLlamaCPPClientLoadAndDownloadProgress(t *testing.T) { | |||
| 36 | + | var mu sync.Mutex | |||
| 37 | + | status := "unloaded" | |||
| 38 | + | events := make(chan string, 8) | |||
| 39 | + | watchDone := make(chan struct{}, 2) | |||
| 40 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 41 | + | if got := r.Header.Get("Authorization"); got != "Bearer secret" { | |||
| 42 | + | t.Errorf("authorization = %q", got) | |||
| 43 | + | } | |||
| 44 | + | switch { | |||
| 45 | + | case r.URL.Path == "/models/sse": | |||
| 46 | + | defer func() { watchDone <- struct{}{} }() | |||
| 47 | + | w.Header().Set("content-type", "text/event-stream") | |||
| 48 | + | flusher := w.(http.Flusher) | |||
| 49 | + | for { | |||
| 50 | + | select { | |||
| 51 | + | case line := <-events: | |||
| 52 | + | fmt.Fprintf(w, "data: %s\n\n", line) | |||
| 53 | + | flusher.Flush() | |||
| 54 | + | mu.Lock() | |||
| 55 | + | if strings.Contains(line, `"event":"status_change"`) { | |||
| 56 | + | status = "loaded" | |||
| 57 | + | } | |||
| 58 | + | if strings.Contains(line, `"event":"download_finished"`) { | |||
| 59 | + | status = "unloaded" | |||
| 60 | + | } | |||
| 61 | + | mu.Unlock() | |||
| 62 | + | case <-r.Context().Done(): | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | case r.URL.Path == "/models/load": | |||
| 67 | + | mu.Lock() | |||
| 68 | + | status = "loading" | |||
| 69 | + | mu.Unlock() | |||
| 70 | + | w.Header().Set("content-type", "application/json") | |||
| 71 | + | fmt.Fprint(w, `{"success":true}`) | |||
| 72 | + | events <- `{"model":"test","event":"status_change","data":{"status":"loading","progress":{"stages":["text_model"],"current":"text_model","value":0.5}}}` | |||
| 73 | + | case r.URL.Path == "/models" && r.Method == http.MethodPost: | |||
| 74 | + | mu.Lock() | |||
| 75 | + | status = "downloading" | |||
| 76 | + | mu.Unlock() | |||
| 77 | + | fmt.Fprint(w, `{"success":true}`) | |||
| 78 | + | events <- `{"model":"test","event":"download_progress","data":{"progress":{"model.gguf":{"done":512,"total":1024}}}}` | |||
| 79 | + | events <- `{"model":"test","event":"download_finished","data":{}}` | |||
| 80 | + | case r.URL.Path == "/models": | |||
| 81 | + | mu.Lock() | |||
| 82 | + | current := status | |||
| 83 | + | mu.Unlock() | |||
| 84 | + | modelStatus := map[string]any{"value": current} | |||
| 85 | + | if current == "downloading" { | |||
| 86 | + | modelStatus["progress"] = map[string]any{"model.gguf": map[string]any{"done": 512, "total": 1024}} | |||
| 87 | + | } | |||
| 88 | + | _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": "test", "status": modelStatus}}}) | |||
| 89 | + | default: | |||
| 90 | + | http.NotFound(w, r) | |||
| 91 | + | } | |||
| 92 | + | })) | |||
| 93 | + | defer server.Close() | |||
| 94 | + | ||||
| 95 | + | client, err := NewLlamaCPPClient(server.URL, "secret") | |||
| 96 | + | if err != nil { | |||
| 97 | + | t.Fatal(err) | |||
| 98 | + | } | |||
| 99 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |||
| 100 | + | defer cancel() | |||
| 101 | + | var loadProgress []LlamaCPPProgress | |||
| 102 | + | model, err := client.LoadAndWait(ctx, "test", func(progress LlamaCPPProgress) { loadProgress = append(loadProgress, progress) }) | |||
| 103 | + | if err != nil { | |||
| 104 | + | t.Fatal(err) | |||
| 105 | + | } | |||
| 106 | + | if model.Status.Value != "loaded" { | |||
| 107 | + | t.Fatalf("status = %q", model.Status.Value) | |||
| 108 | + | } | |||
| 109 | + | if len(loadProgress) == 0 { | |||
| 110 | + | t.Fatal("expected load progress updates") | |||
| 111 | + | } | |||
| 112 | + | select { | |||
| 113 | + | case <-watchDone: | |||
| 114 | + | case <-ctx.Done(): | |||
| 115 | + | t.Fatal("load progress watcher did not stop") | |||
| 116 | + | } | |||
| 117 | + | var download []LlamaCPPProgress | |||
| 118 | + | if _, err := client.DownloadAndWait(ctx, "test", func(progress LlamaCPPProgress) { download = append(download, progress) }); err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | found := false | |||
| 122 | + | for _, progress := range download { | |||
| 123 | + | if progress.HasRatio && progress.Ratio == 0.5 && strings.Contains(progress.Detail, "512 B") { | |||
| 124 | + | found = true | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | if !found { | |||
| 128 | + | t.Fatalf("download progress = %#v", download) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestLlamaCPPClientRemoveUsesDeleteWithModelQuery(t *testing.T) { | |||
| 133 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 134 | + | if r.Method != http.MethodDelete || r.URL.Path != "/models" { | |||
| 135 | + | t.Errorf("request = %s %s", r.Method, r.URL.Path) | |||
| 136 | + | } | |||
| 137 | + | if got := r.URL.Query().Get("model"); got != "owner/model:Q4_K_M" { | |||
| 138 | + | t.Errorf("model = %q", got) | |||
| 139 | + | } | |||
| 140 | + | if got := r.Header.Get("Authorization"); got != "Bearer secret" { | |||
| 141 | + | t.Errorf("authorization = %q", got) | |||
| 142 | + | } | |||
| 143 | + | fmt.Fprint(w, `{"success":true}`) | |||
| 144 | + | })) | |||
| 145 | + | defer server.Close() | |||
| 146 | + | ||||
| 147 | + | client, err := NewLlamaCPPClient(server.URL, "secret") | |||
| 148 | + | if err != nil { | |||
| 149 | + | t.Fatal(err) | |||
| 150 | + | } | |||
| 151 | + | if err := client.Remove(context.Background(), "owner/model:Q4_K_M"); err != nil { | |||
| 152 | + | t.Fatal(err) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestHuggingFaceDetailsCollectsGGUFQuantizations(t *testing.T) { | |||
| 157 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 158 | + | if r.URL.Path == "/api/models" { | |||
| 159 | + | if r.URL.Query().Get("filter") != "gguf" || r.URL.Query().Get("search") != "coder" { | |||
| 160 | + | t.Errorf("query = %s", r.URL.RawQuery) | |||
| 161 | + | } | |||
| 162 | + | fmt.Fprint(w, `[{"id":"owner/model-GGUF","downloads":1200}]`) | |||
| 163 | + | return | |||
| 164 | + | } | |||
| 165 | + | if r.URL.Path == "/api/models/owner/model-GGUF" { | |||
| 166 | + | fmt.Fprint(w, `{"id":"owner/model-GGUF","gated":"manual","siblings":[{"rfilename":"model-Q5_K_M.gguf","size":6000},{"rfilename":"model-Q4_K_M-00001-of-00002.gguf","size":2000},{"rfilename":"model-Q4_K_M-00002-of-00002.gguf","size":3000},{"rfilename":"mmproj-F16.gguf","size":1000}]}`) | |||
| 167 | + | return | |||
| 168 | + | } | |||
| 169 | + | http.NotFound(w, r) | |||
| 170 | + | })) | |||
| 171 | + | defer server.Close() | |||
| 172 | + | ||||
| 173 | + | client := NewHuggingFaceClient("token") | |||
| 174 | + | client.BaseURL = server.URL | |||
| 175 | + | models, err := client.Search(context.Background(), "coder") | |||
| 176 | + | if err != nil || len(models) != 1 { | |||
| 177 | + | t.Fatalf("search: models=%#v err=%v", models, err) | |||
| 178 | + | } | |||
| 179 | + | details, err := client.Details(context.Background(), models[0].ID) | |||
| 180 | + | if err != nil { | |||
| 181 | + | t.Fatal(err) | |||
| 182 | + | } | |||
| 183 | + | if details.Gated != "manual" || len(details.Quantizations) != 2 { | |||
| 184 | + | t.Fatalf("details = %#v", details) | |||
| 185 | + | } | |||
| 186 | + | if details.Quantizations[0].Name != "Q4_K_M" || details.Quantizations[0].Size != 5000 { | |||
| 187 | + | t.Fatalf("first quantization = %#v", details.Quantizations[0]) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestSetManagedModelsDoesNotReplaceDiscoveredCatalog(t *testing.T) { | |||
| 192 | + | SetLiveModels([]Model{{Provider: "openrouter", ID: "vendor/live", Source: "live"}}) | |||
| 193 | + | SetManagedModels([]Model{{Provider: LlamaCPPProviderID, ID: "local", Source: "live"}}) | |||
| 194 | + | t.Cleanup(func() { | |||
| 195 | + | SetLiveModels(nil) | |||
| 196 | + | SetManagedModels(nil) | |||
| 197 | + | }) | |||
| 198 | + | if _, err := FindModel("openrouter", "vendor/live"); err != nil { | |||
| 199 | + | t.Fatal(err) | |||
| 200 | + | } | |||
| 201 | + | if _, err := FindModel(LlamaCPPProviderID, "local"); err != nil { | |||
| 202 | + | t.Fatal(err) | |||
| 203 | + | } | |||
| 204 | + | } | |||
M packages/provider/models.go +29 -3
| @@ -393,9 +393,10 @@ var DefaultModel = Catalog[0] // claude-sonnet-4-5 | |||
| 393 | 393 | // models loaded via SetLiveModels. | |
| 394 | 394 | ||
| 395 | 395 | var ( | |
| 396 | - | activeMu sync.RWMutex | |
| 397 | - | active []Model // live overlay merged in via SetLiveModels; nil = none yet | |
| 398 | - | activeSet bool // true once SetLiveModels has run (even with empty live) | |
| 396 | + | activeMu sync.RWMutex | |
| 397 | + | active []Model // live overlay merged in via SetLiveModels; nil = none yet | |
| 398 | + | activeSet bool // true once SetLiveModels has run (even with empty live) | |
| 399 | + | managedModels []Model // ephemeral models exposed by local model managers | |
| 399 | 400 | ) | |
| 400 | 401 | ||
| 401 | 402 | // SetLiveModels replaces the "live" overlay used by the active catalog. | |
| @@ -431,9 +432,34 @@ func Active() []Model { | |||
| 431 | 432 | } | |
| 432 | 433 | out := make([]Model, len(src)) | |
| 433 | 434 | copy(out, src) | |
| 435 | + | if len(managedModels) == 0 { | |
| 436 | + | return out | |
| 437 | + | } | |
| 438 | + | index := make(map[string]int, len(out)) | |
| 439 | + | for i, model := range out { | |
| 440 | + | index[model.Provider+"\x00"+model.ID] = i | |
| 441 | + | } | |
| 442 | + | for _, model := range managedModels { | |
| 443 | + | key := model.Provider + "\x00" + model.ID | |
| 444 | + | if i, ok := index[key]; ok { | |
| 445 | + | out[i] = model | |
| 446 | + | continue | |
| 447 | + | } | |
| 448 | + | index[key] = len(out) | |
| 449 | + | out = append(out, model) | |
| 450 | + | } | |
| 434 | 451 | return out | |
| 435 | 452 | } | |
| 436 | 453 | ||
| 454 | + | // SetManagedModels replaces the ephemeral catalog entries supplied by local | |
| 455 | + | // model managers. Unlike SetLiveModels, this does not disturb provider model | |
| 456 | + | // discovery or its on-disk cache. | |
| 457 | + | func SetManagedModels(models []Model) { | |
| 458 | + | activeMu.Lock() | |
| 459 | + | defer activeMu.Unlock() | |
| 460 | + | managedModels = append([]Model(nil), models...) | |
| 461 | + | } | |
| 462 | + | ||
| 437 | 463 | // FindModel returns a Model by id, optionally constrained by provider. | |
| 438 | 464 | // If provider is empty, the first matching id is returned. Looks up | |
| 439 | 465 | // against the merged active catalog. | |
| @@ -393,9 +393,10 @@ var DefaultModel = Catalog[0] // claude-sonnet-4-5 | |||||
| 393 | // models loaded via SetLiveModels. | 393 | // models loaded via SetLiveModels. | ||
| 394 | 394 | ||||
| 395 | var ( | 395 | var ( | ||
| 396 | - | activeMu sync.RWMutex | 396 | + | activeMu sync.RWMutex |
| 397 | - | active []Model // live overlay merged in via SetLiveModels; nil = none yet | 397 | + | active []Model // live overlay merged in via SetLiveModels; nil = none yet |
| 398 | - | activeSet bool // true once SetLiveModels has run (even with empty live) | 398 | + | activeSet bool // true once SetLiveModels has run (even with empty live) |
| 399 | + | managedModels []Model // ephemeral models exposed by local model managers | |||
| 399 | ) | 400 | ) | ||
| 400 | 401 | ||||
| 401 | // SetLiveModels replaces the "live" overlay used by the active catalog. | 402 | // SetLiveModels replaces the "live" overlay used by the active catalog. | ||
| @@ -431,9 +432,34 @@ func Active() []Model { | |||||
| 431 | } | 432 | } | ||
| 432 | out := make([]Model, len(src)) | 433 | out := make([]Model, len(src)) | ||
| 433 | copy(out, src) | 434 | copy(out, src) | ||
| 435 | + | if len(managedModels) == 0 { | |||
| 436 | + | return out | |||
| 437 | + | } | |||
| 438 | + | index := make(map[string]int, len(out)) | |||
| 439 | + | for i, model := range out { | |||
| 440 | + | index[model.Provider+"\x00"+model.ID] = i | |||
| 441 | + | } | |||
| 442 | + | for _, model := range managedModels { | |||
| 443 | + | key := model.Provider + "\x00" + model.ID | |||
| 444 | + | if i, ok := index[key]; ok { | |||
| 445 | + | out[i] = model | |||
| 446 | + | continue | |||
| 447 | + | } | |||
| 448 | + | index[key] = len(out) | |||
| 449 | + | out = append(out, model) | |||
| 450 | + | } | |||
| 434 | return out | 451 | return out | ||
| 435 | } | 452 | } | ||
| 436 | 453 | ||||
| 454 | + | // SetManagedModels replaces the ephemeral catalog entries supplied by local | |||
| 455 | + | // model managers. Unlike SetLiveModels, this does not disturb provider model | |||
| 456 | + | // discovery or its on-disk cache. | |||
| 457 | + | func SetManagedModels(models []Model) { | |||
| 458 | + | activeMu.Lock() | |||
| 459 | + | defer activeMu.Unlock() | |||
| 460 | + | managedModels = append([]Model(nil), models...) | |||
| 461 | + | } | |||
| 462 | + | ||||
| 437 | // FindModel returns a Model by id, optionally constrained by provider. | 463 | // FindModel returns a Model by id, optionally constrained by provider. | ||
| 438 | // If provider is empty, the first matching id is returned. Looks up | 464 | // If provider is empty, the first matching id is returned. Looks up | ||
| 439 | // against the merged active catalog. | 465 | // against the merged active catalog. | ||
M packages/tui/editor.go +10 -2
| @@ -25,6 +25,10 @@ type Editor struct { | |||
| 25 | 25 | Prompt string | |
| 26 | 26 | MaxWidth int | |
| 27 | 27 | ||
| 28 | + | // Mask replaces rendered input runes with asterisks while preserving the | |
| 29 | + | // real buffer for submission. It is intended for secrets such as API keys. | |
| 30 | + | Mask bool | |
| 31 | + | ||
| 28 | 32 | // lastRenderWidth is the column count passed to the most recent | |
| 29 | 33 | // Render() call. Up/Down key handling needs this to walk the | |
| 30 | 34 | // same visual layout the user sees: a logical line that wraps to | |
| @@ -766,13 +770,17 @@ func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) { | |||
| 766 | 770 | indent := strings.Repeat(" ", promptLen) | |
| 767 | 771 | ||
| 768 | 772 | for r, line := range e.Lines { | |
| 773 | + | renderedLine := line | |
| 774 | + | if e.Mask { | |
| 775 | + | renderedLine = strings.Repeat("*", len([]rune(line))) | |
| 776 | + | } | |
| 769 | 777 | var prefix string | |
| 770 | 778 | if r == 0 { | |
| 771 | 779 | prefix = plainPrompt | |
| 772 | 780 | } else { | |
| 773 | 781 | prefix = indent | |
| 774 | 782 | } | |
| 775 | - | wrapped := wrapLine(prefix+line, width, indent) | |
| 783 | + | wrapped := wrapLine(prefix+renderedLine, width, indent) | |
| 776 | 784 | if r == e.CursorR { | |
| 777 | 785 | // Compute where CursorC lands inside the wrapped rows by | |
| 778 | 786 | // walking the wrapped output character-by-character and | |
| @@ -781,7 +789,7 @@ func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) { | |||
| 781 | 789 | // answer under word-wrap, where a simple (promptLen+col)/width | |
| 782 | 790 | // formula overshoots when wrapLine broke on a space. | |
| 783 | 791 | targetRunes := e.CursorC | |
| 784 | - | row, col := locateCursor(wrapped, prefix, line, targetRunes, indent) | |
| 792 | + | row, col := locateCursor(wrapped, prefix, renderedLine, targetRunes, indent) | |
| 785 | 793 | visualRow = len(lines) + row | |
| 786 | 794 | visualCol = col | |
| 787 | 795 | } | |
| @@ -25,6 +25,10 @@ type Editor struct { | |||||
| 25 | Prompt string | 25 | Prompt string | ||
| 26 | MaxWidth int | 26 | MaxWidth int | ||
| 27 | 27 | ||||
| 28 | + | // Mask replaces rendered input runes with asterisks while preserving the | |||
| 29 | + | // real buffer for submission. It is intended for secrets such as API keys. | |||
| 30 | + | Mask bool | |||
| 31 | + | ||||
| 28 | // lastRenderWidth is the column count passed to the most recent | 32 | // lastRenderWidth is the column count passed to the most recent | ||
| 29 | // Render() call. Up/Down key handling needs this to walk the | 33 | // Render() call. Up/Down key handling needs this to walk the | ||
| 30 | // same visual layout the user sees: a logical line that wraps to | 34 | // same visual layout the user sees: a logical line that wraps to | ||
| @@ -766,13 +770,17 @@ func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) { | |||||
| 766 | indent := strings.Repeat(" ", promptLen) | 770 | indent := strings.Repeat(" ", promptLen) | ||
| 767 | 771 | ||||
| 768 | for r, line := range e.Lines { | 772 | for r, line := range e.Lines { | ||
| 773 | + | renderedLine := line | |||
| 774 | + | if e.Mask { | |||
| 775 | + | renderedLine = strings.Repeat("*", len([]rune(line))) | |||
| 776 | + | } | |||
| 769 | var prefix string | 777 | var prefix string | ||
| 770 | if r == 0 { | 778 | if r == 0 { | ||
| 771 | prefix = plainPrompt | 779 | prefix = plainPrompt | ||
| 772 | } else { | 780 | } else { | ||
| 773 | prefix = indent | 781 | prefix = indent | ||
| 774 | } | 782 | } | ||
| 775 | - | wrapped := wrapLine(prefix+line, width, indent) | 783 | + | wrapped := wrapLine(prefix+renderedLine, width, indent) |
| 776 | if r == e.CursorR { | 784 | if r == e.CursorR { | ||
| 777 | // Compute where CursorC lands inside the wrapped rows by | 785 | // Compute where CursorC lands inside the wrapped rows by | ||
| 778 | // walking the wrapped output character-by-character and | 786 | // walking the wrapped output character-by-character and | ||
| @@ -781,7 +789,7 @@ func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) { | |||||
| 781 | // answer under word-wrap, where a simple (promptLen+col)/width | 789 | // answer under word-wrap, where a simple (promptLen+col)/width | ||
| 782 | // formula overshoots when wrapLine broke on a space. | 790 | // formula overshoots when wrapLine broke on a space. | ||
| 783 | targetRunes := e.CursorC | 791 | targetRunes := e.CursorC | ||
| 784 | - | row, col := locateCursor(wrapped, prefix, line, targetRunes, indent) | 792 | + | row, col := locateCursor(wrapped, prefix, renderedLine, targetRunes, indent) |
| 785 | visualRow = len(lines) + row | 793 | visualRow = len(lines) + row | ||
| 786 | visualCol = col | 794 | visualCol = col | ||
| 787 | } | 795 | } | ||
A packages/tui/editor_mask_test.go +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestEditorMaskConcealsRenderedValue(t *testing.T) { | |
| 9 | + | ed := NewEditor("") | |
| 10 | + | ed.SetValue("secret-key") | |
| 11 | + | ed.Mask = true | |
| 12 | + | lines, _, _ := ed.Render(80) | |
| 13 | + | rendered := strings.Join(lines, "\n") | |
| 14 | + | if strings.Contains(rendered, "secret-key") || rendered != "**********" { | |
| 15 | + | t.Fatalf("rendered = %q", rendered) | |
| 16 | + | } | |
| 17 | + | if got := ed.SubmitValue(); got != "secret-key" { | |
| 18 | + | t.Fatalf("submitted = %q", got) | |
| 19 | + | } | |
| 20 | + | } | |
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestEditorMaskConcealsRenderedValue(t *testing.T) { | |||
| 9 | + | ed := NewEditor("") | |||
| 10 | + | ed.SetValue("secret-key") | |||
| 11 | + | ed.Mask = true | |||
| 12 | + | lines, _, _ := ed.Render(80) | |||
| 13 | + | rendered := strings.Join(lines, "\n") | |||
| 14 | + | if strings.Contains(rendered, "secret-key") || rendered != "**********" { | |||
| 15 | + | t.Fatalf("rendered = %q", rendered) | |||
| 16 | + | } | |||
| 17 | + | if got := ed.SubmitValue(); got != "secret-key" { | |||
| 18 | + | t.Fatalf("submitted = %q", got) | |||
| 19 | + | } | |||
| 20 | + | } | |||