detached 64117f3

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
752752
753753 The `ollama` provider uses the OpenAI chat completions protocol internally, so it also works with any OpenAI-compatible server (vLLM, LM Studio, LocalAI, etc.).
754754
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+
755794 ### Local models with llama.cpp router mode
756795
757796 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 mode794 ### 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=...
208208 zot --provider openrouter
209209 ```
210210
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+
211263 ## Local llama.cpp router
212264
213265 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 openrouter208 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 router263 ## 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 {
174174 return "deepseek-v4-pro"
175175 case "google":
176176 return "gemini-2.5-pro"
177- case "ollama", provider.LlamaCPPProviderID:
177+ case "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID:
178178 return ""
179179 case "moonshotai", "moonshotai-cn":
180180 return "kimi-k2.6"
@@ -231,7 +231,7 @@ func defaultModelForProvider(prov string) string {
231231 // auto-fallback logic that picks any logged-in provider when the user's
232232 // preferred one has no credentials.
233233 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,
235235 "moonshotai", "moonshotai-cn",
236236 "cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola",
237237 "mistral", "zai",
@@ -410,7 +410,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
410410 // default model are skipped because selecting either one here would
411411 // fail before the user can choose a model.
412412 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 {
414414 continue
415415 }
416416 if !CredentialAvailable(other) {
@@ -425,7 +425,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
425425
426426 model := firstNonEmpty(args.Model, cfg.Model)
427427 if model == "" {
428- if provName == "ollama" || provName == provider.LlamaCPPProviderID {
428+ if provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID {
429429 return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
430430 }
431431 model = defaultModelForProvider(provName)
@@ -441,8 +441,12 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
441441 model = defaultModelForProvider(provName)
442442 }
443443 }
444+ if provName == provider.LMStudioProviderID && LMStudioConfigured() {
445+ // Restore transient metadata when resuming a local model after restart.
446+ _ = RefreshLMStudioModels(context.Background())
447+ }
444448 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) {
446450 // Local providers are intentionally open-catalogue: any model id the
447451 // configured server understands is valid, even if it is not cached.
448452 resolvedModel = provider.Model{
@@ -454,6 +458,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
454458 BaseURL: args.BaseURL,
455459 Source: provName,
456460 }
461+ if provName == provider.LMStudioProviderID {
462+ resolvedModel.ContextWindow = 32768
463+ resolvedModel.MaxOutput = 4096
464+ }
457465 err = nil
458466 }
459467 // Custom providers are open-catalogue like ollama: any model id the
@@ -535,12 +543,25 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
535543 // user didn't pass --base-url, use the model's URL. For ollama,
536544 // keep http://localhost:11434 as a fallback only after the model
537545 // 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.
539550 args.BaseURL = resolvedModel.BaseURL
540551 }
541552 if args.BaseURL == "" && provName == "ollama" {
542553 args.BaseURL = "http://localhost:11434"
543554 }
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+ }
544565 if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
545566 managementURL, _, configErr := ResolveLlamaCPPConfig()
546567 if configErr != nil {
@@ -814,7 +835,7 @@ func (r Resolved) NewClient() provider.Client {
814835 switch r.Provider {
815836 case "ollama":
816837 return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
817- case provider.LlamaCPPProviderID:
838+ case provider.LlamaCPPProviderID, provider.LMStudioProviderID:
818839 credential := r.Credential
819840 if credential == "local" {
820841 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's231 // 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 would410 // 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 continue414 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 the450 // 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 = nil465 err = nil
458 }466 }
459 // Custom providers are open-catalogue like ollama: any model id the467 // 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 model544 // 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.BaseURL550 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.Credential839 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 {
13801380 AuthManager: mgr,
13811381 LlamaCPPConfig: ResolveLlamaCPPConfig,
13821382 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
1383+ RefreshLMStudioModels: RefreshLMStudioModels,
1384+ LMStudioConfigured: LMStudioConfigured,
13831385 BuildAgent: buildAgent,
13841386 SetKimiCLIFallbackDisabled: SetKimiCLIFallbackDisabled,
13851387 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
399399 if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
400400 return v, "apikey", "", nil
401401 }
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+ }
402410 case "llama.cpp":
403411 baseURL, apiKey, resolveErr := resolveLlamaCPPConfig(ctx, commandMode)
404412 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", "", nil400 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() {
105105 // Gateway providers can serve routed model ids like
106106 // "deepseek/deepseek-v4-flash" even when the local catalog does not
107107 // 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.
110112 } else if m, err := provider.FindModel("", cfg.Model); err == nil {
111113 fix := defaultModelForProvider(cfg.Provider)
112114 fmt.Fprintf(os.Stderr,
@@ -114,7 +116,7 @@ func ValidateAndRepairConfig() {
114116 cfg.Model, m.Provider, cfg.Provider, fix)
115117 cfg.Model = fix
116118 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 {
118120 // Model id not in any catalog. Reset to provider's default.
119121 fix := defaultModelForProvider(cfg.Provider)
120122 fmt.Fprintf(os.Stderr,
@@ -142,6 +144,11 @@ func ValidateAndRepairConfig() {
142144 func RefreshModelsAsync() {
143145 go refreshModels()
144146 go refreshCopilotModelAvailability()
147+ go func() {
148+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
149+ defer cancel()
150+ _ = refreshLMStudioModels(ctx, apiKeyCommandSkip)
151+ }()
145152 go func() {
146153 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
147154 defer cancel()
@@ -185,7 +192,7 @@ func refreshLlamaCPPModels(ctx context.Context, commandMode apiKeyCommandMode) e
185192 if err != nil {
186193 return err
187194 }
188- provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL))
195+ provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL))
189196 return nil
190197 }
191198
@@ -263,6 +270,8 @@ func refreshModels() {
263270 return
264271 }
265272 provider.SetLiveModels(all)
273+ // Keep explicit metadata overrides above refreshed server catalogs.
274+ LoadUserModels()
266275 _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{
267276 FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()),
268277 Models: all,
@@ -105,8 +105,10 @@ func ValidateAndRepairConfig() {
105 // Gateway providers can serve routed model ids like105 // Gateway providers can serve routed model ids like
106 // "deepseek/deepseek-v4-flash" even when the local catalog does not106 // "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 = fix117 cfg.Model = fix
116 changed = true118 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 err193 return err
187 }194 }
188- provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL))195+ provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL))
189 return nil196 return nil
190 }197 }
191 198
@@ -263,6 +270,8 @@ func refreshModels() {
263 return270 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 {
183183 // the catalog before /model opens.
184184 RefreshLlamaCPPModels func(context.Context) error
185185
186+ // RefreshLMStudioModels is a no-op until a server URL is registered.
187+ RefreshLMStudioModels func(context.Context) error
188+ LMStudioConfigured func() bool
189+
186190 // BuildAgent is called after a successful login to (re)construct the
187191 // agent with the fresh credential. It returns the new agent and
188192 // the concrete provider/model in use.
@@ -2198,7 +2202,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
21982202 i.submitManualOAuthCode(act.SubmitCode)
21992203 }
22002204 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+ }
22022210 }
22032211 return false
22042212 }
@@ -4533,15 +4541,30 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
45334541 case "/model":
45344542 if len(parts) >= 2 {
45354543 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) {
45374545 i.mu.Lock()
45384546 i.statusOK = "refreshing models"
45394547 i.statusErr = ""
45404548 i.mu.Unlock()
45414549 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+ }
45454568 }()
45464569 } else {
45474570 i.openModelPickerAfterRefresh(nil)
@@ -4873,6 +4896,9 @@ func (i *Interactive) doLogout(target string) {
48734896 continue
48744897 }
48754898 }
4899+ if p == provider.LMStudioProviderID {
4900+ provider.SetManagedModelsForProvider(provider.LMStudioProviderID, nil)
4901+ }
48764902 if p == i.cfg.Provider {
48774903 clearedCurrent = true
48784904 }
@@ -183,6 +183,10 @@ type InteractiveConfig struct {
183 // the catalog before /model opens.183 // the catalog before /model opens.
184 RefreshLlamaCPPModels func(context.Context) error184 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 the190 // BuildAgent is called after a successful login to (re)construct the
187 // agent with the fresh credential. It returns the new agent and191 // 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 false2211 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 continue4896 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 = true4903 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) {
130130 }
131131 return models[i].ID < models[j].ID
132132 })
133- provider.SetManagedModels(provider.LlamaCPPModels(models, client.ServerURL))
133+ provider.SetManagedModelsForProvider(provider.LlamaCPPProviderID, provider.LlamaCPPModels(models, client.ServerURL))
134134 d.mu.Lock()
135135 if d.step != llamaClosed {
136136 d.models = models
@@ -130,7 +130,7 @@ func (d *llamaDialog) refresh(next llamaDialogStep, message string) {
130 }130 }
131 return models[i].ID < models[j].ID131 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 = models136 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 {
184184 }
185185 lines = append(lines, frameRule(th, width))
186186 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:"))
189189 if d.llamaEd == nil {
190190 d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent))
191191 }
@@ -196,8 +196,8 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
196196 }
197197 lines = append(lines, "", th.FG256(th.Muted, "enter continues - esc cancels"), frameRule(th, width))
198198 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):"))
201201 if d.llamaEd == nil {
202202 d.llamaEd = tui.NewEditor(th.AccentBar(th.Accent))
203203 }
@@ -537,7 +537,7 @@ func (d *loginDialog) handleProviderKey(k tui.Key) loginDialogAction {
537537 return loginDialogAction{}
538538 }
539539 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) {
541541 d.step = loginStepLlamaURL
542542 d.llamaEd = nil
543543 d.message = ""
@@ -566,7 +566,11 @@ func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction {
566566 value := strings.TrimSpace(d.llamaEd.SubmitValue())
567567 d.llamaEd.Clear()
568568 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)
570574 if err != nil {
571575 d.message = err.Error()
572576 return loginDialogAction{}
@@ -578,7 +582,7 @@ func (d *loginDialog) handleLlamaKey(k tui.Key) loginDialogAction {
578582 return loginDialogAction{}
579583 }
580584 return loginDialogAction{
581- SaveLlama: true, Provider: provider.LlamaCPPProviderID,
585+ SaveLlama: true, Provider: d.provider,
582586 LlamaURL: d.llamaURL, LlamaAPIKey: value,
583587 }
584588 }
@@ -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 = loginStepLlamaURL541 d.step = loginStepLlamaURL
542 d.llamaEd = nil542 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 {
136136 // APIKeyProviders is the ordered list shown by /login -> api key.
137137 func APIKeyProviders() []string {
138138 return []string{
139- "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp",
139+ "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp", "lmstudio",
140140 "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",
141141 "huggingface", "openrouter", "gondola", "mistral", "zai",
142142 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
@@ -136,7 +136,7 @@ func apiKeyProviderMessage() string {
136 // APIKeyProviders is the ordered list shown by /login -> api key.136 // APIKeyProviders is the ordered list shown by /login -> api key.
137 func APIKeyProviders() []string {137 func APIKeyProviders() []string {
138 return []string{138 return []string{
139- "anthropic", "openai", "kimi", "deepseek", "google", "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 {
99 return "Anthropic (Claude Pro/Max)"
1010 case "openai":
1111 return "OpenAI"
12+ case LMStudioProviderID:
13+ return "LM Studio"
1214 case "openai-codex":
1315 return "OpenAI Codex (ChatGPT Plus/Pro)"
1416 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 {
508508 model.ReasoningLevelMap = maps.Clone(model.ReasoningLevelMap)
509509 key := model.Provider + "\x00" + model.ID
510510 if i, ok := index[key]; ok {
511+ if out[i].Source == "user" {
512+ continue
513+ }
511514 out[i] = model
512515 continue
513516 }
@@ -558,6 +561,24 @@ func SetManagedModels(models []Model) {
558561 managedModels = append([]Model(nil), models...)
559562 }
560563
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+
561582 // FindModel returns a Model by id, optionally constrained by provider.
562583 // If provider is empty, the first matching id is returned. Looks up
563584 // 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.ID509 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] = model514 out[i] = model
512 continue515 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 up583 // 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.

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close