feat: discover models from registered LM Studio servers
- commit
- 64117f3697d0e326047a4e4f861235b53cd124c4
- author
- patriceckhart · · 2026-09-15 18:01 UTC
- parent
- b0db349
- signature
- unsigned
- refs
- tag: v0.3.75
18 files · +723 -25
M README.md +39 -0
| @@ -752,6 +752,45 @@ You can also add models to your `models.json` so you don't need flags every time | |||
| 752 | 752 | ||
| 753 | 753 | The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.). | |
| 754 | 754 | ||
| 755 | + | ### Local models with LM Studio | |
| 756 | + | ||
| 757 | + | Register an LM Studio server through `/login` -> API key -> `lmstudio`. | |
| 758 | + | Enter its server URL (for example `http://localhost:1234`) and an optional | |
| 759 | + | API key. The URL is required and is stored in `auth.json`. Zot does not | |
| 760 | + | probe localhost or discover LM Studio models until a server URL is registered. | |
| 761 | + | An API key alone does not enable discovery. | |
| 762 | + | ||
| 763 | + | After registration, zot refreshes the model list in the background on startup, | |
| 764 | + | after saving the connection, and when opening `/model`. Selecting or resuming | |
| 765 | + | an LM Studio model also refreshes its metadata. Choose a discovered model in | |
| 766 | + | `/model`, or use `zot --provider lmstudio --model <id>` after registration. | |
| 767 | + | Registration never changes your selected model or provider. | |
| 768 | + | ||
| 769 | + | Discovery prefers `/api/v1/models` and falls back to `/v1/models` if the native | |
| 770 | + | endpoint returns 404, 405, or 501. Native discovery excludes embedding models | |
| 771 | + | and uses loaded instance IDs and their configured context limits when present. | |
| 772 | + | Unloaded models use their model keys and advertised maximum context limits. | |
| 773 | + | The fallback only provides IDs, so embedding filtering there is best-effort. | |
| 774 | + | Quantization-specific keys are preserved, but zot does not expand the native | |
| 775 | + | API's separate variant list into additional model choices. | |
| 776 | + | ||
| 777 | + | Missing context metadata defaults to 32,768 tokens. Output defaults to at most | |
| 778 | + | 4,096 tokens (or a quarter of the context window for smaller models). | |
| 779 | + | These are estimates, not server guarantees. Discovered models have zero API | |
| 780 | + | pricing, and reasoning controls are not enabled automatically from capability | |
| 781 | + | metadata. Token usage still comes from inference responses. Override metadata | |
| 782 | + | through `models.json` under `lmstudio` when needed, including the context limit | |
| 783 | + | for models that LM Studio will load with a smaller context than advertised. | |
| 784 | + | User overrides take precedence. Discovery does not change metadata for other | |
| 785 | + | providers or the coding-agent workflow. | |
| 786 | + | ||
| 787 | + | Listing models does not load or download them. Inference with an unloaded | |
| 788 | + | model depends on LM Studio's auto-loading settings. The discovered catalog is | |
| 789 | + | in-memory only, and a failed refresh retains the previous snapshot in that | |
| 790 | + | process. `/model` reports discovery failures without blocking other providers. | |
| 791 | + | Use `/logout lmstudio` to remove the connection and stop discovery. | |
| 792 | + | Background refresh never executes API-key commands. | |
| 793 | + | ||
| 755 | 794 | ### Local models with llama.cpp router mode | |
| 756 | 795 | ||
| 757 | 796 | 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. | |
| @@ -752,6 +752,45 @@ You can also add models to your `models.json` so you don't need flags every time | |||||
| 752 | 752 | ||||
| 753 | The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.). | 753 | The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.). | ||
| 754 | 754 | ||||
| 755 | + | ### Local models with LM Studio | |||
| 756 | + | ||||
| 757 | + | Register an LM Studio server through `/login` -> API key -> `lmstudio`. | |||
| 758 | + | Enter its server URL (for example `http://localhost:1234`) and an optional | |||
| 759 | + | API key. The URL is required and is stored in `auth.json`. Zot does not | |||
| 760 | + | probe localhost or discover LM Studio models until a server URL is registered. | |||
| 761 | + | An API key alone does not enable discovery. | |||
| 762 | + | ||||
| 763 | + | After registration, zot refreshes the model list in the background on startup, | |||
| 764 | + | after saving the connection, and when opening `/model`. Selecting or resuming | |||
| 765 | + | an LM Studio model also refreshes its metadata. Choose a discovered model in | |||
| 766 | + | `/model`, or use `zot --provider lmstudio --model <id>` after registration. | |||
| 767 | + | Registration never changes your selected model or provider. | |||
| 768 | + | ||||
| 769 | + | Discovery prefers `/api/v1/models` and falls back to `/v1/models` if the native | |||
| 770 | + | endpoint returns 404, 405, or 501. Native discovery excludes embedding models | |||
| 771 | + | and uses loaded instance IDs and their configured context limits when present. | |||
| 772 | + | Unloaded models use their model keys and advertised maximum context limits. | |||
| 773 | + | The fallback only provides IDs, so embedding filtering there is best-effort. | |||
| 774 | + | Quantization-specific keys are preserved, but zot does not expand the native | |||
| 775 | + | API's separate variant list into additional model choices. | |||
| 776 | + | ||||
| 777 | + | Missing context metadata defaults to 32,768 tokens. Output defaults to at most | |||
| 778 | + | 4,096 tokens (or a quarter of the context window for smaller models). | |||
| 779 | + | These are estimates, not server guarantees. Discovered models have zero API | |||
| 780 | + | pricing, and reasoning controls are not enabled automatically from capability | |||
| 781 | + | metadata. Token usage still comes from inference responses. Override metadata | |||
| 782 | + | through `models.json` under `lmstudio` when needed, including the context limit | |||
| 783 | + | for models that LM Studio will load with a smaller context than advertised. | |||
| 784 | + | User overrides take precedence. Discovery does not change metadata for other | |||
| 785 | + | providers or the coding-agent workflow. | |||
| 786 | + | ||||
| 787 | + | Listing models does not load or download them. Inference with an unloaded | |||
| 788 | + | model depends on LM Studio's auto-loading settings. The discovered catalog is | |||
| 789 | + | in-memory only, and a failed refresh retains the previous snapshot in that | |||
| 790 | + | process. `/model` reports discovery failures without blocking other providers. | |||
| 791 | + | Use `/logout lmstudio` to remove the connection and stop discovery. | |||
| 792 | + | Background refresh never executes API-key commands. | |||
| 793 | + | ||||
| 755 | ### Local models with llama.cpp router mode | 794 | ### Local models with llama.cpp router mode | ||
| 756 | 795 | ||||
| 757 | 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. | 796 | 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. | ||
M docs/providers.md +52 -0
| @@ -208,6 +208,58 @@ export OPENROUTER_API_KEY=... | |||
| 208 | 208 | zot --provider openrouter | |
| 209 | 209 | ``` | |
| 210 | 210 | ||
| 211 | + | ## Local LM Studio server | |
| 212 | + | ||
| 213 | + | Register the `lmstudio` provider through `/login`, choose `api key`, and select | |
| 214 | + | `LM Studio`. Enter the server URL, for example `http://localhost:1234`, followed | |
| 215 | + | by an optional API key. Zot validates the URL and stores the connection in | |
| 216 | + | `$ZOT_HOME/auth.json`. Saving it does not require the server to be online. | |
| 217 | + | ||
| 218 | + | A registered server URL is required for discovery. Zot does not probe localhost, | |
| 219 | + | and an API key alone does not enable discovery. Unlike llama.cpp's | |
| 220 | + | `LLAMA_BASE_URL`, `LMSTUDIO_BASE_URL` is not a registration mechanism. Your | |
| 221 | + | current provider and selected model do not change when registering a server. | |
| 222 | + | ||
| 223 | + | Open `/model` to refresh and select a discovered model. Zot also refreshes after | |
| 224 | + | saving the connection, in the background on startup, and when selecting or | |
| 225 | + | resuming an LM Studio model. After registration, you can select a model directly: | |
| 226 | + | ||
| 227 | + | ```sh | |
| 228 | + | zot --provider lmstudio --model my-local-model | |
| 229 | + | ``` | |
| 230 | + | ||
| 231 | + | Discovery prefers `/api/v1/models` and falls back to `/v1/models` when the native | |
| 232 | + | endpoint returns 404, 405, or 501. Authentication errors do not trigger fallback. | |
| 233 | + | The native list excludes entries explicitly marked as embedding models. The | |
| 234 | + | fallback only supplies IDs, so embedding filtering is best-effort. | |
| 235 | + | ||
| 236 | + | Loaded models use their instance IDs and configured context lengths. Unloaded | |
| 237 | + | models use their keys and advertised maximum context lengths. Quantization-specific | |
| 238 | + | keys are preserved, but the separate native variant list is not expanded into | |
| 239 | + | additional choices. Listing models does not load or download them. Inference | |
| 240 | + | with an unloaded model depends on LM Studio's auto-loading settings. There is | |
| 241 | + | no `/llama`-style management interface for LM Studio. | |
| 242 | + | ||
| 243 | + | Missing context metadata defaults to 32,768 tokens. Output defaults to at most | |
| 244 | + | 4,096 tokens, or a quarter of the context window for smaller models. These are | |
| 245 | + | estimates, not server guarantees. Discovered models have zero API pricing, and | |
| 246 | + | token usage comes from inference responses. Reasoning controls are not enabled | |
| 247 | + | automatically from capability metadata, which does not mean server-side reasoning | |
| 248 | + | is disabled. Override metadata under `lmstudio` in `$ZOT_HOME/models.json`, | |
| 249 | + | especially when an unloaded model will use a smaller context than advertised. | |
| 250 | + | User entries take precedence over discovery. | |
| 251 | + | ||
| 252 | + | The discovered catalog stays in memory, separate from the six-hour cloud model | |
| 253 | + | cache and llama.cpp's entries. Failed refreshes retain the previous snapshot, | |
| 254 | + | and `/model` reports discovery errors while keeping other providers accessible. | |
| 255 | + | Use `/logout lmstudio` to remove the connection and stop discovery. Background | |
| 256 | + | refresh never executes API-key commands. | |
| 257 | + | ||
| 258 | + | The existing coding loop, tools, permissions, and built-in provider pricing | |
| 259 | + | remain unchanged. For older builds without `LM Studio` in `/login`, the manual | |
| 260 | + | OpenAI-compatible setup in [README.md](../README.md#local-models-with-ollama) | |
| 261 | + | remains available. | |
| 262 | + | ||
| 211 | 263 | ## Local llama.cpp router | |
| 212 | 264 | ||
| 213 | 265 | 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. | |
| @@ -208,6 +208,58 @@ export OPENROUTER_API_KEY=... | |||||
| 208 | zot --provider openrouter | 208 | zot --provider openrouter | ||
| 209 | ``` | 209 | ``` | ||
| 210 | 210 | ||||
| 211 | + | ## Local LM Studio server | |||
| 212 | + | ||||
| 213 | + | Register the `lmstudio` provider through `/login`, choose `api key`, and select | |||
| 214 | + | `LM Studio`. Enter the server URL, for example `http://localhost:1234`, followed | |||
| 215 | + | by an optional API key. Zot validates the URL and stores the connection in | |||
| 216 | + | `$ZOT_HOME/auth.json`. Saving it does not require the server to be online. | |||
| 217 | + | ||||
| 218 | + | A registered server URL is required for discovery. Zot does not probe localhost, | |||
| 219 | + | and an API key alone does not enable discovery. Unlike llama.cpp's | |||
| 220 | + | `LLAMA_BASE_URL`, `LMSTUDIO_BASE_URL` is not a registration mechanism. Your | |||
| 221 | + | current provider and selected model do not change when registering a server. | |||
| 222 | + | ||||
| 223 | + | Open `/model` to refresh and select a discovered model. Zot also refreshes after | |||
| 224 | + | saving the connection, in the background on startup, and when selecting or | |||
| 225 | + | resuming an LM Studio model. After registration, you can select a model directly: | |||
| 226 | + | ||||
| 227 | + | ```sh | |||
| 228 | + | zot --provider lmstudio --model my-local-model | |||
| 229 | + | ``` | |||
| 230 | + | ||||
| 231 | + | Discovery prefers `/api/v1/models` and falls back to `/v1/models` when the native | |||
| 232 | + | endpoint returns 404, 405, or 501. Authentication errors do not trigger fallback. | |||
| 233 | + | The native list excludes entries explicitly marked as embedding models. The | |||
| 234 | + | fallback only supplies IDs, so embedding filtering is best-effort. | |||
| 235 | + | ||||
| 236 | + | Loaded models use their instance IDs and configured context lengths. Unloaded | |||
| 237 | + | models use their keys and advertised maximum context lengths. Quantization-specific | |||
| 238 | + | keys are preserved, but the separate native variant list is not expanded into | |||
| 239 | + | additional choices. Listing models does not load or download them. Inference | |||
| 240 | + | with an unloaded model depends on LM Studio's auto-loading settings. There is | |||
| 241 | + | no `/llama`-style management interface for LM Studio. | |||
| 242 | + | ||||
| 243 | + | Missing context metadata defaults to 32,768 tokens. Output defaults to at most | |||
| 244 | + | 4,096 tokens, or a quarter of the context window for smaller models. These are | |||
| 245 | + | estimates, not server guarantees. Discovered models have zero API pricing, and | |||
| 246 | + | token usage comes from inference responses. Reasoning controls are not enabled | |||
| 247 | + | automatically from capability metadata, which does not mean server-side reasoning | |||
| 248 | + | is disabled. Override metadata under `lmstudio` in `$ZOT_HOME/models.json`, | |||
| 249 | + | especially when an unloaded model will use a smaller context than advertised. | |||
| 250 | + | User entries take precedence over discovery. | |||
| 251 | + | ||||
| 252 | + | The discovered catalog stays in memory, separate from the six-hour cloud model | |||
| 253 | + | cache and llama.cpp's entries. Failed refreshes retain the previous snapshot, | |||
| 254 | + | and `/model` reports discovery errors while keeping other providers accessible. | |||
| 255 | + | Use `/logout lmstudio` to remove the connection and stop discovery. Background | |||
| 256 | + | refresh never executes API-key commands. | |||
| 257 | + | ||||
| 258 | + | The existing coding loop, tools, permissions, and built-in provider pricing | |||
| 259 | + | remain unchanged. For older builds without `LM Studio` in `/login`, the manual | |||
| 260 | + | OpenAI-compatible setup in [README.md](../README.md#local-models-with-ollama) | |||
| 261 | + | remains available. | |||
| 262 | + | ||||
| 211 | ## Local llama.cpp router | 263 | ## Local llama.cpp router | ||
| 212 | 264 | ||||
| 213 | 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. | 265 | 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. | ||
M packages/agent/build.go +28 -7
| @@ -174,7 +174,7 @@ func defaultModelForProvider(prov string) string { | |||
| 174 | 174 | return "deepseek-v4-pro" | |
| 175 | 175 | case "google": | |
| 176 | 176 | return "gemini-2.5-pro" | |
| 177 | - | case "ollama", provider.LlamaCPPProviderID: | |
| 177 | + | case "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID: | |
| 178 | 178 | return "" | |
| 179 | 179 | case "moonshotai", "moonshotai-cn": | |
| 180 | 180 | return "kimi-k2.6" | |
| @@ -231,7 +231,7 @@ func defaultModelForProvider(prov string) string { | |||
| 231 | 231 | // auto-fallback logic that picks any logged-in provider when the user's | |
| 232 | 232 | // preferred one has no credentials. | |
| 233 | 233 | var knownProviders = []string{ | |
| 234 | - | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, | |
| 234 | + | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID, | |
| 235 | 235 | "moonshotai", "moonshotai-cn", | |
| 236 | 236 | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola", | |
| 237 | 237 | "mistral", "zai", | |
| @@ -410,7 +410,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 410 | 410 | // default model are skipped because selecting either one here would | |
| 411 | 411 | // fail before the user can choose a model. | |
| 412 | 412 | for _, other := range knownProviders { | |
| 413 | - | if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID { | |
| 413 | + | if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID || other == provider.LMStudioProviderID { | |
| 414 | 414 | continue | |
| 415 | 415 | } | |
| 416 | 416 | if !CredentialAvailable(other) { | |
| @@ -425,7 +425,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 425 | 425 | ||
| 426 | 426 | model := firstNonEmpty(args.Model, cfg.Model) | |
| 427 | 427 | if model == "" { | |
| 428 | - | if provName == "ollama" || provName == provider.LlamaCPPProviderID { | |
| 428 | + | if provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID { | |
| 429 | 429 | return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName) | |
| 430 | 430 | } | |
| 431 | 431 | model = defaultModelForProvider(provName) | |
| @@ -441,8 +441,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 441 | 441 | model = defaultModelForProvider(provName) | |
| 442 | 442 | } | |
| 443 | 443 | } | |
| 444 | + | if provName == provider.LMStudioProviderID && LMStudioConfigured() { | |
| 445 | + | // Restore transient metadata when resuming a local model after restart. | |
| 446 | + | _ = RefreshLMStudioModels(context.Background()) | |
| 447 | + | } | |
| 444 | 448 | resolvedModel, err := provider.FindModel(provName, model) | |
| 445 | - | if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) { | |
| 449 | + | if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID) { | |
| 446 | 450 | // Local providers are intentionally open-catalogue: any model id the | |
| 447 | 451 | // configured server understands is valid, even if it is not cached. | |
| 448 | 452 | resolvedModel = provider.Model{ | |
| @@ -454,6 +458,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 454 | 458 | BaseURL: args.BaseURL, | |
| 455 | 459 | Source: provName, | |
| 456 | 460 | } | |
| 461 | + | if provName == provider.LMStudioProviderID { | |
| 462 | + | resolvedModel.ContextWindow = 32768 | |
| 463 | + | resolvedModel.MaxOutput = 4096 | |
| 464 | + | } | |
| 457 | 465 | err = nil | |
| 458 | 466 | } | |
| 459 | 467 | // Custom providers are open-catalogue like ollama: any model id the | |
| @@ -535,12 +543,25 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 535 | 543 | // user didn't pass --base-url, use the model's URL. For ollama, | |
| 536 | 544 | // keep http://localhost:11434 as a fallback only after the model | |
| 537 | 545 | // metadata has had a chance to provide a custom baseUrl. | |
| 538 | - | if args.BaseURL == "" && resolvedModel.BaseURL != "" { | |
| 546 | + | if args.BaseURL == "" && resolvedModel.BaseURL != "" && (provName != provider.LMStudioProviderID || resolvedModel.Source == "user") { | |
| 547 | + | // LM Studio discovery can retain an older snapshot on failure. | |
| 548 | + | // Use its currently registered endpoint below, never a stale URL | |
| 549 | + | // with the credentials for a newly registered server. | |
| 539 | 550 | args.BaseURL = resolvedModel.BaseURL | |
| 540 | 551 | } | |
| 541 | 552 | if args.BaseURL == "" && provName == "ollama" { | |
| 542 | 553 | args.BaseURL = "http://localhost:11434" | |
| 543 | 554 | } | |
| 555 | + | if args.BaseURL == "" && provName == provider.LMStudioProviderID { | |
| 556 | + | root, _, configErr := resolveLMStudioConfig(context.Background(), apiKeyCommandExecute) | |
| 557 | + | if configErr != nil { | |
| 558 | + | return Resolved{}, configErr | |
| 559 | + | } | |
| 560 | + | if root == "" { | |
| 561 | + | return Resolved{}, fmt.Errorf("LM Studio requires a server URL registered through /login or an explicit --base-url") | |
| 562 | + | } | |
| 563 | + | args.BaseURL = root + "/v1" | |
| 564 | + | } | |
| 544 | 565 | if args.BaseURL == "" && provName == provider.LlamaCPPProviderID { | |
| 545 | 566 | managementURL, _, configErr := ResolveLlamaCPPConfig() | |
| 546 | 567 | if configErr != nil { | |
| @@ -814,7 +835,7 @@ func (r Resolved) NewClient() provider.Client { | |||
| 814 | 835 | switch r.Provider { | |
| 815 | 836 | case "ollama": | |
| 816 | 837 | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | |
| 817 | - | case provider.LlamaCPPProviderID: | |
| 838 | + | case provider.LlamaCPPProviderID, provider.LMStudioProviderID: | |
| 818 | 839 | credential := r.Credential | |
| 819 | 840 | if credential == "local" { | |
| 820 | 841 | credential = "" | |
| @@ -174,7 +174,7 @@ func defaultModelForProvider(prov string) string { | |||||
| 174 | return "deepseek-v4-pro" | 174 | return "deepseek-v4-pro" | ||
| 175 | case "google": | 175 | case "google": | ||
| 176 | return "gemini-2.5-pro" | 176 | return "gemini-2.5-pro" | ||
| 177 | - | case "ollama", provider.LlamaCPPProviderID: | 177 | + | case "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID: |
| 178 | return "" | 178 | return "" | ||
| 179 | case "moonshotai", "moonshotai-cn": | 179 | case "moonshotai", "moonshotai-cn": | ||
| 180 | return "kimi-k2.6" | 180 | return "kimi-k2.6" | ||
| @@ -231,7 +231,7 @@ func defaultModelForProvider(prov string) string { | |||||
| 231 | // auto-fallback logic that picks any logged-in provider when the user's | 231 | // auto-fallback logic that picks any logged-in provider when the user's | ||
| 232 | // preferred one has no credentials. | 232 | // preferred one has no credentials. | ||
| 233 | var knownProviders = []string{ | 233 | var knownProviders = []string{ | ||
| 234 | - | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, | 234 | + | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID, |
| 235 | "moonshotai", "moonshotai-cn", | 235 | "moonshotai", "moonshotai-cn", | ||
| 236 | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola", | 236 | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola", | ||
| 237 | "mistral", "zai", | 237 | "mistral", "zai", | ||
| @@ -410,7 +410,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 410 | // default model are skipped because selecting either one here would | 410 | // default model are skipped because selecting either one here would | ||
| 411 | // fail before the user can choose a model. | 411 | // fail before the user can choose a model. | ||
| 412 | for _, other := range knownProviders { | 412 | for _, other := range knownProviders { | ||
| 413 | - | if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID { | 413 | + | if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID || other == provider.LMStudioProviderID { |
| 414 | continue | 414 | continue | ||
| 415 | } | 415 | } | ||
| 416 | if !CredentialAvailable(other) { | 416 | if !CredentialAvailable(other) { | ||
| @@ -425,7 +425,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 425 | 425 | ||||
| 426 | model := firstNonEmpty(args.Model, cfg.Model) | 426 | model := firstNonEmpty(args.Model, cfg.Model) | ||
| 427 | if model == "" { | 427 | if model == "" { | ||
| 428 | - | if provName == "ollama" || provName == provider.LlamaCPPProviderID { | 428 | + | if provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID { |
| 429 | return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName) | 429 | return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName) | ||
| 430 | } | 430 | } | ||
| 431 | model = defaultModelForProvider(provName) | 431 | model = defaultModelForProvider(provName) | ||
| @@ -441,8 +441,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 441 | model = defaultModelForProvider(provName) | 441 | model = defaultModelForProvider(provName) | ||
| 442 | } | 442 | } | ||
| 443 | } | 443 | } | ||
| 444 | + | if provName == provider.LMStudioProviderID && LMStudioConfigured() { | |||
| 445 | + | // Restore transient metadata when resuming a local model after restart. | |||
| 446 | + | _ = RefreshLMStudioModels(context.Background()) | |||
| 447 | + | } | |||
| 444 | resolvedModel, err := provider.FindModel(provName, model) | 448 | resolvedModel, err := provider.FindModel(provName, model) | ||
| 445 | - | if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID) { | 449 | + | if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID) { |
| 446 | // Local providers are intentionally open-catalogue: any model id the | 450 | // Local providers are intentionally open-catalogue: any model id the | ||
| 447 | // configured server understands is valid, even if it is not cached. | 451 | // configured server understands is valid, even if it is not cached. | ||
| 448 | resolvedModel = provider.Model{ | 452 | resolvedModel = provider.Model{ | ||
| @@ -454,6 +458,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 454 | BaseURL: args.BaseURL, | 458 | BaseURL: args.BaseURL, | ||
| 455 | Source: provName, | 459 | Source: provName, | ||
| 456 | } | 460 | } | ||
| 461 | + | if provName == provider.LMStudioProviderID { | |||
| 462 | + | resolvedModel.ContextWindow = 32768 | |||
| 463 | + | resolvedModel.MaxOutput = 4096 | |||
| 464 | + | } | |||
| 457 | err = nil | 465 | err = nil | ||
| 458 | } | 466 | } | ||
| 459 | // Custom providers are open-catalogue like ollama: any model id the | 467 | // Custom providers are open-catalogue like ollama: any model id the | ||
| @@ -535,12 +543,25 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 535 | // user didn't pass --base-url, use the model's URL. For ollama, | 543 | // user didn't pass --base-url, use the model's URL. For ollama, | ||
| 536 | // keep http://localhost:11434 as a fallback only after the model | 544 | // keep http://localhost:11434 as a fallback only after the model | ||
| 537 | // metadata has had a chance to provide a custom baseUrl. | 545 | // metadata has had a chance to provide a custom baseUrl. | ||
| 538 | - | if args.BaseURL == "" && resolvedModel.BaseURL != "" { | 546 | + | if args.BaseURL == "" && resolvedModel.BaseURL != "" && (provName != provider.LMStudioProviderID || resolvedModel.Source == "user") { |
| 547 | + | // LM Studio discovery can retain an older snapshot on failure. | |||
| 548 | + | // Use its currently registered endpoint below, never a stale URL | |||
| 549 | + | // with the credentials for a newly registered server. | |||
| 539 | args.BaseURL = resolvedModel.BaseURL | 550 | args.BaseURL = resolvedModel.BaseURL | ||
| 540 | } | 551 | } | ||
| 541 | if args.BaseURL == "" && provName == "ollama" { | 552 | if args.BaseURL == "" && provName == "ollama" { | ||
| 542 | args.BaseURL = "http://localhost:11434" | 553 | args.BaseURL = "http://localhost:11434" | ||
| 543 | } | 554 | } | ||
| 555 | + | if args.BaseURL == "" && provName == provider.LMStudioProviderID { | |||
| 556 | + | root, _, configErr := resolveLMStudioConfig(context.Background(), apiKeyCommandExecute) | |||
| 557 | + | if configErr != nil { | |||
| 558 | + | return Resolved{}, configErr | |||
| 559 | + | } | |||
| 560 | + | if root == "" { | |||
| 561 | + | return Resolved{}, fmt.Errorf("LM Studio requires a server URL registered through /login or an explicit --base-url") | |||
| 562 | + | } | |||
| 563 | + | args.BaseURL = root + "/v1" | |||
| 564 | + | } | |||
| 544 | if args.BaseURL == "" && provName == provider.LlamaCPPProviderID { | 565 | if args.BaseURL == "" && provName == provider.LlamaCPPProviderID { | ||
| 545 | managementURL, _, configErr := ResolveLlamaCPPConfig() | 566 | managementURL, _, configErr := ResolveLlamaCPPConfig() | ||
| 546 | if configErr != nil { | 567 | if configErr != nil { | ||
| @@ -814,7 +835,7 @@ func (r Resolved) NewClient() provider.Client { | |||||
| 814 | switch r.Provider { | 835 | switch r.Provider { | ||
| 815 | case "ollama": | 836 | case "ollama": | ||
| 816 | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | 837 | return wrap(provider.NewOpenAI(r.Credential, r.BaseURL)) | ||
| 817 | - | case provider.LlamaCPPProviderID: | 838 | + | case provider.LlamaCPPProviderID, provider.LMStudioProviderID: |
| 818 | credential := r.Credential | 839 | credential := r.Credential | ||
| 819 | if credential == "local" { | 840 | if credential == "local" { | ||
| 820 | credential = "" | 841 | credential = "" | ||
M packages/agent/cli.go +2 -0
| @@ -1380,6 +1380,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1380 | 1380 | AuthManager: mgr, | |
| 1381 | 1381 | LlamaCPPConfig: ResolveLlamaCPPConfig, | |
| 1382 | 1382 | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | |
| 1383 | + | RefreshLMStudioModels: RefreshLMStudioModels, | |
| 1384 | + | LMStudioConfigured: LMStudioConfigured, | |
| 1383 | 1385 | BuildAgent: buildAgent, | |
| 1384 | 1386 | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | |
| 1385 | 1387 | BuildAgentFor: buildAgentFor, | |
| @@ -1380,6 +1380,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1380 | AuthManager: mgr, | 1380 | AuthManager: mgr, | ||
| 1381 | LlamaCPPConfig: ResolveLlamaCPPConfig, | 1381 | LlamaCPPConfig: ResolveLlamaCPPConfig, | ||
| 1382 | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | 1382 | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | ||
| 1383 | + | RefreshLMStudioModels: RefreshLMStudioModels, | |||
| 1384 | + | LMStudioConfigured: LMStudioConfigured, | |||
| 1383 | BuildAgent: buildAgent, | 1385 | BuildAgent: buildAgent, | ||
| 1384 | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | 1386 | SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled, | ||
| 1385 | BuildAgentFor: buildAgentFor, | 1387 | BuildAgentFor: buildAgentFor, | ||
M packages/agent/config.go +8 -0
| @@ -399,6 +399,14 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma | |||
| 399 | 399 | if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { | |
| 400 | 400 | return v, "apikey", "", nil | |
| 401 | 401 | } | |
| 402 | + | case "lmstudio": | |
| 403 | + | baseURL, apiKey, resolveErr := resolveLMStudioConfig(ctx, commandMode) | |
| 404 | + | if resolveErr != nil { | |
| 405 | + | return "", "", "", resolveErr | |
| 406 | + | } | |
| 407 | + | if baseURL != "" { | |
| 408 | + | return firstNonEmpty(apiKey, "local"), "apikey", "", nil | |
| 409 | + | } | |
| 402 | 410 | case "llama.cpp": | |
| 403 | 411 | baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode) | |
| 404 | 412 | if resolveErr != nil { | |
| @@ -399,6 +399,14 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma | |||||
| 399 | if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { | 399 | if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { | ||
| 400 | return v, "apikey", "", nil | 400 | return v, "apikey", "", nil | ||
| 401 | } | 401 | } | ||
| 402 | + | case "lmstudio": | |||
| 403 | + | baseURL, apiKey, resolveErr := resolveLMStudioConfig(ctx, commandMode) | |||
| 404 | + | if resolveErr != nil { | |||
| 405 | + | return "", "", "", resolveErr | |||
| 406 | + | } | |||
| 407 | + | if baseURL != "" { | |||
| 408 | + | return firstNonEmpty(apiKey, "local"), "apikey", "", nil | |||
| 409 | + | } | |||
| 402 | case "llama.cpp": | 410 | case "llama.cpp": | ||
| 403 | baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode) | 411 | baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode) | ||
| 404 | if resolveErr != nil { | 412 | if resolveErr != nil { | ||
A packages/agent/lmstudio.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // resolveLMStudioConfig never assumes a localhost endpoint. Discovery is | |
| 12 | + | // enabled only by a saved server URL, independently of whether a key is needed. | |
| 13 | + | func resolveLMStudioConfig(ctx context.Context, mode apiKeyCommandMode) (string, string, error) { | |
| 14 | + | creds, err := AuthStoreFor().Load() | |
| 15 | + | if err != nil { | |
| 16 | + | return "", "", err | |
| 17 | + | } | |
| 18 | + | stored, ok := creds.AdditionalAPIKeyCreds[provider.LMStudioProviderID] | |
| 19 | + | if !ok || strings.TrimSpace(stored.BaseURL) == "" { | |
| 20 | + | return "", "", nil | |
| 21 | + | } | |
| 22 | + | root, err := provider.NormalizeLMStudioURL(stored.BaseURL) | |
| 23 | + | if err != nil { | |
| 24 | + | return "", "", err | |
| 25 | + | } | |
| 26 | + | if stored.APIKeyCommand != nil && mode == apiKeyCommandSkip { | |
| 27 | + | return "", "", nil | |
| 28 | + | } | |
| 29 | + | key, _, err := resolveStoredAPIKey(ctx, provider.LMStudioProviderID, stored, mode) | |
| 30 | + | return root, key, err | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // LMStudioConfigured checks only saved configuration, never the network. | |
| 34 | + | func LMStudioConfigured() bool { | |
| 35 | + | root, _, err := AuthStoreFor().EndpointCredential(provider.LMStudioProviderID) | |
| 36 | + | return err == nil && strings.TrimSpace(root) != "" | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func RefreshLMStudioModels(ctx context.Context) error { | |
| 40 | + | ctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 41 | + | defer cancel() | |
| 42 | + | return refreshLMStudioModels(ctx, apiKeyCommandExecute) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func refreshLMStudioModels(ctx context.Context, mode apiKeyCommandMode) error { | |
| 46 | + | root, key, err := resolveLMStudioConfig(ctx, mode) | |
| 47 | + | if err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 50 | + | if root == "" { | |
| 51 | + | if LMStudioConfigured() { | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | provider.SetManagedModelsForProvider(provider.LMStudioProviderID, nil) | |
| 55 | + | return nil | |
| 56 | + | } | |
| 57 | + | models, err := provider.DiscoverLMStudio(ctx, root, key) | |
| 58 | + | if err != nil { | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | // A login or logout may have changed the endpoint during the request. | |
| 62 | + | currentRoot, currentKey, err := resolveLMStudioConfig(ctx, mode) | |
| 63 | + | if err != nil { | |
| 64 | + | return err | |
| 65 | + | } | |
| 66 | + | if currentRoot != root || currentKey != key { | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | provider.SetManagedModelsForProvider(provider.LMStudioProviderID, models) | |
| 70 | + | return nil | |
| 71 | + | } | |
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // resolveLMStudioConfig never assumes a localhost endpoint. Discovery is | |||
| 12 | + | // enabled only by a saved server URL, independently of whether a key is needed. | |||
| 13 | + | func resolveLMStudioConfig(ctx context.Context, mode apiKeyCommandMode) (string, string, error) { | |||
| 14 | + | creds, err := AuthStoreFor().Load() | |||
| 15 | + | if err != nil { | |||
| 16 | + | return "", "", err | |||
| 17 | + | } | |||
| 18 | + | stored, ok := creds.AdditionalAPIKeyCreds[provider.LMStudioProviderID] | |||
| 19 | + | if !ok || strings.TrimSpace(stored.BaseURL) == "" { | |||
| 20 | + | return "", "", nil | |||
| 21 | + | } | |||
| 22 | + | root, err := provider.NormalizeLMStudioURL(stored.BaseURL) | |||
| 23 | + | if err != nil { | |||
| 24 | + | return "", "", err | |||
| 25 | + | } | |||
| 26 | + | if stored.APIKeyCommand != nil && mode == apiKeyCommandSkip { | |||
| 27 | + | return "", "", nil | |||
| 28 | + | } | |||
| 29 | + | key, _, err := resolveStoredAPIKey(ctx, provider.LMStudioProviderID, stored, mode) | |||
| 30 | + | return root, key, err | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // LMStudioConfigured checks only saved configuration, never the network. | |||
| 34 | + | func LMStudioConfigured() bool { | |||
| 35 | + | root, _, err := AuthStoreFor().EndpointCredential(provider.LMStudioProviderID) | |||
| 36 | + | return err == nil && strings.TrimSpace(root) != "" | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func RefreshLMStudioModels(ctx context.Context) error { | |||
| 40 | + | ctx, cancel := context.WithTimeout(ctx, 3*time.Second) | |||
| 41 | + | defer cancel() | |||
| 42 | + | return refreshLMStudioModels(ctx, apiKeyCommandExecute) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func refreshLMStudioModels(ctx context.Context, mode apiKeyCommandMode) error { | |||
| 46 | + | root, key, err := resolveLMStudioConfig(ctx, mode) | |||
| 47 | + | if err != nil { | |||
| 48 | + | return err | |||
| 49 | + | } | |||
| 50 | + | if root == "" { | |||
| 51 | + | if LMStudioConfigured() { | |||
| 52 | + | return nil | |||
| 53 | + | } | |||
| 54 | + | provider.SetManagedModelsForProvider(provider.LMStudioProviderID, nil) | |||
| 55 | + | return nil | |||
| 56 | + | } | |||
| 57 | + | models, err := provider.DiscoverLMStudio(ctx, root, key) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return err | |||
| 60 | + | } | |||
| 61 | + | // A login or logout may have changed the endpoint during the request. | |||
| 62 | + | currentRoot, currentKey, err := resolveLMStudioConfig(ctx, mode) | |||
| 63 | + | if err != nil { | |||
| 64 | + | return err | |||
| 65 | + | } | |||
| 66 | + | if currentRoot != root || currentKey != key { | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | provider.SetManagedModelsForProvider(provider.LMStudioProviderID, models) | |||
| 70 | + | return nil | |||
| 71 | + | } | |||
A packages/agent/lmstudio_test.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestLMStudioDiscoveryRequiresRegisteredURL(t *testing.T) { | |
| 15 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 16 | + | provider.SetManagedModels(nil) | |
| 17 | + | t.Cleanup(func() { provider.SetManagedModels(nil) }) | |
| 18 | + | var requests atomic.Int32 | |
| 19 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 20 | + | requests.Add(1) | |
| 21 | + | fmt.Fprint(w, `{"models":[{"type":"llm","key":"local-model","max_context_length":8192}]}`) | |
| 22 | + | })) | |
| 23 | + | defer server.Close() | |
| 24 | + | // Merely knowing an endpoint through an environment variable is not registration. | |
| 25 | + | t.Setenv("LMSTUDIO_BASE_URL", server.URL) | |
| 26 | + | if LMStudioConfigured() { | |
| 27 | + | t.Fatal("unexpected default registration") | |
| 28 | + | } | |
| 29 | + | if err := RefreshLMStudioModels(context.Background()); err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | if requests.Load() != 0 { | |
| 33 | + | t.Fatal("unregistered discovery made a request") | |
| 34 | + | } | |
| 35 | + | if err := AuthStoreFor().SetAPIKey(provider.LMStudioProviderID, "key-only"); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | if err := RefreshLMStudioModels(context.Background()); err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | if requests.Load() != 0 { | |
| 42 | + | t.Fatal("key without URL enabled discovery") | |
| 43 | + | } | |
| 44 | + | if err := AuthStoreFor().SetEndpointCredential(provider.LMStudioProviderID, server.URL, ""); err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | if !LMStudioConfigured() { | |
| 48 | + | t.Fatal("missing registration") | |
| 49 | + | } | |
| 50 | + | if err := RefreshLMStudioModels(context.Background()); err != nil { | |
| 51 | + | t.Fatal(err) | |
| 52 | + | } | |
| 53 | + | if requests.Load() != 1 { | |
| 54 | + | t.Fatalf("requests=%d", requests.Load()) | |
| 55 | + | } | |
| 56 | + | m, err := provider.FindModel(provider.LMStudioProviderID, "local-model") | |
| 57 | + | if err != nil || m.ContextWindow != 8192 { | |
| 58 | + | t.Fatalf("model=%+v, err=%v", m, err) | |
| 59 | + | } | |
| 60 | + | r, err := Resolve(Args{Provider: provider.LMStudioProviderID, Model: "local-model", NoSkill: true}, true) | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | if r.BaseURL != server.URL+"/v1" || r.Credential != "local" { | |
| 65 | + | t.Fatalf("incorrect local routing: %s", r.BaseURL) | |
| 66 | + | } | |
| 67 | + | if err := AuthStoreFor().Clear(provider.LMStudioProviderID); err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | if err := RefreshLMStudioModels(context.Background()); err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | if requests.Load() != 2 { | |
| 74 | + | t.Fatal("logout still fetched models") | |
| 75 | + | } | |
| 76 | + | if _, err := provider.FindModel(provider.LMStudioProviderID, "local-model"); err == nil { | |
| 77 | + | t.Fatal("logout retained discovered model") | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestLMStudioConfigSurvivesTransientCatalog(t *testing.T) { | |
| 82 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 83 | + | provider.SetManagedModels(nil) | |
| 84 | + | t.Cleanup(func() { provider.SetManagedModels(nil) }) | |
| 85 | + | if err := SaveConfig(Config{Provider: provider.LMStudioProviderID, Model: "gpt-5"}); err != nil { | |
| 86 | + | t.Fatal(err) | |
| 87 | + | } | |
| 88 | + | ValidateAndRepairConfig() | |
| 89 | + | cfg, err := LoadConfig() | |
| 90 | + | if err != nil || cfg.Provider != provider.LMStudioProviderID || cfg.Model != "gpt-5" { | |
| 91 | + | t.Fatalf("config=%+v error=%v", cfg, err) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestLMStudioRefreshPreservesSnapshotOnFailure(t *testing.T) { | |
| 96 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 97 | + | provider.SetManagedModels([]provider.Model{{Provider: provider.LMStudioProviderID, ID: "previous", BaseURL: "http://unused.invalid/v1", Source: "live"}}) | |
| 98 | + | t.Cleanup(func() { provider.SetManagedModels(nil) }) | |
| 99 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(503) })) | |
| 100 | + | defer server.Close() | |
| 101 | + | if err := AuthStoreFor().SetEndpointCredential(provider.LMStudioProviderID, server.URL, ""); err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | if err := RefreshLMStudioModels(context.Background()); err == nil { | |
| 105 | + | t.Fatal("missing discovery error") | |
| 106 | + | } | |
| 107 | + | if _, err := provider.FindModel(provider.LMStudioProviderID, "previous"); err != nil { | |
| 108 | + | t.Fatal(err) | |
| 109 | + | } | |
| 110 | + | r, err := Resolve(Args{Provider: provider.LMStudioProviderID, Model: "previous", NoSkill: true}, true) | |
| 111 | + | if err != nil { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | if r.BaseURL != server.URL+"/v1" { | |
| 115 | + | t.Fatalf("stale endpoint used for inference: %s", r.BaseURL) | |
| 116 | + | } | |
| 117 | + | } | |
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestLMStudioDiscoveryRequiresRegisteredURL(t *testing.T) { | |||
| 15 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 16 | + | provider.SetManagedModels(nil) | |||
| 17 | + | t.Cleanup(func() { provider.SetManagedModels(nil) }) | |||
| 18 | + | var requests atomic.Int32 | |||
| 19 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 20 | + | requests.Add(1) | |||
| 21 | + | fmt.Fprint(w, `{"models":[{"type":"llm","key":"local-model","max_context_length":8192}]}`) | |||
| 22 | + | })) | |||
| 23 | + | defer server.Close() | |||
| 24 | + | // Merely knowing an endpoint through an environment variable is not registration. | |||
| 25 | + | t.Setenv("LMSTUDIO_BASE_URL", server.URL) | |||
| 26 | + | if LMStudioConfigured() { | |||
| 27 | + | t.Fatal("unexpected default registration") | |||
| 28 | + | } | |||
| 29 | + | if err := RefreshLMStudioModels(context.Background()); err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | if requests.Load() != 0 { | |||
| 33 | + | t.Fatal("unregistered discovery made a request") | |||
| 34 | + | } | |||
| 35 | + | if err := AuthStoreFor().SetAPIKey(provider.LMStudioProviderID, "key-only"); err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | if err := RefreshLMStudioModels(context.Background()); err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | if requests.Load() != 0 { | |||
| 42 | + | t.Fatal("key without URL enabled discovery") | |||
| 43 | + | } | |||
| 44 | + | if err := AuthStoreFor().SetEndpointCredential(provider.LMStudioProviderID, server.URL, ""); err != nil { | |||
| 45 | + | t.Fatal(err) | |||
| 46 | + | } | |||
| 47 | + | if !LMStudioConfigured() { | |||
| 48 | + | t.Fatal("missing registration") | |||
| 49 | + | } | |||
| 50 | + | if err := RefreshLMStudioModels(context.Background()); err != nil { | |||
| 51 | + | t.Fatal(err) | |||
| 52 | + | } | |||
| 53 | + | if requests.Load() != 1 { | |||
| 54 | + | t.Fatalf("requests=%d", requests.Load()) | |||
| 55 | + | } | |||
| 56 | + | m, err := provider.FindModel(provider.LMStudioProviderID, "local-model") | |||
| 57 | + | if err != nil || m.ContextWindow != 8192 { | |||
| 58 | + | t.Fatalf("model=%+v, err=%v", m, err) | |||
| 59 | + | } | |||
| 60 | + | r, err := Resolve(Args{Provider: provider.LMStudioProviderID, Model: "local-model", NoSkill: true}, true) | |||
| 61 | + | if err != nil { | |||
| 62 | + | t.Fatal(err) | |||
| 63 | + | } | |||
| 64 | + | if r.BaseURL != server.URL+"/v1" || r.Credential != "local" { | |||
| 65 | + | t.Fatalf("incorrect local routing: %s", r.BaseURL) | |||
| 66 | + | } | |||
| 67 | + | if err := AuthStoreFor().Clear(provider.LMStudioProviderID); err != nil { | |||
| 68 | + | t.Fatal(err) | |||
| 69 | + | } | |||
| 70 | + | if err := RefreshLMStudioModels(context.Background()); err != nil { | |||
| 71 | + | t.Fatal(err) | |||
| 72 | + | } | |||
| 73 | + | if requests.Load() != 2 { | |||
| 74 | + | t.Fatal("logout still fetched models") | |||
| 75 | + | } | |||
| 76 | + | if _, err := provider.FindModel(provider.LMStudioProviderID, "local-model"); err == nil { | |||
| 77 | + | t.Fatal("logout retained discovered model") | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestLMStudioConfigSurvivesTransientCatalog(t *testing.T) { | |||
| 82 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 83 | + | provider.SetManagedModels(nil) | |||
| 84 | + | t.Cleanup(func() { provider.SetManagedModels(nil) }) | |||
| 85 | + | if err := SaveConfig(Config{Provider: provider.LMStudioProviderID, Model: "gpt-5"}); err != nil { | |||
| 86 | + | t.Fatal(err) | |||
| 87 | + | } | |||
| 88 | + | ValidateAndRepairConfig() | |||
| 89 | + | cfg, err := LoadConfig() | |||
| 90 | + | if err != nil || cfg.Provider != provider.LMStudioProviderID || cfg.Model != "gpt-5" { | |||
| 91 | + | t.Fatalf("config=%+v error=%v", cfg, err) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestLMStudioRefreshPreservesSnapshotOnFailure(t *testing.T) { | |||
| 96 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 97 | + | provider.SetManagedModels([]provider.Model{{Provider: provider.LMStudioProviderID, ID: "previous", BaseURL: "http://unused.invalid/v1", Source: "live"}}) | |||
| 98 | + | t.Cleanup(func() { provider.SetManagedModels(nil) }) | |||
| 99 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(503) })) | |||
| 100 | + | defer server.Close() | |||
| 101 | + | if err := AuthStoreFor().SetEndpointCredential(provider.LMStudioProviderID, server.URL, ""); err != nil { | |||
| 102 | + | t.Fatal(err) | |||
| 103 | + | } | |||
| 104 | + | if err := RefreshLMStudioModels(context.Background()); err == nil { | |||
| 105 | + | t.Fatal("missing discovery error") | |||
| 106 | + | } | |||
| 107 | + | if _, err := provider.FindModel(provider.LMStudioProviderID, "previous"); err != nil { | |||
| 108 | + | t.Fatal(err) | |||
| 109 | + | } | |||
| 110 | + | r, err := Resolve(Args{Provider: provider.LMStudioProviderID, Model: "previous", NoSkill: true}, true) | |||
| 111 | + | if err != nil { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | if r.BaseURL != server.URL+"/v1" { | |||
| 115 | + | t.Fatalf("stale endpoint used for inference: %s", r.BaseURL) | |||
| 116 | + | } | |||
| 117 | + | } | |||
M packages/agent/modelsync.go +13 -4
| @@ -105,8 +105,10 @@ func ValidateAndRepairConfig() { | |||
| 105 | 105 | // Gateway providers can serve routed model ids like | |
| 106 | 106 | // "deepseek/deepseek-v4-flash" even when the local catalog does not | |
| 107 | 107 | // know them. Preserve only routed ids; plain typos are still repaired. | |
| 108 | - | if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model) { | |
| 109 | - | // Provider is a router and the id is route-qualified; preserve it. | |
| 108 | + | if cfg.Provider == provider.LMStudioProviderID || (isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model)) { | |
| 109 | + | // Local discovery is transient. Preserve the selected LM Studio | |
| 110 | + | // ID even if another provider has an identically named model. | |
| 111 | + | // Routed gateway IDs are also valid without a catalog entry. | |
| 110 | 112 | } else if m, err := provider.FindModel("", cfg.Model); err == nil { | |
| 111 | 113 | fix := defaultModelForProvider(cfg.Provider) | |
| 112 | 114 | fmt.Fprintf(os.Stderr, | |
| @@ -114,7 +116,7 @@ func ValidateAndRepairConfig() { | |||
| 114 | 116 | cfg.Model, m.Provider, cfg.Provider, fix) | |
| 115 | 117 | cfg.Model = fix | |
| 116 | 118 | changed = true | |
| 117 | - | } else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID { | |
| 119 | + | } else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID && cfg.Provider != provider.LMStudioProviderID { | |
| 118 | 120 | // Model id not in any catalog. Reset to provider's default. | |
| 119 | 121 | fix := defaultModelForProvider(cfg.Provider) | |
| 120 | 122 | fmt.Fprintf(os.Stderr, | |
| @@ -142,6 +144,11 @@ func ValidateAndRepairConfig() { | |||
| 142 | 144 | func RefreshModelsAsync() { | |
| 143 | 145 | go refreshModels() | |
| 144 | 146 | go refreshCopilotModelAvailability() | |
| 147 | + | go func() { | |
| 148 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 149 | + | defer cancel() | |
| 150 | + | _ = refreshLMStudioModels(ctx, apiKeyCommandSkip) | |
| 151 | + | }() | |
| 145 | 152 | go func() { | |
| 146 | 153 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 147 | 154 | defer cancel() | |
| @@ -185,7 +192,7 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e | |||
| 185 | 192 | if err != nil { | |
| 186 | 193 | return err | |
| 187 | 194 | } | |
| 188 | - | provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL)) | |
| 195 | + | provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL)) | |
| 189 | 196 | return nil | |
| 190 | 197 | } | |
| 191 | 198 | ||
| @@ -263,6 +270,8 @@ func refreshModels() { | |||
| 263 | 270 | return | |
| 264 | 271 | } | |
| 265 | 272 | provider.SetLiveModels(all) | |
| 273 | + | // Keep explicit metadata overrides above refreshed server catalogs. | |
| 274 | + | LoadUserModels() | |
| 266 | 275 | _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{ | |
| 267 | 276 | FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()), | |
| 268 | 277 | Models: all, | |
| @@ -105,8 +105,10 @@ func ValidateAndRepairConfig() { | |||||
| 105 | // Gateway providers can serve routed model ids like | 105 | // Gateway providers can serve routed model ids like | ||
| 106 | // "deepseek/deepseek-v4-flash" even when the local catalog does not | 106 | // "deepseek/deepseek-v4-flash" even when the local catalog does not | ||
| 107 | // know them. Preserve only routed ids; plain typos are still repaired. | 107 | // know them. Preserve only routed ids; plain typos are still repaired. | ||
| 108 | - | if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model) { | 108 | + | if cfg.Provider == provider.LMStudioProviderID || (isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model)) { |
| 109 | - | // Provider is a router and the id is route-qualified; preserve it. | 109 | + | // Local discovery is transient. Preserve the selected LM Studio |
| 110 | + | // ID even if another provider has an identically named model. | |||
| 111 | + | // Routed gateway IDs are also valid without a catalog entry. | |||
| 110 | } else if m, err := provider.FindModel("", cfg.Model); err == nil { | 112 | } else if m, err := provider.FindModel("", cfg.Model); err == nil { | ||
| 111 | fix := defaultModelForProvider(cfg.Provider) | 113 | fix := defaultModelForProvider(cfg.Provider) | ||
| 112 | fmt.Fprintf(os.Stderr, | 114 | fmt.Fprintf(os.Stderr, | ||
| @@ -114,7 +116,7 @@ func ValidateAndRepairConfig() { | |||||
| 114 | cfg.Model, m.Provider, cfg.Provider, fix) | 116 | cfg.Model, m.Provider, cfg.Provider, fix) | ||
| 115 | cfg.Model = fix | 117 | cfg.Model = fix | ||
| 116 | changed = true | 118 | changed = true | ||
| 117 | - | } else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID { | 119 | + | } else if cfg.Provider != "ollama" && cfg.Provider != provider.LlamaCPPProviderID && cfg.Provider != provider.LMStudioProviderID { |
| 118 | // Model id not in any catalog. Reset to provider's default. | 120 | // Model id not in any catalog. Reset to provider's default. | ||
| 119 | fix := defaultModelForProvider(cfg.Provider) | 121 | fix := defaultModelForProvider(cfg.Provider) | ||
| 120 | fmt.Fprintf(os.Stderr, | 122 | fmt.Fprintf(os.Stderr, | ||
| @@ -142,6 +144,11 @@ func ValidateAndRepairConfig() { | |||||
| 142 | func RefreshModelsAsync() { | 144 | func RefreshModelsAsync() { | ||
| 143 | go refreshModels() | 145 | go refreshModels() | ||
| 144 | go refreshCopilotModelAvailability() | 146 | go refreshCopilotModelAvailability() | ||
| 147 | + | go func() { | |||
| 148 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |||
| 149 | + | defer cancel() | |||
| 150 | + | _ = refreshLMStudioModels(ctx, apiKeyCommandSkip) | |||
| 151 | + | }() | |||
| 145 | go func() { | 152 | go func() { | ||
| 146 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | 153 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | ||
| 147 | defer cancel() | 154 | defer cancel() | ||
| @@ -185,7 +192,7 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e | |||||
| 185 | if err != nil { | 192 | if err != nil { | ||
| 186 | return err | 193 | return err | ||
| 187 | } | 194 | } | ||
| 188 | - | provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL)) | 195 | + | provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL)) |
| 189 | return nil | 196 | return nil | ||
| 190 | } | 197 | } | ||
| 191 | 198 | ||||
| @@ -263,6 +270,8 @@ func refreshModels() { | |||||
| 263 | return | 270 | return | ||
| 264 | } | 271 | } | ||
| 265 | provider.SetLiveModels(all) | 272 | provider.SetLiveModels(all) | ||
| 273 | + | // Keep explicit metadata overrides above refreshed server catalogs. | |||
| 274 | + | LoadUserModels() | |||
| 266 | _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{ | 275 | _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{ | ||
| 267 | FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()), | 276 | FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()), | ||
| 268 | Models: all, | 277 | Models: all, | ||
M packages/agent/modes/interactive.go +31 -5
| @@ -183,6 +183,10 @@ type InteractiveConfig struct { | |||
| 183 | 183 | // the catalog before /model opens. | |
| 184 | 184 | RefreshLlamaCPPModels func(context.Context) error | |
| 185 | 185 | ||
| 186 | + | // RefreshLMStudioModels is a no-op until a server URL is registered. | |
| 187 | + | RefreshLMStudioModels func(context.Context) error | |
| 188 | + | LMStudioConfigured func() bool | |
| 189 | + | ||
| 186 | 190 | // BuildAgent is called after a successful login to (re)construct the | |
| 187 | 191 | // agent with the fresh credential. It returns the new agent and | |
| 188 | 192 | // the concrete provider/model in use. | |
| @@ -2198,7 +2202,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2198 | 2202 | i.submitManualOAuthCode(act.SubmitCode) | |
| 2199 | 2203 | } | |
| 2200 | 2204 | if act.SaveLlama { | |
| 2201 | - | i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey) | |
| 2205 | + | if act.Provider == provider.LMStudioProviderID { | |
| 2206 | + | i.saveLMStudioLogin(act.LlamaURL, act.LlamaAPIKey) | |
| 2207 | + | } else { | |
| 2208 | + | i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey) | |
| 2209 | + | } | |
| 2202 | 2210 | } | |
| 2203 | 2211 | return false | |
| 2204 | 2212 | } | |
| @@ -4533,15 +4541,30 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 4533 | 4541 | case "/model": | |
| 4534 | 4542 | if len(parts) >= 2 { | |
| 4535 | 4543 | i.applyModelSelection("", parts[1]) | |
| 4536 | - | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | |
| 4544 | + | } else if (i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil) || (i.cfg.LMStudioConfigured != nil && i.cfg.LMStudioConfigured() && i.cfg.RefreshLMStudioModels != nil) { | |
| 4537 | 4545 | i.mu.Lock() | |
| 4538 | 4546 | i.statusOK = "refreshing models" | |
| 4539 | 4547 | i.statusErr = "" | |
| 4540 | 4548 | i.mu.Unlock() | |
| 4541 | 4549 | go func() { | |
| 4542 | - | refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 4543 | - | defer cancel() | |
| 4544 | - | i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)} | |
| 4550 | + | refresh := func(fn func(context.Context) error) error { | |
| 4551 | + | refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second) | |
| 4552 | + | defer cancel() | |
| 4553 | + | return fn(refreshCtx) | |
| 4554 | + | } | |
| 4555 | + | var err error | |
| 4556 | + | if i.cfg.RefreshLMStudioModels != nil { | |
| 4557 | + | err = refresh(i.cfg.RefreshLMStudioModels) | |
| 4558 | + | } | |
| 4559 | + | if i.cfg.RefreshLlamaCPPModels != nil { | |
| 4560 | + | if llamaErr := refresh(i.cfg.RefreshLlamaCPPModels); err == nil { | |
| 4561 | + | err = llamaErr | |
| 4562 | + | } | |
| 4563 | + | } | |
| 4564 | + | select { | |
| 4565 | + | case i.modelRefresh <- modelRefreshResult{err: err}: | |
| 4566 | + | case <-ctx.Done(): | |
| 4567 | + | } | |
| 4545 | 4568 | }() | |
| 4546 | 4569 | } else { | |
| 4547 | 4570 | i.openModelPickerAfterRefresh(nil) | |
| @@ -4873,6 +4896,9 @@ func (i *Interactive) doLogout(target string) { | |||
| 4873 | 4896 | continue | |
| 4874 | 4897 | } | |
| 4875 | 4898 | } | |
| 4899 | + | if p == provider.LMStudioProviderID { | |
| 4900 | + | provider.SetManagedModelsForProvider(provider.LMStudioProviderID, nil) | |
| 4901 | + | } | |
| 4876 | 4902 | if p == i.cfg.Provider { | |
| 4877 | 4903 | clearedCurrent = true | |
| 4878 | 4904 | } | |
| @@ -183,6 +183,10 @@ type InteractiveConfig struct { | |||||
| 183 | // the catalog before /model opens. | 183 | // the catalog before /model opens. | ||
| 184 | RefreshLlamaCPPModels func(context.Context) error | 184 | RefreshLlamaCPPModels func(context.Context) error | ||
| 185 | 185 | ||||
| 186 | + | // RefreshLMStudioModels is a no-op until a server URL is registered. | |||
| 187 | + | RefreshLMStudioModels func(context.Context) error | |||
| 188 | + | LMStudioConfigured func() bool | |||
| 189 | + | ||||
| 186 | // BuildAgent is called after a successful login to (re)construct the | 190 | // BuildAgent is called after a successful login to (re)construct the | ||
| 187 | // agent with the fresh credential. It returns the new agent and | 191 | // agent with the fresh credential. It returns the new agent and | ||
| 188 | // the concrete provider/model in use. | 192 | // the concrete provider/model in use. | ||
| @@ -2198,7 +2202,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 2198 | i.submitManualOAuthCode(act.SubmitCode) | 2202 | i.submitManualOAuthCode(act.SubmitCode) | ||
| 2199 | } | 2203 | } | ||
| 2200 | if act.SaveLlama { | 2204 | if act.SaveLlama { | ||
| 2201 | - | i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey) | 2205 | + | if act.Provider == provider.LMStudioProviderID { |
| 2206 | + | i.saveLMStudioLogin(act.LlamaURL, act.LlamaAPIKey) | |||
| 2207 | + | } else { | |||
| 2208 | + | i.saveLlamaCPPLogin(act.LlamaURL, act.LlamaAPIKey) | |||
| 2209 | + | } | |||
| 2202 | } | 2210 | } | ||
| 2203 | return false | 2211 | return false | ||
| 2204 | } | 2212 | } | ||
| @@ -4533,15 +4541,30 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 4533 | case "/model": | 4541 | case "/model": | ||
| 4534 | if len(parts) >= 2 { | 4542 | if len(parts) >= 2 { | ||
| 4535 | i.applyModelSelection("", parts[1]) | 4543 | i.applyModelSelection("", parts[1]) | ||
| 4536 | - | } else if i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil { | 4544 | + | } else if (i.llamaConfigured && i.cfg.RefreshLlamaCPPModels != nil) || (i.cfg.LMStudioConfigured != nil && i.cfg.LMStudioConfigured() && i.cfg.RefreshLMStudioModels != nil) { |
| 4537 | i.mu.Lock() | 4545 | i.mu.Lock() | ||
| 4538 | i.statusOK = "refreshing models" | 4546 | i.statusOK = "refreshing models" | ||
| 4539 | i.statusErr = "" | 4547 | i.statusErr = "" | ||
| 4540 | i.mu.Unlock() | 4548 | i.mu.Unlock() | ||
| 4541 | go func() { | 4549 | go func() { | ||
| 4542 | - | refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second) | 4550 | + | refresh := func(fn func(context.Context) error) error { |
| 4543 | - | defer cancel() | 4551 | + | refreshCtx, cancel := context.WithTimeout(ctx, 3*time.Second) |
| 4544 | - | i.modelRefresh <- modelRefreshResult{err: i.cfg.RefreshLlamaCPPModels(refreshCtx)} | 4552 | + | defer cancel() |
| 4553 | + | return fn(refreshCtx) | |||
| 4554 | + | } | |||
| 4555 | + | var err error | |||
| 4556 | + | if i.cfg.RefreshLMStudioModels != nil { | |||
| 4557 | + | err = refresh(i.cfg.RefreshLMStudioModels) | |||
| 4558 | + | } | |||
| 4559 | + | if i.cfg.RefreshLlamaCPPModels != nil { | |||
| 4560 | + | if llamaErr := refresh(i.cfg.RefreshLlamaCPPModels); err == nil { | |||
| 4561 | + | err = llamaErr | |||
| 4562 | + | } | |||
| 4563 | + | } | |||
| 4564 | + | select { | |||
| 4565 | + | case i.modelRefresh <- modelRefreshResult{err: err}: | |||
| 4566 | + | case <-ctx.Done(): | |||
| 4567 | + | } | |||
| 4545 | }() | 4568 | }() | ||
| 4546 | } else { | 4569 | } else { | ||
| 4547 | i.openModelPickerAfterRefresh(nil) | 4570 | i.openModelPickerAfterRefresh(nil) | ||
| @@ -4873,6 +4896,9 @@ func (i *Interactive) doLogout(target string) { | |||||
| 4873 | continue | 4896 | continue | ||
| 4874 | } | 4897 | } | ||
| 4875 | } | 4898 | } | ||
| 4899 | + | if p == provider.LMStudioProviderID { | |||
| 4900 | + | provider.SetManagedModelsForProvider(provider.LMStudioProviderID, nil) | |||
| 4901 | + | } | |||
| 4876 | if p == i.cfg.Provider { | 4902 | if p == i.cfg.Provider { | ||
| 4877 | clearedCurrent = true | 4903 | clearedCurrent = true | ||
| 4878 | } | 4904 | } | ||
A packages/agent/modes/interactive_lmstudio.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func (i *Interactive) saveLMStudioLogin(baseURL, apiKey string) { | |
| 11 | + | if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil { | |
| 12 | + | i.dialog.ShowResult(false, "auth store is unavailable") | |
| 13 | + | return | |
| 14 | + | } | |
| 15 | + | if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LMStudioProviderID, baseURL, apiKey); err != nil { | |
| 16 | + | i.dialog.ShowResult(false, err.Error()) | |
| 17 | + | return | |
| 18 | + | } | |
| 19 | + | provider.SetManagedModelsForProvider(provider.LMStudioProviderID, nil) | |
| 20 | + | i.dialog.ShowResult(true, "") | |
| 21 | + | if i.cfg.RefreshLMStudioModels != nil { | |
| 22 | + | go func() { | |
| 23 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |
| 24 | + | defer cancel() | |
| 25 | + | // Connection errors are reported when /model is opened. Saving | |
| 26 | + | // the endpoint does not require the server to be online. | |
| 27 | + | _ = i.cfg.RefreshLMStudioModels(ctx) | |
| 28 | + | }() | |
| 29 | + | } | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func (i *Interactive) saveLMStudioLogin(baseURL, apiKey string) { | |||
| 11 | + | if i.cfg.AuthManager == nil || i.cfg.AuthManager.Store() == nil { | |||
| 12 | + | i.dialog.ShowResult(false, "auth store is unavailable") | |||
| 13 | + | return | |||
| 14 | + | } | |||
| 15 | + | if err := i.cfg.AuthManager.Store().SetEndpointCredential(provider.LMStudioProviderID, baseURL, apiKey); err != nil { | |||
| 16 | + | i.dialog.ShowResult(false, err.Error()) | |||
| 17 | + | return | |||
| 18 | + | } | |||
| 19 | + | provider.SetManagedModelsForProvider(provider.LMStudioProviderID, nil) | |||
| 20 | + | i.dialog.ShowResult(true, "") | |||
| 21 | + | if i.cfg.RefreshLMStudioModels != nil { | |||
| 22 | + | go func() { | |||
| 23 | + | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) | |||
| 24 | + | defer cancel() | |||
| 25 | + | // Connection errors are reported when /model is opened. Saving | |||
| 26 | + | // the endpoint does not require the server to be online. | |||
| 27 | + | _ = i.cfg.RefreshLMStudioModels(ctx) | |||
| 28 | + | }() | |||
| 29 | + | } | |||
| 30 | + | } | |||
M packages/agent/modes/llama_dialog.go +1 -1
| @@ -130,7 +130,7 @@ func (d *llamaDialog) refresh(next llamaDialogStep, message string) { | |||
| 130 | 130 | } | |
| 131 | 131 | return models[i].ID < models[j].ID | |
| 132 | 132 | }) | |
| 133 | - | provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL)) | |
| 133 | + | provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL)) | |
| 134 | 134 | d.mu.Lock() | |
| 135 | 135 | if d.step != llamaClosed { | |
| 136 | 136 | d.models = models | |
| @@ -130,7 +130,7 @@ func (d *llamaDialog) refresh(next llamaDialogStep, message string) { | |||||
| 130 | } | 130 | } | ||
| 131 | return models[i].ID < models[j].ID | 131 | return models[i].ID < models[j].ID | ||
| 132 | }) | 132 | }) | ||
| 133 | - | provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL)) | 133 | + | provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL)) |
| 134 | d.mu.Lock() | 134 | d.mu.Lock() | ||
| 135 | if d.step != llamaClosed { | 135 | if d.step != llamaClosed { | ||
| 136 | d.models = models | 136 | d.models = models | ||
M packages/agent/modes/login_dialog.go +11 -7
| @@ -184,8 +184,8 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 184 | 184 | } | |
| 185 | 185 | lines = append(lines, frameRule(th, width)) | |
| 186 | 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:")) | |
| 187 | + | lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width)) | |
| 188 | + | lines = append(lines, th.FG256(th.Muted, "server URL:")) | |
| 189 | 189 | if d.llamaEd == nil { | |
| 190 | 190 | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 191 | 191 | } | |
| @@ -196,8 +196,8 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||
| 196 | 196 | } | |
| 197 | 197 | lines = append(lines, "", th.FG256(th.Muted, "enter continues - esc cancels"), frameRule(th, width)) | |
| 198 | 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):")) | |
| 199 | + | lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width)) | |
| 200 | + | lines = append(lines, th.FG256(th.Muted, "server: "+d.llamaURL), "", th.FG256(th.Muted, "API key (optional):")) | |
| 201 | 201 | if d.llamaEd == nil { | |
| 202 | 202 | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | |
| 203 | 203 | } | |
| @@ -537,7 +537,7 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||
| 537 | 537 | return loginDialogAction{} | |
| 538 | 538 | } | |
| 539 | 539 | d.provider = providers[d.cursor] | |
| 540 | - | if d.method == "apikey" && d.provider == provider.LlamaCPPProviderID { | |
| 540 | + | if d.method == "apikey" && (d.provider == provider.LlamaCPPProviderID || d.provider == provider.LMStudioProviderID) { | |
| 541 | 541 | d.step = loginStepLlamaURL | |
| 542 | 542 | d.llamaEd = nil | |
| 543 | 543 | d.message = "" | |
| @@ -566,7 +566,11 @@ func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | |||
| 566 | 566 | value := strings.TrimSpace(d.llamaEd.SubmitValue()) | |
| 567 | 567 | d.llamaEd.Clear() | |
| 568 | 568 | if d.step == loginStepLlamaURL { | |
| 569 | - | normalized, err := provider.NormalizeLlamaCPPURL(value) | |
| 569 | + | normalize := provider.NormalizeLlamaCPPURL | |
| 570 | + | if d.provider == provider.LMStudioProviderID { | |
| 571 | + | normalize = provider.NormalizeLMStudioURL | |
| 572 | + | } | |
| 573 | + | normalized, err := normalize(value) | |
| 570 | 574 | if err != nil { | |
| 571 | 575 | d.message = err.Error() | |
| 572 | 576 | return loginDialogAction{} | |
| @@ -578,7 +582,7 @@ func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | |||
| 578 | 582 | return loginDialogAction{} | |
| 579 | 583 | } | |
| 580 | 584 | return loginDialogAction{ | |
| 581 | - | SaveLlama: true, Provider: provider.LlamaCPPProviderID, | |
| 585 | + | SaveLlama: true, Provider: d.provider, | |
| 582 | 586 | LlamaURL: d.llamaURL, LlamaAPIKey: value, | |
| 583 | 587 | } | |
| 584 | 588 | } | |
| @@ -184,8 +184,8 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 184 | } | 184 | } | ||
| 185 | lines = append(lines, frameRule(th, width)) | 185 | lines = append(lines, frameRule(th, width)) | ||
| 186 | case loginStepLlamaURL: | 186 | case loginStepLlamaURL: | ||
| 187 | - | lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width)) | 187 | + | lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width)) |
| 188 | - | lines = append(lines, th.FG256(th.Muted, "llama.cpp router URL:")) | 188 | + | lines = append(lines, th.FG256(th.Muted, "server URL:")) |
| 189 | if d.llamaEd == nil { | 189 | if d.llamaEd == nil { | ||
| 190 | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | 190 | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | ||
| 191 | } | 191 | } | ||
| @@ -196,8 +196,8 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { | |||||
| 196 | } | 196 | } | ||
| 197 | lines = append(lines, "", th.FG256(th.Muted, "enter continues - esc cancels"), frameRule(th, width)) | 197 | lines = append(lines, "", th.FG256(th.Muted, "enter continues - esc cancels"), frameRule(th, width)) | ||
| 198 | case loginStepLlamaKey: | 198 | case loginStepLlamaKey: | ||
| 199 | - | lines = append(lines, frameHeader(th, "login - api key - llama.cpp", width)) | 199 | + | lines = append(lines, frameHeader(th, "login - api key - "+providerLabel(d.provider), width)) |
| 200 | - | lines = append(lines, th.FG256(th.Muted, "router: "+d.llamaURL), "", th.FG256(th.Muted, "API key (optional):")) | 200 | + | lines = append(lines, th.FG256(th.Muted, "server: "+d.llamaURL), "", th.FG256(th.Muted, "API key (optional):")) |
| 201 | if d.llamaEd == nil { | 201 | if d.llamaEd == nil { | ||
| 202 | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | 202 | d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent)) | ||
| 203 | } | 203 | } | ||
| @@ -537,7 +537,7 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction { | |||||
| 537 | return loginDialogAction{} | 537 | return loginDialogAction{} | ||
| 538 | } | 538 | } | ||
| 539 | d.provider = providers[d.cursor] | 539 | d.provider = providers[d.cursor] | ||
| 540 | - | if d.method == "apikey" && d.provider == provider.LlamaCPPProviderID { | 540 | + | if d.method == "apikey" && (d.provider == provider.LlamaCPPProviderID || d.provider == provider.LMStudioProviderID) { |
| 541 | d.step = loginStepLlamaURL | 541 | d.step = loginStepLlamaURL | ||
| 542 | d.llamaEd = nil | 542 | d.llamaEd = nil | ||
| 543 | d.message = "" | 543 | d.message = "" | ||
| @@ -566,7 +566,11 @@ func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | |||||
| 566 | value := strings.TrimSpace(d.llamaEd.SubmitValue()) | 566 | value := strings.TrimSpace(d.llamaEd.SubmitValue()) | ||
| 567 | d.llamaEd.Clear() | 567 | d.llamaEd.Clear() | ||
| 568 | if d.step == loginStepLlamaURL { | 568 | if d.step == loginStepLlamaURL { | ||
| 569 | - | normalized, err := provider.NormalizeLlamaCPPURL(value) | 569 | + | normalize := provider.NormalizeLlamaCPPURL |
| 570 | + | if d.provider == provider.LMStudioProviderID { | |||
| 571 | + | normalize = provider.NormalizeLMStudioURL | |||
| 572 | + | } | |||
| 573 | + | normalized, err := normalize(value) | |||
| 570 | if err != nil { | 574 | if err != nil { | ||
| 571 | d.message = err.Error() | 575 | d.message = err.Error() | ||
| 572 | return loginDialogAction{} | 576 | return loginDialogAction{} | ||
| @@ -578,7 +582,7 @@ func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction { | |||||
| 578 | return loginDialogAction{} | 582 | return loginDialogAction{} | ||
| 579 | } | 583 | } | ||
| 580 | return loginDialogAction{ | 584 | return loginDialogAction{ | ||
| 581 | - | SaveLlama: true, Provider: provider.LlamaCPPProviderID, | 585 | + | SaveLlama: true, Provider: d.provider, |
| 582 | LlamaURL: d.llamaURL, LlamaAPIKey: value, | 586 | LlamaURL: d.llamaURL, LlamaAPIKey: value, | ||
| 583 | } | 587 | } | ||
| 584 | } | 588 | } | ||
A packages/agent/modes/login_lmstudio_test.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestLoginLMStudioRequiresServerURL(t *testing.T) { | |
| 10 | + | d := newLoginDialog() | |
| 11 | + | d.step = loginStepProvider | |
| 12 | + | d.method = "apikey" | |
| 13 | + | d.status = map[string]string{} | |
| 14 | + | d.providerQuery = "lmstudio" | |
| 15 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 16 | + | if d.step != loginStepLlamaURL || d.provider != "lmstudio" { | |
| 17 | + | t.Fatalf("wrong login step: %v", d.step) | |
| 18 | + | } | |
| 19 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 20 | + | if d.step != loginStepLlamaURL { | |
| 21 | + | t.Fatal("empty URL enabled discovery") | |
| 22 | + | } | |
| 23 | + | d.llamaEd.SetValue("http://localhost:1234/api/v1") | |
| 24 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 25 | + | if d.step != loginStepLlamaKey { | |
| 26 | + | t.Fatalf("URL rejected: %s", d.message) | |
| 27 | + | } | |
| 28 | + | a := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 29 | + | if !a.SaveLlama || a.Provider != "lmstudio" || a.LlamaURL != "http://localhost:1234" || a.LlamaAPIKey != "" { | |
| 30 | + | t.Fatalf("action=%+v", a) | |
| 31 | + | } | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/packages/tui" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestLoginLMStudioRequiresServerURL(t *testing.T) { | |||
| 10 | + | d := newLoginDialog() | |||
| 11 | + | d.step = loginStepProvider | |||
| 12 | + | d.method = "apikey" | |||
| 13 | + | d.status = map[string]string{} | |||
| 14 | + | d.providerQuery = "lmstudio" | |||
| 15 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 16 | + | if d.step != loginStepLlamaURL || d.provider != "lmstudio" { | |||
| 17 | + | t.Fatalf("wrong login step: %v", d.step) | |||
| 18 | + | } | |||
| 19 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 20 | + | if d.step != loginStepLlamaURL { | |||
| 21 | + | t.Fatal("empty URL enabled discovery") | |||
| 22 | + | } | |||
| 23 | + | d.llamaEd.SetValue("http://localhost:1234/api/v1") | |||
| 24 | + | d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 25 | + | if d.step != loginStepLlamaKey { | |||
| 26 | + | t.Fatalf("URL rejected: %s", d.message) | |||
| 27 | + | } | |||
| 28 | + | a := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |||
| 29 | + | if !a.SaveLlama || a.Provider != "lmstudio" || a.LlamaURL != "http://localhost:1234" || a.LlamaAPIKey != "" { | |||
| 30 | + | t.Fatalf("action=%+v", a) | |||
| 31 | + | } | |||
| 32 | + | } | |||
M packages/provider/auth/server.go +1 -1
| @@ -136,7 +136,7 @@ func apiKeyProviderMessage() string { | |||
| 136 | 136 | // APIKeyProviders is the ordered list shown by /login -> api key. | |
| 137 | 137 | func APIKeyProviders() []string { | |
| 138 | 138 | return []string{ | |
| 139 | - | "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp", | |
| 139 | + | "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp", "lmstudio", | |
| 140 | 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | |
| 141 | 141 | "huggingface", "openrouter", "gondola", "mistral", "zai", | |
| 142 | 142 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | |
| @@ -136,7 +136,7 @@ func apiKeyProviderMessage() string { | |||||
| 136 | // APIKeyProviders is the ordered list shown by /login -> api key. | 136 | // APIKeyProviders is the ordered list shown by /login -> api key. | ||
| 137 | func APIKeyProviders() []string { | 137 | func APIKeyProviders() []string { | ||
| 138 | return []string{ | 138 | return []string{ | ||
| 139 | - | "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp", | 139 | + | "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp", "lmstudio", |
| 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | ||
| 141 | "huggingface", "openrouter", "gondola", "mistral", "zai", | 141 | "huggingface", "openrouter", "gondola", "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/labels.go +2 -0
| @@ -9,6 +9,8 @@ func ProviderLabel(id string) string { | |||
| 9 | 9 | return "Anthropic (Claude Pro/Max)" | |
| 10 | 10 | case "openai": | |
| 11 | 11 | return "OpenAI" | |
| 12 | + | case LMStudioProviderID: | |
| 13 | + | return "LM Studio" | |
| 12 | 14 | case "openai-codex": | |
| 13 | 15 | return "OpenAI Codex (ChatGPT Plus/Pro)" | |
| 14 | 16 | case "openai-responses": | |
| @@ -9,6 +9,8 @@ func ProviderLabel(id string) string { | |||||
| 9 | return "Anthropic (Claude Pro/Max)" | 9 | return "Anthropic (Claude Pro/Max)" | ||
| 10 | case "openai": | 10 | case "openai": | ||
| 11 | return "OpenAI" | 11 | return "OpenAI" | ||
| 12 | + | case LMStudioProviderID: | |||
| 13 | + | return "LM Studio" | |||
| 12 | case "openai-codex": | 14 | case "openai-codex": | ||
| 13 | return "OpenAI Codex (ChatGPT Plus/Pro)" | 15 | return "OpenAI Codex (ChatGPT Plus/Pro)" | ||
| 14 | case "openai-responses": | 16 | case "openai-responses": | ||
A packages/provider/lmstudio.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/url" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const LMStudioProviderID = "lmstudio" | |
| 14 | + | ||
| 15 | + | // NormalizeLMStudioURL accepts a server root or either API base URL. | |
| 16 | + | func NormalizeLMStudioURL(value string) (string, error) { | |
| 17 | + | u, err := url.Parse(strings.TrimSpace(value)) | |
| 18 | + | if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { | |
| 19 | + | return "", fmt.Errorf("LM Studio server URL must be an http or https URL without credentials, query, or fragment") | |
| 20 | + | } | |
| 21 | + | u.Path = strings.TrimRight(u.Path, "/") | |
| 22 | + | if strings.HasSuffix(u.Path, "/api/v1") { | |
| 23 | + | u.Path = strings.TrimSuffix(u.Path, "/api/v1") | |
| 24 | + | } else { | |
| 25 | + | u.Path = strings.TrimSuffix(u.Path, "/v1") | |
| 26 | + | } | |
| 27 | + | u.RawPath = "" | |
| 28 | + | return strings.TrimRight(u.String(), "/"), nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // DiscoverLMStudio lists models without loading them. Only older servers that | |
| 32 | + | // lack the native endpoint fall back to the less descriptive OpenAI endpoint. | |
| 33 | + | func DiscoverLMStudio(ctx context.Context, serverURL, apiKey string) ([]Model, error) { | |
| 34 | + | root, err := NormalizeLMStudioURL(serverURL) | |
| 35 | + | if err != nil { | |
| 36 | + | return nil, err | |
| 37 | + | } | |
| 38 | + | client := &http.Client{Timeout: 3 * time.Second} | |
| 39 | + | get := func(path string, out any) (int, error) { | |
| 40 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, root+path, nil) | |
| 41 | + | if err != nil { | |
| 42 | + | return 0, err | |
| 43 | + | } | |
| 44 | + | if apiKey != "" { | |
| 45 | + | req.Header.Set("Authorization", "Bearer "+apiKey) | |
| 46 | + | } | |
| 47 | + | resp, err := client.Do(req) | |
| 48 | + | if err != nil { | |
| 49 | + | if ctx.Err() != nil { | |
| 50 | + | return 0, ctx.Err() | |
| 51 | + | } | |
| 52 | + | return 0, fmt.Errorf("LM Studio discovery request failed") | |
| 53 | + | } | |
| 54 | + | defer resp.Body.Close() | |
| 55 | + | if resp.StatusCode != http.StatusOK { | |
| 56 | + | return resp.StatusCode, fmt.Errorf("LM Studio discovery HTTP %d", resp.StatusCode) | |
| 57 | + | } | |
| 58 | + | if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 8<<20)).Decode(out); err != nil { | |
| 59 | + | return resp.StatusCode, fmt.Errorf("LM Studio discovery returned invalid model data") | |
| 60 | + | } | |
| 61 | + | return resp.StatusCode, nil | |
| 62 | + | } | |
| 63 | + | var native struct { | |
| 64 | + | Models []struct { | |
| 65 | + | Type string `json:"type"` | |
| 66 | + | Key string `json:"key"` | |
| 67 | + | Name string `json:"display_name"` | |
| 68 | + | Context int `json:"max_context_length"` | |
| 69 | + | Loaded []struct { | |
| 70 | + | ID string `json:"id"` | |
| 71 | + | Config struct { | |
| 72 | + | Context int `json:"context_length"` | |
| 73 | + | } `json:"config"` | |
| 74 | + | } `json:"loaded_instances"` | |
| 75 | + | } `json:"models"` | |
| 76 | + | } | |
| 77 | + | status, err := get("/api/v1/models", &native) | |
| 78 | + | var models []Model | |
| 79 | + | seen := map[string]bool{} | |
| 80 | + | add := func(id, name string, contextWindow int) { | |
| 81 | + | if strings.TrimSpace(id) == "" || seen[id] { | |
| 82 | + | return | |
| 83 | + | } | |
| 84 | + | seen[id] = true | |
| 85 | + | if name == "" { | |
| 86 | + | name = id | |
| 87 | + | } | |
| 88 | + | if contextWindow <= 0 { | |
| 89 | + | contextWindow = 32768 | |
| 90 | + | } | |
| 91 | + | maxOutput := min(4096, max(1, contextWindow/4)) | |
| 92 | + | models = append(models, Model{Provider: LMStudioProviderID, ID: id, DisplayName: name, ContextWindow: contextWindow, MaxOutput: maxOutput, BaseURL: root + "/v1", Source: "live"}) | |
| 93 | + | } | |
| 94 | + | if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented { | |
| 95 | + | var fallback struct { | |
| 96 | + | Data []struct { | |
| 97 | + | ID string `json:"id"` | |
| 98 | + | } `json:"data"` | |
| 99 | + | } | |
| 100 | + | if _, err := get("/v1/models", &fallback); err != nil { | |
| 101 | + | return nil, err | |
| 102 | + | } | |
| 103 | + | if fallback.Data == nil { | |
| 104 | + | return nil, fmt.Errorf("LM Studio discovery response is missing data") | |
| 105 | + | } | |
| 106 | + | for _, m := range fallback.Data { | |
| 107 | + | if strings.Contains(strings.ToLower(m.ID), "embed") { | |
| 108 | + | continue | |
| 109 | + | } | |
| 110 | + | add(m.ID, m.ID, 0) | |
| 111 | + | } | |
| 112 | + | return models, nil | |
| 113 | + | } | |
| 114 | + | if err != nil { | |
| 115 | + | return nil, err | |
| 116 | + | } | |
| 117 | + | if native.Models == nil { | |
| 118 | + | return nil, fmt.Errorf("LM Studio discovery response is missing models") | |
| 119 | + | } | |
| 120 | + | for _, m := range native.Models { | |
| 121 | + | if m.Type != "llm" { | |
| 122 | + | continue | |
| 123 | + | } | |
| 124 | + | if len(m.Loaded) == 0 { | |
| 125 | + | add(m.Key, m.Name, m.Context) | |
| 126 | + | continue | |
| 127 | + | } | |
| 128 | + | for _, instance := range m.Loaded { | |
| 129 | + | id := instance.ID | |
| 130 | + | if id == "" { | |
| 131 | + | id = m.Key | |
| 132 | + | } | |
| 133 | + | // Unknown loaded context uses a conservative default, not the | |
| 134 | + | // model's theoretical training limit. | |
| 135 | + | add(id, m.Name, instance.Config.Context) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | return models, nil | |
| 139 | + | } | |
| @@ -0,0 +1,139 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/url" | |||
| 9 | + | "strings" | |||
| 10 | + | "time" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const LMStudioProviderID = "lmstudio" | |||
| 14 | + | ||||
| 15 | + | // NormalizeLMStudioURL accepts a server root or either API base URL. | |||
| 16 | + | func NormalizeLMStudioURL(value string) (string, error) { | |||
| 17 | + | u, err := url.Parse(strings.TrimSpace(value)) | |||
| 18 | + | if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" { | |||
| 19 | + | return "", fmt.Errorf("LM Studio server URL must be an http or https URL without credentials, query, or fragment") | |||
| 20 | + | } | |||
| 21 | + | u.Path = strings.TrimRight(u.Path, "/") | |||
| 22 | + | if strings.HasSuffix(u.Path, "/api/v1") { | |||
| 23 | + | u.Path = strings.TrimSuffix(u.Path, "/api/v1") | |||
| 24 | + | } else { | |||
| 25 | + | u.Path = strings.TrimSuffix(u.Path, "/v1") | |||
| 26 | + | } | |||
| 27 | + | u.RawPath = "" | |||
| 28 | + | return strings.TrimRight(u.String(), "/"), nil | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // DiscoverLMStudio lists models without loading them. Only older servers that | |||
| 32 | + | // lack the native endpoint fall back to the less descriptive OpenAI endpoint. | |||
| 33 | + | func DiscoverLMStudio(ctx context.Context, serverURL, apiKey string) ([]Model, error) { | |||
| 34 | + | root, err := NormalizeLMStudioURL(serverURL) | |||
| 35 | + | if err != nil { | |||
| 36 | + | return nil, err | |||
| 37 | + | } | |||
| 38 | + | client := &http.Client{Timeout: 3 * time.Second} | |||
| 39 | + | get := func(path string, out any) (int, error) { | |||
| 40 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, root+path, nil) | |||
| 41 | + | if err != nil { | |||
| 42 | + | return 0, err | |||
| 43 | + | } | |||
| 44 | + | if apiKey != "" { | |||
| 45 | + | req.Header.Set("Authorization", "Bearer "+apiKey) | |||
| 46 | + | } | |||
| 47 | + | resp, err := client.Do(req) | |||
| 48 | + | if err != nil { | |||
| 49 | + | if ctx.Err() != nil { | |||
| 50 | + | return 0, ctx.Err() | |||
| 51 | + | } | |||
| 52 | + | return 0, fmt.Errorf("LM Studio discovery request failed") | |||
| 53 | + | } | |||
| 54 | + | defer resp.Body.Close() | |||
| 55 | + | if resp.StatusCode != http.StatusOK { | |||
| 56 | + | return resp.StatusCode, fmt.Errorf("LM Studio discovery HTTP %d", resp.StatusCode) | |||
| 57 | + | } | |||
| 58 | + | if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 8<<20)).Decode(out); err != nil { | |||
| 59 | + | return resp.StatusCode, fmt.Errorf("LM Studio discovery returned invalid model data") | |||
| 60 | + | } | |||
| 61 | + | return resp.StatusCode, nil | |||
| 62 | + | } | |||
| 63 | + | var native struct { | |||
| 64 | + | Models []struct { | |||
| 65 | + | Type string `json:"type"` | |||
| 66 | + | Key string `json:"key"` | |||
| 67 | + | Name string `json:"display_name"` | |||
| 68 | + | Context int `json:"max_context_length"` | |||
| 69 | + | Loaded []struct { | |||
| 70 | + | ID string `json:"id"` | |||
| 71 | + | Config struct { | |||
| 72 | + | Context int `json:"context_length"` | |||
| 73 | + | } `json:"config"` | |||
| 74 | + | } `json:"loaded_instances"` | |||
| 75 | + | } `json:"models"` | |||
| 76 | + | } | |||
| 77 | + | status, err := get("/api/v1/models", &native) | |||
| 78 | + | var models []Model | |||
| 79 | + | seen := map[string]bool{} | |||
| 80 | + | add := func(id, name string, contextWindow int) { | |||
| 81 | + | if strings.TrimSpace(id) == "" || seen[id] { | |||
| 82 | + | return | |||
| 83 | + | } | |||
| 84 | + | seen[id] = true | |||
| 85 | + | if name == "" { | |||
| 86 | + | name = id | |||
| 87 | + | } | |||
| 88 | + | if contextWindow <= 0 { | |||
| 89 | + | contextWindow = 32768 | |||
| 90 | + | } | |||
| 91 | + | maxOutput := min(4096, max(1, contextWindow/4)) | |||
| 92 | + | models = append(models, Model{Provider: LMStudioProviderID, ID: id, DisplayName: name, ContextWindow: contextWindow, MaxOutput: maxOutput, BaseURL: root + "/v1", Source: "live"}) | |||
| 93 | + | } | |||
| 94 | + | if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented { | |||
| 95 | + | var fallback struct { | |||
| 96 | + | Data []struct { | |||
| 97 | + | ID string `json:"id"` | |||
| 98 | + | } `json:"data"` | |||
| 99 | + | } | |||
| 100 | + | if _, err := get("/v1/models", &fallback); err != nil { | |||
| 101 | + | return nil, err | |||
| 102 | + | } | |||
| 103 | + | if fallback.Data == nil { | |||
| 104 | + | return nil, fmt.Errorf("LM Studio discovery response is missing data") | |||
| 105 | + | } | |||
| 106 | + | for _, m := range fallback.Data { | |||
| 107 | + | if strings.Contains(strings.ToLower(m.ID), "embed") { | |||
| 108 | + | continue | |||
| 109 | + | } | |||
| 110 | + | add(m.ID, m.ID, 0) | |||
| 111 | + | } | |||
| 112 | + | return models, nil | |||
| 113 | + | } | |||
| 114 | + | if err != nil { | |||
| 115 | + | return nil, err | |||
| 116 | + | } | |||
| 117 | + | if native.Models == nil { | |||
| 118 | + | return nil, fmt.Errorf("LM Studio discovery response is missing models") | |||
| 119 | + | } | |||
| 120 | + | for _, m := range native.Models { | |||
| 121 | + | if m.Type != "llm" { | |||
| 122 | + | continue | |||
| 123 | + | } | |||
| 124 | + | if len(m.Loaded) == 0 { | |||
| 125 | + | add(m.Key, m.Name, m.Context) | |||
| 126 | + | continue | |||
| 127 | + | } | |||
| 128 | + | for _, instance := range m.Loaded { | |||
| 129 | + | id := instance.ID | |||
| 130 | + | if id == "" { | |||
| 131 | + | id = m.Key | |||
| 132 | + | } | |||
| 133 | + | // Unknown loaded context uses a conservative default, not the | |||
| 134 | + | // model's theoretical training limit. | |||
| 135 | + | add(id, m.Name, instance.Config.Context) | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | return models, nil | |||
| 139 | + | } | |||
A packages/provider/lmstudio_test.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "strings" | |
| 10 | + | "testing" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestDiscoverLMStudio(t *testing.T) { | |
| 14 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 15 | + | if r.URL.Path != "/api/v1/models" || r.Header.Get("Authorization") != "Bearer test-key" { | |
| 16 | + | t.Errorf("unexpected discovery request: %s", r.URL.Path) | |
| 17 | + | } | |
| 18 | + | fmt.Fprint(w, `{"models":[ | |
| 19 | + | {"type":"llm","key":"model-key","display_name":"Local Model","max_context_length":262144,"loaded_instances":[{"id":"instance","config":{"context_length":50176}}]}, | |
| 20 | + | {"type":"llm","key":"unloaded@q4","max_context_length":8192}, | |
| 21 | + | {"type":"llm","key":"unloaded@q4"}, | |
| 22 | + | {"type":"embedding","key":"embedding"}, | |
| 23 | + | {"type":"llm","key":""}]}`) | |
| 24 | + | })) | |
| 25 | + | defer server.Close() | |
| 26 | + | models, err := DiscoverLMStudio(context.Background(), server.URL+"/v1/", "test-key") | |
| 27 | + | if err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | if len(models) != 2 { | |
| 31 | + | t.Fatalf("models = %+v", models) | |
| 32 | + | } | |
| 33 | + | m := models[0] | |
| 34 | + | if m.ID != "instance" || m.ContextWindow != 50176 || m.DisplayName != "Local Model" || m.BaseURL != server.URL+"/v1" || m.Provider != LMStudioProviderID || m.Reasoning || m.PriceInput != 0 { | |
| 35 | + | t.Fatalf("unexpected metadata: %+v", m) | |
| 36 | + | } | |
| 37 | + | if models[1].ID != "unloaded@q4" || models[1].MaxOutput != 2048 { | |
| 38 | + | t.Fatalf("unloaded model = %+v", models[1]) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestDiscoverLMStudioFallbackAndErrors(t *testing.T) { | |
| 43 | + | for _, status := range []int{404, 405, 501, 401, 500, 200} { | |
| 44 | + | t.Run(fmt.Sprint(status), func(t *testing.T) { | |
| 45 | + | fallbackCalls := 0 | |
| 46 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 47 | + | if r.Header.Get("Authorization") != "" { | |
| 48 | + | t.Error("keyless request sent authorization") | |
| 49 | + | } | |
| 50 | + | if r.URL.Path == "/api/v1/models" { | |
| 51 | + | w.WriteHeader(status) | |
| 52 | + | fmt.Fprint(w, `{`) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | fallbackCalls++ | |
| 56 | + | fmt.Fprint(w, `{"data":[{"id":"qwen/local@q4"},{"id":"qwen/local@q4"},{"id":"text-embedding-test"}]}`) | |
| 57 | + | })) | |
| 58 | + | defer server.Close() | |
| 59 | + | models, err := DiscoverLMStudio(context.Background(), server.URL, "") | |
| 60 | + | if status == 404 || status == 405 || status == 501 { | |
| 61 | + | if err != nil || fallbackCalls != 1 || len(models) != 1 || models[0].ContextWindow != 32768 { | |
| 62 | + | t.Fatalf("models=%+v error=%v calls=%d", models, err, fallbackCalls) | |
| 63 | + | } | |
| 64 | + | } else if err == nil || fallbackCalls != 0 { | |
| 65 | + | t.Fatalf("error=%v fallback calls=%d", err, fallbackCalls) | |
| 66 | + | } | |
| 67 | + | }) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestDiscoverLMStudioEmptyAndCanceled(t *testing.T) { | |
| 72 | + | for _, body := range []string{`{"models":[]}`, `{}`, `null`} { | |
| 73 | + | t.Run(body, func(t *testing.T) { | |
| 74 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) })) | |
| 75 | + | defer server.Close() | |
| 76 | + | models, err := DiscoverLMStudio(context.Background(), server.URL, "") | |
| 77 | + | if body == `{"models":[]}` { | |
| 78 | + | if err != nil || len(models) != 0 { | |
| 79 | + | t.Fatalf("models=%v error=%v", models, err) | |
| 80 | + | } | |
| 81 | + | } else if err == nil { | |
| 82 | + | t.Fatal("missing model list accepted") | |
| 83 | + | } | |
| 84 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 85 | + | cancel() | |
| 86 | + | if _, err := DiscoverLMStudio(ctx, server.URL, ""); !errors.Is(err, context.Canceled) { | |
| 87 | + | t.Fatalf("cancel error=%v", err) | |
| 88 | + | } | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestNormalizeLMStudioURL(t *testing.T) { | |
| 94 | + | for _, suffix := range []string{"", "/", "/v1", "/api/v1/"} { | |
| 95 | + | got, err := NormalizeLMStudioURL("http://localhost:1234/proxy" + suffix) | |
| 96 | + | if err != nil || got != "http://localhost:1234/proxy" { | |
| 97 | + | t.Fatalf("normalize %q: %q, %v", suffix, got, err) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | for _, value := range []string{"", "localhost:1234", "file:///tmp", "https://secret@example.com", "http://localhost?token=secret"} { | |
| 101 | + | if _, err := NormalizeLMStudioURL(value); err == nil || strings.Contains(err.Error(), "secret") { | |
| 102 | + | t.Fatalf("invalid URL error: %v", err) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestManagedLocalProvidersRemainIndependent(t *testing.T) { | |
| 108 | + | SetManagedModels(nil) | |
| 109 | + | t.Cleanup(func() { SetManagedModels(nil) }) | |
| 110 | + | SetManagedModelsForProvider(LlamaCPPProviderID, []Model{{Provider: LlamaCPPProviderID, ID: "llama"}}) | |
| 111 | + | SetManagedModelsForProvider(LMStudioProviderID, []Model{{Provider: LMStudioProviderID, ID: "studio"}}) | |
| 112 | + | SetManagedModelsForProvider(LlamaCPPProviderID, nil) | |
| 113 | + | if _, err := FindModel(LMStudioProviderID, "studio"); err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | if _, err := FindModel(LlamaCPPProviderID, "llama"); err == nil { | |
| 117 | + | t.Fatal("stale llama model") | |
| 118 | + | } | |
| 119 | + | SetUserModels([]Model{{Provider: LMStudioProviderID, ID: "studio", ContextWindow: 12345, Source: "user"}}) | |
| 120 | + | t.Cleanup(func() { SetLiveModels(nil) }) | |
| 121 | + | model, err := FindModel(LMStudioProviderID, "studio") | |
| 122 | + | if err != nil || model.ContextWindow != 12345 { | |
| 123 | + | t.Fatalf("user override lost: %+v, %v", model, err) | |
| 124 | + | } | |
| 125 | + | } | |
| @@ -0,0 +1,125 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/http/httptest" | |||
| 9 | + | "strings" | |||
| 10 | + | "testing" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestDiscoverLMStudio(t *testing.T) { | |||
| 14 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 15 | + | if r.URL.Path != "/api/v1/models" || r.Header.Get("Authorization") != "Bearer test-key" { | |||
| 16 | + | t.Errorf("unexpected discovery request: %s", r.URL.Path) | |||
| 17 | + | } | |||
| 18 | + | fmt.Fprint(w, `{"models":[ | |||
| 19 | + | {"type":"llm","key":"model-key","display_name":"Local Model","max_context_length":262144,"loaded_instances":[{"id":"instance","config":{"context_length":50176}}]}, | |||
| 20 | + | {"type":"llm","key":"unloaded@q4","max_context_length":8192}, | |||
| 21 | + | {"type":"llm","key":"unloaded@q4"}, | |||
| 22 | + | {"type":"embedding","key":"embedding"}, | |||
| 23 | + | {"type":"llm","key":""}]}`) | |||
| 24 | + | })) | |||
| 25 | + | defer server.Close() | |||
| 26 | + | models, err := DiscoverLMStudio(context.Background(), server.URL+"/v1/", "test-key") | |||
| 27 | + | if err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | if len(models) != 2 { | |||
| 31 | + | t.Fatalf("models = %+v", models) | |||
| 32 | + | } | |||
| 33 | + | m := models[0] | |||
| 34 | + | if m.ID != "instance" || m.ContextWindow != 50176 || m.DisplayName != "Local Model" || m.BaseURL != server.URL+"/v1" || m.Provider != LMStudioProviderID || m.Reasoning || m.PriceInput != 0 { | |||
| 35 | + | t.Fatalf("unexpected metadata: %+v", m) | |||
| 36 | + | } | |||
| 37 | + | if models[1].ID != "unloaded@q4" || models[1].MaxOutput != 2048 { | |||
| 38 | + | t.Fatalf("unloaded model = %+v", models[1]) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestDiscoverLMStudioFallbackAndErrors(t *testing.T) { | |||
| 43 | + | for _, status := range []int{404, 405, 501, 401, 500, 200} { | |||
| 44 | + | t.Run(fmt.Sprint(status), func(t *testing.T) { | |||
| 45 | + | fallbackCalls := 0 | |||
| 46 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 47 | + | if r.Header.Get("Authorization") != "" { | |||
| 48 | + | t.Error("keyless request sent authorization") | |||
| 49 | + | } | |||
| 50 | + | if r.URL.Path == "/api/v1/models" { | |||
| 51 | + | w.WriteHeader(status) | |||
| 52 | + | fmt.Fprint(w, `{`) | |||
| 53 | + | return | |||
| 54 | + | } | |||
| 55 | + | fallbackCalls++ | |||
| 56 | + | fmt.Fprint(w, `{"data":[{"id":"qwen/local@q4"},{"id":"qwen/local@q4"},{"id":"text-embedding-test"}]}`) | |||
| 57 | + | })) | |||
| 58 | + | defer server.Close() | |||
| 59 | + | models, err := DiscoverLMStudio(context.Background(), server.URL, "") | |||
| 60 | + | if status == 404 || status == 405 || status == 501 { | |||
| 61 | + | if err != nil || fallbackCalls != 1 || len(models) != 1 || models[0].ContextWindow != 32768 { | |||
| 62 | + | t.Fatalf("models=%+v error=%v calls=%d", models, err, fallbackCalls) | |||
| 63 | + | } | |||
| 64 | + | } else if err == nil || fallbackCalls != 0 { | |||
| 65 | + | t.Fatalf("error=%v fallback calls=%d", err, fallbackCalls) | |||
| 66 | + | } | |||
| 67 | + | }) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestDiscoverLMStudioEmptyAndCanceled(t *testing.T) { | |||
| 72 | + | for _, body := range []string{`{"models":[]}`, `{}`, `null`} { | |||
| 73 | + | t.Run(body, func(t *testing.T) { | |||
| 74 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) })) | |||
| 75 | + | defer server.Close() | |||
| 76 | + | models, err := DiscoverLMStudio(context.Background(), server.URL, "") | |||
| 77 | + | if body == `{"models":[]}` { | |||
| 78 | + | if err != nil || len(models) != 0 { | |||
| 79 | + | t.Fatalf("models=%v error=%v", models, err) | |||
| 80 | + | } | |||
| 81 | + | } else if err == nil { | |||
| 82 | + | t.Fatal("missing model list accepted") | |||
| 83 | + | } | |||
| 84 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 85 | + | cancel() | |||
| 86 | + | if _, err := DiscoverLMStudio(ctx, server.URL, ""); !errors.Is(err, context.Canceled) { | |||
| 87 | + | t.Fatalf("cancel error=%v", err) | |||
| 88 | + | } | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestNormalizeLMStudioURL(t *testing.T) { | |||
| 94 | + | for _, suffix := range []string{"", "/", "/v1", "/api/v1/"} { | |||
| 95 | + | got, err := NormalizeLMStudioURL("http://localhost:1234/proxy" + suffix) | |||
| 96 | + | if err != nil || got != "http://localhost:1234/proxy" { | |||
| 97 | + | t.Fatalf("normalize %q: %q, %v", suffix, got, err) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | for _, value := range []string{"", "localhost:1234", "file:///tmp", "https://secret@example.com", "http://localhost?token=secret"} { | |||
| 101 | + | if _, err := NormalizeLMStudioURL(value); err == nil || strings.Contains(err.Error(), "secret") { | |||
| 102 | + | t.Fatalf("invalid URL error: %v", err) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestManagedLocalProvidersRemainIndependent(t *testing.T) { | |||
| 108 | + | SetManagedModels(nil) | |||
| 109 | + | t.Cleanup(func() { SetManagedModels(nil) }) | |||
| 110 | + | SetManagedModelsForProvider(LlamaCPPProviderID, []Model{{Provider: LlamaCPPProviderID, ID: "llama"}}) | |||
| 111 | + | SetManagedModelsForProvider(LMStudioProviderID, []Model{{Provider: LMStudioProviderID, ID: "studio"}}) | |||
| 112 | + | SetManagedModelsForProvider(LlamaCPPProviderID, nil) | |||
| 113 | + | if _, err := FindModel(LMStudioProviderID, "studio"); err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | if _, err := FindModel(LlamaCPPProviderID, "llama"); err == nil { | |||
| 117 | + | t.Fatal("stale llama model") | |||
| 118 | + | } | |||
| 119 | + | SetUserModels([]Model{{Provider: LMStudioProviderID, ID: "studio", ContextWindow: 12345, Source: "user"}}) | |||
| 120 | + | t.Cleanup(func() { SetLiveModels(nil) }) | |||
| 121 | + | model, err := FindModel(LMStudioProviderID, "studio") | |||
| 122 | + | if err != nil || model.ContextWindow != 12345 { | |||
| 123 | + | t.Fatalf("user override lost: %+v, %v", model, err) | |||
| 124 | + | } | |||
| 125 | + | } | |||
M packages/provider/models.go +21 -0
| @@ -508,6 +508,9 @@ func activeModels(availableOnly bool) []Model { | |||
| 508 | 508 | model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap) | |
| 509 | 509 | key := model.Provider + "\x00" + model.ID | |
| 510 | 510 | if i, ok := index[key]; ok { | |
| 511 | + | if out[i].Source == "user" { | |
| 512 | + | continue | |
| 513 | + | } | |
| 511 | 514 | out[i] = model | |
| 512 | 515 | continue | |
| 513 | 516 | } | |
| @@ -558,6 +561,24 @@ func SetManagedModels(models []Model) { | |||
| 558 | 561 | managedModels = append([]Model(nil), models...) | |
| 559 | 562 | } | |
| 560 | 563 | ||
| 564 | + | // SetManagedModelsForProvider replaces only one local server's entries. | |
| 565 | + | func SetManagedModelsForProvider(providerID string, models []Model) { | |
| 566 | + | activeMu.Lock() | |
| 567 | + | defer activeMu.Unlock() | |
| 568 | + | kept := managedModels[:0] | |
| 569 | + | for _, m := range managedModels { | |
| 570 | + | if m.Provider != providerID { | |
| 571 | + | kept = append(kept, m) | |
| 572 | + | } | |
| 573 | + | } | |
| 574 | + | managedModels = kept | |
| 575 | + | for _, m := range models { | |
| 576 | + | if m.Provider == providerID { | |
| 577 | + | managedModels = append(managedModels, m) | |
| 578 | + | } | |
| 579 | + | } | |
| 580 | + | } | |
| 581 | + | ||
| 561 | 582 | // FindModel returns a Model by id, optionally constrained by provider. | |
| 562 | 583 | // If provider is empty, the first matching id is returned. Looks up | |
| 563 | 584 | // against the merged catalog, including models hidden by account availability. | |
| @@ -508,6 +508,9 @@ func activeModels(availableOnly bool) []Model { | |||||
| 508 | model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap) | 508 | model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap) | ||
| 509 | key := model.Provider + "\x00" + model.ID | 509 | key := model.Provider + "\x00" + model.ID | ||
| 510 | if i, ok := index[key]; ok { | 510 | if i, ok := index[key]; ok { | ||
| 511 | + | if out[i].Source == "user" { | |||
| 512 | + | continue | |||
| 513 | + | } | |||
| 511 | out[i] = model | 514 | out[i] = model | ||
| 512 | continue | 515 | continue | ||
| 513 | } | 516 | } | ||
| @@ -558,6 +561,24 @@ func SetManagedModels(models []Model) { | |||||
| 558 | managedModels = append([]Model(nil), models...) | 561 | managedModels = append([]Model(nil), models...) | ||
| 559 | } | 562 | } | ||
| 560 | 563 | ||||
| 564 | + | // SetManagedModelsForProvider replaces only one local server's entries. | |||
| 565 | + | func SetManagedModelsForProvider(providerID string, models []Model) { | |||
| 566 | + | activeMu.Lock() | |||
| 567 | + | defer activeMu.Unlock() | |||
| 568 | + | kept := managedModels[:0] | |||
| 569 | + | for _, m := range managedModels { | |||
| 570 | + | if m.Provider != providerID { | |||
| 571 | + | kept = append(kept, m) | |||
| 572 | + | } | |||
| 573 | + | } | |||
| 574 | + | managedModels = kept | |||
| 575 | + | for _, m := range models { | |||
| 576 | + | if m.Provider == providerID { | |||
| 577 | + | managedModels = append(managedModels, m) | |||
| 578 | + | } | |||
| 579 | + | } | |||
| 580 | + | } | |||
| 581 | + | ||||
| 561 | // FindModel returns a Model by id, optionally constrained by provider. | 582 | // FindModel returns a Model by id, optionally constrained by provider. | ||
| 562 | // If provider is empty, the first matching id is returned. Looks up | 583 | // If provider is empty, the first matching id is returned. Looks up | ||
| 563 | // against the merged catalog, including models hidden by account availability. | 584 | // against the merged catalog, including models hidden by account availability. | ||