detached 09b92ac

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:
247247 | `/login` | Log in via API key or subscription (opens a dialog). |
248248 | `/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. |
249249 | `/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. |
250251 | `/sessions` | Resume a previous session for this directory. |
251252 | `/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`. |
252253 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
@@ -389,9 +390,9 @@ zot's built-in provider catalog includes:
389390 - **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`).
390391 - **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.
391392 - **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`.
393394
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.
395396
396397 ## Models
397398
@@ -633,6 +634,69 @@ You can also add models to your `models.json` so you don't need flags every time
633634
634635 The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.).
635636
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+
636700 ## Inline images
637701
638702 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 ## Models397 ## 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 images700 ## 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
66
77 ## Login methods
88
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.
1010
1111 - `api key`: stores an API key in `$ZOT_HOME/auth.json` when the provider uses a normal key.
1212 - `subscription`: stores OAuth credentials for subscription-backed providers.
@@ -87,6 +87,58 @@ export OPENROUTER_API_KEY=...
8787 zot --provider openrouter
8888 ```
8989
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+
90142 ## Cloud providers
91143
92144 ### Amazon Bedrock
@@ -6,7 +6,7 @@ with `/model`, list them with `zot --list-models`, and add private models in
6 6
7 ## Login methods7 ## 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 openrouter87 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 providers142 ## Cloud providers
91 143
92 ### Amazon Bedrock144 ### Amazon Bedrock
M packages/agent/args.go +1 -1
@@ -382,7 +382,7 @@ func PrintHelp(version string) {
382382 row{"zot tg ...", "short alias for telegram-bot"},
383383 )
384384 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)"},
386386 row{"--model ID", "model id (see --list-models)"},
387387 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
388388 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 {
155155 return "deepseek-v4-pro"
156156 case "google":
157157 return "gemini-2.5-pro"
158- case "ollama":
158+ case "ollama", provider.LlamaCPPProviderID:
159159 return ""
160160 case "moonshotai", "moonshotai-cn":
161161 return "kimi-k2.6"
@@ -210,7 +210,7 @@ func defaultModelForProvider(prov string) string {
210210 // auto-fallback logic that picks any logged-in provider when the user's
211211 // preferred one has no credentials.
212212 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,
214214 "moonshotai", "moonshotai-cn",
215215 "cerebras", "groq", "xai", "together", "huggingface", "openrouter",
216216 "mistral", "zai",
@@ -271,6 +271,8 @@ var providerAliases = map[string]string{
271271 "cloudflare": "cloudflare-workers-ai",
272272 "workers-ai": "cloudflare-workers-ai",
273273 "hf": "huggingface",
274+ "llama": provider.LlamaCPPProviderID,
275+ "llamacpp": provider.LlamaCPPProviderID,
274276 }
275277
276278 // canonicalProvider normalises a user-supplied provider name: trims
@@ -379,10 +381,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
379381 // amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
380382 // IAM keys) when no config.json pins the provider, such as after
381383 // 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.
384387 for _, other := range knownProviders {
385- if other == provName || other == "ollama" {
388+ if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID {
386389 continue
387390 }
388391 if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
@@ -395,8 +398,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
395398
396399 model := firstNonEmpty(args.Model, cfg.Model)
397400 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)
400403 }
401404 model = defaultModelForProvider(provName)
402405 }
@@ -412,18 +415,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
412415 }
413416 }
414417 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.
419421 resolvedModel = provider.Model{
420- Provider: "ollama",
422+ Provider: provName,
421423 ID: model,
422424 DisplayName: model,
423- ContextWindow: 32768,
424- MaxOutput: 8192,
425+ ContextWindow: 128000,
426+ MaxOutput: 16384,
425427 BaseURL: args.BaseURL,
426- Source: "ollama",
428+ Source: provName,
427429 }
428430 err = nil
429431 }
@@ -512,6 +514,17 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
512514 if args.BaseURL == "" && provName == "ollama" {
513515 args.BaseURL = "http://localhost:11434"
514516 }
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+ }
515528
516529 // Insecure TLS is intentionally scoped to explicit custom endpoints.
517530 // Built-in provider base URLs, auth calls, and model discovery keep normal
@@ -743,6 +756,12 @@ func (r Resolved) NewClient() provider.Client {
743756 switch r.Provider {
744757 case "ollama":
745758 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))
746765 case "kimi":
747766 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
748767 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
@@ -1013,6 +1032,8 @@ func envVarName(provider string) string {
10131032 return "GEMINI"
10141033 case "ollama":
10151034 return "OLLAMA"
1035+ case "llama.cpp":
1036+ return "LLAMA"
10161037 case "moonshotai", "moonshotai-cn":
10171038 return "MOONSHOT"
10181039 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's210 // 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: trims278 // 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 after382 // IAM keys) when no config.json pins the provider, such as after
381 // pointing ZOT_HOME at a fresh home dir. Iteration order of383 // 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 continue389 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 the419+ // Local providers are intentionally open-catalogue: any model id the
417- // local server understands is valid, even if not in the420+ // 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 = nil430 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 normal530 // 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) {
237237 }
238238 }
239239
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+
240279 func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
241280 t.Setenv("ZOT_HOME", t.TempDir())
242281 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 {
10121012 Agent: ag,
10131013 InitialInput: args.Prompt,
10141014 AuthManager: mgr,
1015+ LlamaCPPConfig: ResolveLlamaCPPConfig,
1016+ RefreshLlamaCPPModels: RefreshLlamaCPPModels,
10151017 BuildAgent: buildAgent,
10161018 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
10171019 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 (
1212 "strings"
1313 "time"
1414
15+ providerpkg "github.com/patriceckhart/zot/packages/provider"
1516 "github.com/patriceckhart/zot/packages/provider/auth"
1617 )
1718
@@ -216,6 +217,29 @@ func SaveConfig(c Config) error {
216217 // AuthStoreFor returns the auth.Store backed by AuthPath().
217218 func AuthStoreFor() *auth.Store { return auth.NewStore(AuthPath()) }
218219
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+
219243 // ResolveCredential returns the credential (api key or oauth access
220244 // token), the method ("apikey"/"oauth"), and an error when no
221245 // credential is available.
@@ -283,6 +307,14 @@ func ResolveCredentialFull(provider, explicit string) (cred, method, accountID s
283307 if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
284308 return v, "apikey", "", nil
285309 }
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+ }
286318 case "moonshotai", "moonshotai-cn":
287319 // Moonshot direct API (separate from kimi-coding, which is the
288320 // 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 access243 // ResolveCredential returns the credential (api key or oauth access
220 // token), the method ("apikey"/"oauth"), and an error when no244 // 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", "", nil308 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 the319 // 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() {
114114 cfg.Model, m.Provider, cfg.Provider, fix)
115115 cfg.Model = fix
116116 changed = true
117- } else if cfg.Provider != "ollama" {
117+ } else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID {
118118 // Model id not in any catalog. Reset to provider's default.
119119 fix := defaultModelForProvider(cfg.Provider)
120120 fmt.Fprintf(os.Stderr,
@@ -141,6 +141,31 @@ func ValidateAndRepairConfig() {
141141 // the baked-in catalog if this fails.
142142 func RefreshModelsAsync() {
143143 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
144169 }
145170
146171 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 = fix115 cfg.Model = fix
116 changed = true116 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 @@
11 package agent
22
33 import (
4+ "context"
45 "encoding/json"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
59 "os"
610 "path/filepath"
711 "testing"
12+
13+ "github.com/patriceckhart/zot/packages/provider"
814 )
915
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+
1049 // TestValidateAndRepairConfig_MismatchedPair simulates the bug from a
1150 // stale /model switch: provider=anthropic but model=kimi-for-coding
1251 // (which belongs to provider=kimi). The validator should rewrite the
@@ -1,12 +1,51 @@
1 package agent1 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 a49 // TestValidateAndRepairConfig_MismatchedPair simulates the bug from a
11 // stale /model switch: provider=anthropic but model=kimi-for-coding50 // stale /model switch: provider=anthropic but model=kimi-for-coding
12 // (which belongs to provider=kimi). The validator should rewrite the51 // (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{
3636 // section it lives in. The width is computed from the longest label
3737 // across BOTH lists, with a minimum of 14 cells so changes to either
3838 // 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 {
4040 if width < 20 {
4141 width = 20
4242 }
@@ -50,6 +50,9 @@ func renderHelpBlock(th tui.Theme, width int) []string {
5050 // already, leaving its description mis-aligned).
5151 labelWidth := 14
5252 for _, c := range slashCatalog {
53+ if c.Name == "/llama" && !llamaConfigured {
54+ continue
55+ }
5356 if n := runewidth.StringWidth(c.Name); n > labelWidth {
5457 labelWidth = n
5558 }
@@ -74,6 +77,9 @@ func renderHelpBlock(th tui.Theme, width int) []string {
7477 // commands section
7578 out = append(out, tui.Bold("slash commands:"))
7679 for _, c := range slashCatalog {
80+ if c.Name == "/llama" && !llamaConfigured {
81+ continue
82+ }
7783 out = append(out, fmt.Sprintf(" %s %s",
7884 th.FG256(th.Accent, pad(c.Name)),
7985 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 label36 // 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 either37 // 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 = 2041 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 := 1451 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 = n57 labelWidth = n
55 }58 }
@@ -74,6 +77,9 @@ func renderHelpBlock(th tui.Theme, width int) []string {
74 // commands section77 // 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 {
114114 // Auth is required. When the user runs /login, Interactive talks to
115115 // AuthManager to open a browser and wait for the callback.
116116 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+
117126 // BuildAgent is called after a successful login to (re)construct the
118127 // agent with the fresh credential. It returns the new agent and
119128 // the concrete provider/model in use.
@@ -253,6 +262,8 @@ type ChangelogPayload struct {
253262 URL string
254263 }
255264
265+type modelRefreshResult struct{ err error }
266+
256267 // Interactive is the TUI chat loop.
257268 type chatCacheKey struct {
258269 cols int
@@ -340,6 +351,7 @@ type Interactive struct {
340351 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
341352 busy bool
342353 dirty chan struct{}
354+ modelRefresh chan modelRefreshResult
343355 cancelTurn context.CancelFunc
344356 scrollOffset int // rows from the bottom; 0 = pinned to latest
345357 prevScrollOffset int // last value redraw snapped against; tracks intent
@@ -391,6 +403,7 @@ type Interactive struct {
391403
392404 dialog *loginDialog
393405 modelDialog *modelDialog
406+ llamaDialog *llamaDialog
394407 rescueDialog *rescueDialog
395408 sessionDialog *sessionDialog
396409 swarmDialog *swarmDialog
@@ -407,6 +420,7 @@ type Interactive struct {
407420 sessionOpsDialog *sessionOpsDialog
408421 sessionTreeDialog *sessionTreeDialog
409422 extPanel *extPanelDialog
423+ llamaConfigured bool
410424
411425 // swarmWatch tracks auto-swarm sub-agents the main agent spawned
412426 // via swarm_spawn. Each entry holds the agent + the task text;
@@ -520,8 +534,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
520534 toolCalls: map[string]*tui.ToolCallView{},
521535 toolGate: map[string]int{},
522536 dirty: make(chan struct{}, 8),
537+ modelRefresh: make(chan modelRefreshResult, 1),
523538 dialog: newLoginDialog(),
524539 modelDialog: newModelDialog(),
540+ llamaDialog: newLlamaDialog(),
525541 rescueDialog: newRescueDialog(),
526542 sessionDialog: newSessionDialog(),
527543 swarmDialog: newSwarmDialog(),
@@ -543,6 +559,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
543559 }
544560 i.fileSuggest.SetRecursive(cfg.RecursiveFileSuggest != nil && *cfg.RecursiveFileSuggest)
545561 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+ }
546566 if cfg.Agent != nil {
547567 i.agent = cfg.Agent
548568 i.view.Messages = cfg.Agent.Messages()
@@ -768,6 +788,9 @@ func (i *Interactive) Run(ctx context.Context) error {
768788 case ev := <-authEvents:
769789 i.handleAuthEvent(ev)
770790 i.invalidate()
791+ case result := <-i.modelRefresh:
792+ i.openModelPickerAfterRefresh(result.err)
793+ i.invalidate()
771794 case info, ok := <-updates:
772795 if ok && info.Available {
773796 i.mu.Lock()
@@ -1102,6 +1125,8 @@ func (i *Interactive) redraw() {
11021125 dialog = i.dialog.Render(i.cfg.Theme, cols)
11031126 case i.modelDialog.Active():
11041127 dialog = i.modelDialog.Render(i.cfg.Theme, cols)
1128+ case i.llamaDialog.Active():
1129+ dialog = i.llamaDialog.Render(i.cfg.Theme, cols)
11051130 case i.rescueDialog.Active():
11061131 dialog = i.rescueDialog.Render(i.cfg.Theme, cols)
11071132 case i.sessionDialog.Active():
@@ -1150,6 +1175,7 @@ func (i *Interactive) redraw() {
11501175 // Feed extension-registered commands into the suggester first so
11511176 // they show up in tab-complete + the popup alongside the built-ins.
11521177 i.suggest.SetJailed(i.cfg.Sandbox.Locked())
1178+ i.suggest.SetLlamaConfigured(i.llamaConfigured)
11531179 if i.cfg.Extensions != nil {
11541180 catalog := i.cfg.Extensions.Commands()
11551181 extra := make([]slashCommand, 0, len(catalog))
@@ -1531,6 +1557,15 @@ func (i *Interactive) redraw() {
15311557 cursorCol = c
15321558 }
15331559 }
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+ }
15341569 if i.sessionDialog.Active() {
15351570 if r, c := i.sessionDialog.CursorPos(); r >= 0 {
15361571 cursorRow = dialogLead + r
@@ -1865,6 +1900,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
18651900 if act.SubmitCode != "" {
18661901 i.submitManualOAuthCode(act.SubmitCode)
18671902 }
1903+ if act.SaveLlama {
1904+ i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey)
1905+ }
18681906 return false
18691907 }
18701908 if i.modelDialog.Active() {
@@ -1887,6 +1925,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
18871925 }
18881926 return false
18891927 }
1928+ if i.llamaDialog.Active() {
1929+ i.llamaDialog.HandleKey(k)
1930+ i.invalidate()
1931+ return false
1932+ }
18901933 if i.rescueDialog.Active() {
18911934 if k.Kind == tui.KeyCtrlC {
18921935 i.rescueDialog.Close()
@@ -3857,7 +3900,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
38573900 i.mu.Unlock()
38583901 case "/help":
38593902 i.mu.Lock()
3860- i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols())
3903+ i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured)
38613904 i.statusErr = ""
38623905 i.statusOK = ""
38633906 // 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) {
38803923 case "/model":
38813924 if len(parts) >= 2 {
38823925 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+ }()
38833936 } 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()
38893956 }
38903957 case "/settings":
38913958 i.openSettingsDialog()
@@ -4040,6 +4107,22 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
40404107 return false
40414108 }
40424109
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+
40434126 // openLogoutDialog shows the provider picker for `/logout` with no
40444127 // argument. Only providers the user is currently logged into are
40454128 // listed, plus an "all" entry when more than one is present. If
@@ -4091,7 +4174,7 @@ func (i *Interactive) openLogoutDialog() {
40914174 items = append(items, logoutItem{label: providerLabel("openai-codex"), target: "openai-codex", method: "subscription"})
40924175 }
40934176 for p, c := range creds.AdditionalAPIKeyCreds {
4094- if c.APIKey != "" {
4177+ if c.APIKey != "" || c.BaseURL != "" {
40954178 items = append(items, logoutItem{label: providerLabel(p), target: p, method: "api key"})
40964179 }
40974180 }
@@ -4182,8 +4265,15 @@ func (i *Interactive) doLogout(target string) {
41824265 }
41834266 }
41844267
4268+ llamaConfigured := i.llamaConfigured
4269+ if i.cfg.LlamaCPPConfig != nil {
4270+ baseURL, _, err := i.cfg.LlamaCPPConfig()
4271+ llamaConfigured = err == nil && baseURL != ""
4272+ }
4273+
41854274 i.mu.Lock()
41864275 defer i.mu.Unlock()
4276+ i.llamaConfigured = llamaConfigured
41874277 if len(errs) > 0 {
41884278 i.statusErr = "logout errors: " + strings.Join(errs, "; ")
41894279 return
@@ -4268,6 +4358,23 @@ func providerSetupInfo(provider string) (string, []string, bool) {
42684358 }
42694359 }
42704360
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+
42714378 func (i *Interactive) startAPIKeyFlow(provider string) {
42724379 if title, lines, ok := providerSetupInfo(provider); ok {
42734380 i.dialog.ShowInfo(title, lines)
@@ -114,6 +114,15 @@ type InteractiveConfig struct {
114 // Auth is required. When the user runs /login, Interactive talks to114 // 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.Manager116 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 the126 // BuildAgent is called after a successful login to (re)construct the
118 // agent with the fresh credential. It returns the new agent and127 // 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 string262 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 int269 cols int
@@ -340,6 +351,7 @@ type Interactive struct {
340 lastCtxInput int // input_tokens of the most recent turn — approximates current context size351 lastCtxInput int // input_tokens of the most recent turn — approximates current context size
341 busy bool352 busy bool
342 dirty chan struct{}353 dirty chan struct{}
354+ modelRefresh chan modelRefreshResult
343 cancelTurn context.CancelFunc355 cancelTurn context.CancelFunc
344 scrollOffset int // rows from the bottom; 0 = pinned to latest356 scrollOffset int // rows from the bottom; 0 = pinned to latest
345 prevScrollOffset int // last value redraw snapped against; tracks intent357 prevScrollOffset int // last value redraw snapped against; tracks intent
@@ -391,6 +403,7 @@ type Interactive struct {
391 403
392 dialog *loginDialog404 dialog *loginDialog
393 modelDialog *modelDialog405 modelDialog *modelDialog
406+ llamaDialog *llamaDialog
394 rescueDialog *rescueDialog407 rescueDialog *rescueDialog
395 sessionDialog *sessionDialog408 sessionDialog *sessionDialog
396 swarmDialog *swarmDialog409 swarmDialog *swarmDialog
@@ -407,6 +420,7 @@ type Interactive struct {
407 sessionOpsDialog *sessionOpsDialog420 sessionOpsDialog *sessionOpsDialog
408 sessionTreeDialog *sessionTreeDialog421 sessionTreeDialog *sessionTreeDialog
409 extPanel *extPanelDialog422 extPanel *extPanelDialog
423+ llamaConfigured bool
410 424
411 // swarmWatch tracks auto-swarm sub-agents the main agent spawned425 // 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.Agent567 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 so1175 // 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 = c1557 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 + r1571 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 false1906 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 false1926 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 []string3937+ 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 false4107 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 no4126 // openLogoutDialog shows the provider picker for `/logout` with no
4044 // argument. Only providers the user is currently logged into are4127 // argument. Only providers the user is currently logged into are
4045 // listed, plus an "all" entry when more than one is present. If4128 // 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 return4279 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 (
44 "fmt"
55 "path/filepath"
66 "sort"
7+ "strings"
78
89 "github.com/patriceckhart/zot/packages/provider"
910 "github.com/patriceckhart/zot/packages/provider/auth"
@@ -20,6 +21,8 @@ const (
2021 loginStepClosed loginStep = iota
2122 loginStepMethod // pick apikey vs subscription
2223 loginStepProvider // pick anthropic vs openai vs kimi
24+ loginStepLlamaURL // enter and validate llama.cpp router URL
25+ loginStepLlamaKey // enter optional llama.cpp API key
2326 loginStepWaiting // browser open, waiting for callback
2427 loginStepPasteCode // user pastes the auth code here
2528 loginStepInfo // informational setup guidance
@@ -31,16 +34,19 @@ const loginProviderPageSize = 8
3134 // loginDialog is a tiny inline dialog rendered above the editor while
3235 // the user picks their login method and provider.
3336 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
4450
4551 // status is a snapshot of the current login state for each
4652 // provider, captured when Open() runs. Rendered above the
@@ -72,6 +78,9 @@ func (d *loginDialog) Open(zotHome string) {
7278 d.success = false
7379 d.url = ""
7480 d.cursor = 0
81+ d.providerQuery = ""
82+ d.llamaEd = nil
83+ d.llamaURL = ""
7584 d.infoTitle = ""
7685 d.infoLines = nil
7786 d.status = map[string]string{}
@@ -139,12 +148,19 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
139148 }
140149 lines = append(lines, frameRule(th, width))
141150 case loginStepProvider:
142- opts := providersForMethod(d.method)
151+ opts := d.providerOptions()
143152 lines = append(lines, frameHeader(th, "login - "+d.method, width))
144153 for _, l := range d.renderStatusLines(th) {
145154 lines = append(lines, l)
146155 }
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))
148164 start, end := d.providerPage(len(opts))
149165 for i := start; i < end; i++ {
150166 o := opts[i]
@@ -161,10 +177,34 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
161177 lines = append(lines, th.FG256(th.Muted, label)+th.FG256(th.Accent, tag))
162178 }
163179 }
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 {
165183 lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" (%d/%d)", d.cursor+1, len(opts))))
166184 }
167185 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))
168208 case loginStepWaiting:
169209 lines = append(lines, frameHeader(th, "login - "+d.method+" - "+providerLabel(d.provider), width))
170210 lines = append(lines, th.FG256(th.Muted, "open this URL in a browser:"))
@@ -276,6 +316,21 @@ func providersForMethod(method string) []string {
276316 // providerLabel returns the user-facing label for a provider id.
277317 func providerLabel(id string) string { return provider.ProviderLabel(id) }
278318
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+
279334 func providerPickerTag(method, status string) string {
280335 switch method {
281336 case "apikey":
@@ -377,7 +432,10 @@ type loginDialogAction struct {
377432 StartAPIKey bool
378433 StartOAuth bool
379434 StartManual bool
435+ SaveLlama bool
380436 Provider string
437+ LlamaURL string
438+ LlamaAPIKey string
381439 Close bool
382440 SubmitCode string
383441 }
@@ -389,6 +447,8 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
389447 return d.handleMethodKey(k)
390448 case loginStepProvider:
391449 return d.handleProviderKey(k)
450+ case loginStepLlamaURL, loginStepLlamaKey:
451+ return d.handleLlamaKey(k)
392452 case loginStepWaiting:
393453 return d.handleWaitingKey(k)
394454 case loginStepPasteCode:
@@ -425,6 +485,7 @@ func (d *loginDialog) handleMethodKey(k tui.Key) loginDialogAction {
425485 }
426486 d.step = loginStepProvider
427487 d.cursor = 0
488+ d.providerQuery = ""
428489 }
429490 return loginDialogAction{}
430491 }
@@ -436,7 +497,7 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
436497 d.cursor--
437498 }
438499 case tui.KeyDown:
439- providers := providersForMethod(d.method)
500+ providers := d.providerOptions()
440501 if d.cursor < len(providers)-1 {
441502 d.cursor++
442503 }
@@ -446,20 +507,39 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
446507 d.cursor = 0
447508 }
448509 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
453527 }
454528 case tui.KeyEsc:
455529 d.Close()
456530 return loginDialogAction{Close: true}
457531 case tui.KeyEnter:
458- providers := providersForMethod(d.method)
532+ providers := d.providerOptions()
459533 if d.cursor < 0 || d.cursor >= len(providers) {
460534 return loginDialogAction{}
461535 }
462536 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+ }
463543 d.step = loginStepWaiting
464544 if d.method == "apikey" {
465545 return loginDialogAction{StartAPIKey: true, Provider: d.provider}
@@ -469,6 +549,37 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
469549 return loginDialogAction{}
470550 }
471551
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+
472583 // ShowWaiting transitions to the waiting state with the given URL.
473584 // No-op if the user has already dismissed the dialog.
474585 func (d *loginDialog) ShowWaiting(url string) {
@@ -534,10 +645,21 @@ func (d *loginDialog) handlePasteCodeKey(k tui.Key) loginDialogAction {
534645 }
535646
536647 // 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.
540651 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+ }
541663 if d.codeEd == nil {
542664 return -1, -1
543665 }
@@ -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 = iota21 loginStepClosed loginStep = iota
21 loginStepMethod // pick apikey vs subscription22 loginStepMethod // pick apikey vs subscription
22 loginStepProvider // pick anthropic vs openai vs kimi23 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 callback26 loginStepWaiting // browser open, waiting for callback
24 loginStepPasteCode // user pastes the auth code here27 loginStepPasteCode // user pastes the auth code here
25 loginStepInfo // informational setup guidance28 loginStepInfo // informational setup guidance
@@ -31,16 +34,19 @@ const loginProviderPageSize = 8
31 // loginDialog is a tiny inline dialog rendered above the editor while34 // 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 loginStep37+ 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 string40+ message string
38- success bool41+ success bool
39- url string42+ url string
40- cursor int43+ cursor int
41- codeEd *tui.Editor44+ providerQuery string
42- infoTitle string45+ codeEd *tui.Editor
43- infoLines []string46+ 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 each51 // status is a snapshot of the current login state for each
46 // provider, captured when Open() runs. Rendered above the52 // provider, captured when Open() runs. Rendered above the
@@ -72,6 +78,9 @@ func (d *loginDialog) Open(zotHome string) {
72 d.success = false78 d.success = false
73 d.url = ""79 d.url = ""
74 d.cursor = 080 d.cursor = 0
81+ d.providerQuery = ""
82+ d.llamaEd = nil
83+ d.llamaURL = ""
75 d.infoTitle = ""84 d.infoTitle = ""
76 d.infoLines = nil85 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 bool432 StartAPIKey bool
378 StartOAuth bool433 StartOAuth bool
379 StartManual bool434 StartManual bool
435+ SaveLlama bool
380 Provider string436 Provider string
437+ LlamaURL string
438+ LlamaAPIKey string
381 Close bool439 Close bool
382 SubmitCode string440 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 = loginStepProvider486 d.step = loginStepProvider
427 d.cursor = 0487 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 = 0507 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 += loginProviderPageSize511+ if len(providers) > 0 {
451- if d.cursor >= len(providers) {512+ d.cursor += loginProviderPageSize
452- d.cursor = len(providers) - 1513+ 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 = loginStepWaiting543 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 the647 // CursorPos returns the absolute row/col inside the dialog where the
537-// terminal cursor should sit (paste-code step). Returns -1, -1 if the648+// 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 to649+// -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, -1664 return -1, -1
543 }665 }
M packages/agent/modes/login_dialog_test.go +62 -0
@@ -7,6 +7,68 @@ import (
77 "github.com/patriceckhart/zot/packages/tui"
88 )
99
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+
1072 func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) {
1173 d := newLoginDialog()
1274 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 {
9090 for i, it := range d.items {
9191 plain := " " + it.label
9292 if it.method != "" {
93- plain += " " + th.FG256(th.Muted, "("+it.method+")")
93+ plain += " (" + it.method + ")"
9494 }
9595 if i == d.cursor {
9696 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.label91 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 {
2525 // the streaming response without trouble.
2626 func slashCancelsTurn(head string) bool {
2727 switch head {
28- case "/clear", "/compact", "/logout", "/login", "/model", "/reload-ext", "/cd":
28+ case "/clear", "/compact", "/logout", "/login", "/model", "/llama", "/reload-ext", "/cd":
2929 return true
3030 }
3131 return false
@@ -38,6 +38,7 @@ var slashCatalog = []slashCommand{
3838 {Name: "/login", Desc: "log in via api key or subscription"},
3939 {Name: "/logout", Desc: "clear a provider's credentials"},
4040 {Name: "/model", Desc: "pick a model (or /model <id>)"},
41+ {Name: "/llama", Desc: "manage llama.cpp router models"},
4142 {Name: "/sessions", Desc: "resume a previous session for this directory"},
4243 {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"},
4344 {Name: "/jump", Desc: "scroll the chat to a previous turn (or /jump <text>)"},
@@ -62,9 +63,9 @@ const slashSuggestPageSize = 8
6263 type slashSuggester struct {
6364 cursor int
6465
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
6869
6970 // extra are commands contributed by extensions, refreshed each
7071 // frame from the extension manager. Empty when no extensions
@@ -93,6 +94,10 @@ func (s *slashSuggester) SetExtra(cmds []slashCommand) {
9394 // so state-dependent commands can appear/disappear immediately.
9495 func (s *slashSuggester) SetJailed(jailed bool) { s.jailed = jailed }
9596
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+
96101 // allCatalog returns slashCatalog plus the current extra commands
97102 // (extension-registered) with a header divider between the two
98103 // groups. Extra entries are only kept if they don't collide with
@@ -133,7 +138,7 @@ func (s *slashSuggester) baseCatalog() []slashCommand {
133138 }
134139 out := make([]slashCommand, 0, len(slashCatalog)-1)
135140 for _, c := range slashCatalog {
136- if c.Name == hide {
141+ if c.Name == hide || (c.Name == "/llama" && !s.llamaConfigured) {
137142 continue
138143 }
139144 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 true29 return true
30 }30 }
31 return false31 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 int64 cursor int
64 65
65- // jailed tracks whether the sandbox is currently locked. It is used66+ // jailed and llamaConfigured drive state-dependent command visibility.
66- // to hide state-dependent commands from the autocomplete popup.67+ jailed bool
67- jailed bool68+ llamaConfigured bool
68 69
69 // extra are commands contributed by extensions, refreshed each70 // extra are commands contributed by extensions, refreshed each
70 // frame from the extension manager. Empty when no extensions71 // 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 commands101 // allCatalog returns slashCatalog plus the current extra commands
97 // (extension-registered) with a header divider between the two102 // (extension-registered) with a header divider between the two
98 // groups. Extra entries are only kept if they don't collide with103 // 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 continue142 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) {
2121 }
2222 }
2323
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+
2435 func TestSlashSuggesterHasSwarm(t *testing.T) {
2536 s := newSlashSuggester()
2637 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 {
136136 // APIKeyProviders is the ordered list shown by /login -> api key.
137137 func APIKeyProviders() []string {
138138 return []string{
139- "anthropic", "openai", "kimi", "deepseek", "google",
139+ "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp",
140140 "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",
141141 "huggingface", "openrouter", "mistral", "zai",
142142 "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 {
2929 // use either APIKey or OAuth; OpenAI may store both so the public API
3030 // route and ChatGPT/Codex subscription route can coexist.
3131 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"`
3435 }
3536
3637 // OAuthToken is an OAuth 2 token set with refresh support.
@@ -62,7 +63,7 @@ func (t *OAuthToken) Expired() bool {
6263 // Has reports whether at least one credential is present for provider.
6364 func (c *Credentials) Has(provider string) bool {
6465 p := c.get(provider)
65- return p != nil && (p.APIKey != "" || p.OAuth != nil)
66+ return p != nil && (p.APIKey != "" || p.BaseURL != "" || p.OAuth != nil)
6667 }
6768
6869 // Method returns "apikey", "oauth", or "" for the given provider.
@@ -71,7 +72,7 @@ func (c *Credentials) Method(provider string) string {
7172 if p == nil {
7273 return ""
7374 }
74- if p.APIKey != "" {
75+ if p.APIKey != "" || p.BaseURL != "" {
7576 return "apikey"
7677 }
7778 if p.OAuth != nil {
@@ -107,7 +108,7 @@ func (c *Credentials) setAdditional(provider string, p ProviderCreds) {
107108 if c.AdditionalAPIKeyCreds == nil {
108109 c.AdditionalAPIKeyCreds = map[string]ProviderCreds{}
109110 }
110- if p.APIKey == "" && p.OAuth == nil {
111+ if p.APIKey == "" && p.BaseURL == "" && p.OAuth == nil {
111112 delete(c.AdditionalAPIKeyCreds, provider)
112113 if len(c.AdditionalAPIKeyCreds) == 0 {
113114 c.AdditionalAPIKeyCreds = nil
@@ -175,6 +176,32 @@ func (s *Store) SetAPIKey(provider, key string) error {
175176 return s.saveLocked(c)
176177 }
177178
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+
178205 // SetOAuth replaces the OAuth token for provider and saves to disk.
179206 func (s *Store) SetOAuth(provider string, tok OAuthToken) error {
180207 s.mu.Lock()
@@ -29,8 +29,9 @@ type Credentials struct {
29 // use either APIKey or OAuth; OpenAI may store both so the public API29 // 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 = nil114 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 (
55 "testing"
66 )
77
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+
829 func TestStoreAdditionalAPIKeyClear(t *testing.T) {
930 store := NewStore(filepath.Join(t.TempDir(), "auth.json"))
1031 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 {
7373 return "Cloudflare AI Gateway"
7474 case "ollama":
7575 return "Ollama"
76+ case LlamaCPPProviderID:
77+ return "llama.cpp"
7678 }
7779 return titleProviderID(id)
7880 }
@@ -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
393393 // models loaded via SetLiveModels.
394394
395395 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
399400 )
400401
401402 // SetLiveModels replaces the "live" overlay used by the active catalog.
@@ -431,9 +432,34 @@ func Active() []Model {
431432 }
432433 out := make([]Model, len(src))
433434 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+ }
434451 return out
435452 }
436453
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+
437463 // FindModel returns a Model by id, optionally constrained by provider.
438464 // If provider is empty, the first matching id is returned. Looks up
439465 // 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.RWMutex396+ activeMu sync.RWMutex
397- active []Model // live overlay merged in via SetLiveModels; nil = none yet397+ 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 out451 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 up464 // 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 {
2525 Prompt string
2626 MaxWidth int
2727
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+
2832 // lastRenderWidth is the column count passed to the most recent
2933 // Render() call. Up/Down key handling needs this to walk the
3034 // 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) {
766770 indent := strings.Repeat(" ", promptLen)
767771
768772 for r, line := range e.Lines {
773+ renderedLine := line
774+ if e.Mask {
775+ renderedLine = strings.Repeat("*", len([]rune(line)))
776+ }
769777 var prefix string
770778 if r == 0 {
771779 prefix = plainPrompt
772780 } else {
773781 prefix = indent
774782 }
775- wrapped := wrapLine(prefix+line, width, indent)
783+ wrapped := wrapLine(prefix+renderedLine, width, indent)
776784 if r == e.CursorR {
777785 // Compute where CursorC lands inside the wrapped rows by
778786 // walking the wrapped output character-by-character and
@@ -781,7 +789,7 @@ func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
781789 // answer under word-wrap, where a simple (promptLen+col)/width
782790 // formula overshoots when wrapLine broke on a space.
783791 targetRunes := e.CursorC
784- row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
792+ row, col := locateCursor(wrapped, prefix, renderedLine, targetRunes, indent)
785793 visualRow = len(lines) + row
786794 visualCol = col
787795 }
@@ -25,6 +25,10 @@ type Editor struct {
25 Prompt string25 Prompt string
26 MaxWidth int26 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 recent32 // lastRenderWidth is the column count passed to the most recent
29 // Render() call. Up/Down key handling needs this to walk the33 // Render() call. Up/Down key handling needs this to walk the
30 // same visual layout the user sees: a logical line that wraps to34 // 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 string777 var prefix string
770 if r == 0 {778 if r == 0 {
771 prefix = plainPrompt779 prefix = plainPrompt
772 } else {780 } else {
773 prefix = indent781 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 by785 // Compute where CursorC lands inside the wrapped rows by
778 // walking the wrapped output character-by-character and786 // 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)/width789 // 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.CursorC791 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) + row793 visualRow = len(lines) + row
786 visualCol = col794 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close