feat(provider): add lmstudio and align local provider default auth/base handling (#2193)

* feat(provider): add lmstudio vendor and local no-key behavior

* refactor(provider): consolidate protocol metadata and local tests

* fix(provider): sync lmstudio probing and model normalization

* test(web): format lmstudio model status cases for golines
This commit is contained in:
LC
2026-03-31 14:48:18 +08:00
committed by GitHub
parent d11f1bc064
commit ee02e30992
11 changed files with 307 additions and 72 deletions
+14 -8
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
const modelProbeTimeout = 800 * time.Millisecond
@@ -60,10 +61,14 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
return true
}
switch modelProtocol(m.Model) {
protocol := modelProtocol(m.Model)
switch protocol {
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
return true
case "ollama", "vllm":
}
if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
apiBase := strings.TrimSpace(m.APIBase)
return apiBase == "" || hasLocalAPIBase(apiBase)
}
@@ -81,7 +86,7 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool {
switch protocol {
case "ollama":
return probeOllamaModelFunc(apiBase, modelID)
case "vllm":
case "vllm", "lmstudio":
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
case "github-copilot", "copilot":
return probeTCPServiceFunc(apiBase)
@@ -100,11 +105,12 @@ func modelProbeAPIBase(m *config.ModelConfig) string {
return normalizeModelProbeAPIBase(apiBase)
}
switch modelProtocol(m.Model) {
case "ollama":
return "http://localhost:11434/v1"
case "vllm":
return "http://localhost:8000/v1"
protocol := modelProtocol(m.Model)
if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
return providers.DefaultAPIBaseForProtocol(protocol)
}
switch protocol {
case "github-copilot", "copilot":
return "localhost:4321"
default:
+50
View File
@@ -35,3 +35,53 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T
t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured")
}
}
func TestRequiresRuntimeProbe_LMStudio(t *testing.T) {
if !requiresRuntimeProbe(&config.ModelConfig{
Model: "lmstudio/openai/gpt-oss-20b",
}) {
t.Fatal("requiresRuntimeProbe(lmstudio with default base) = false, want true")
}
if requiresRuntimeProbe(&config.ModelConfig{
Model: "lmstudio/openai/gpt-oss-20b",
APIBase: "https://api.example.com/v1",
}) {
t.Fatal("requiresRuntimeProbe(lmstudio with remote base) = true, want false")
}
}
func TestModelProbeAPIBase_LMStudioDefault(t *testing.T) {
got := modelProbeAPIBase(&config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"})
if got != "http://localhost:1234/v1" {
t.Fatalf("modelProbeAPIBase(lmstudio) = %q, want %q", got, "http://localhost:1234/v1")
}
}
func TestProbeLocalModelAvailability_LMStudioUsesOpenAICompatibleProbe(t *testing.T) {
originalProbe := probeOpenAICompatibleModelFunc
defer func() { probeOpenAICompatibleModelFunc = originalProbe }()
called := false
probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
called = true
if apiBase != "http://localhost:1234/v1" {
t.Fatalf("apiBase = %q, want %q", apiBase, "http://localhost:1234/v1")
}
if modelID != "openai/gpt-oss-20b" {
t.Fatalf("modelID = %q, want %q", modelID, "openai/gpt-oss-20b")
}
if apiKey != "" {
t.Fatalf("apiKey = %q, want empty", apiKey)
}
return true
}
model := &config.ModelConfig{Model: "lmstudio/openai/gpt-oss-20b"}
if !probeLocalModelAvailability(model) {
t.Fatal("probeLocalModelAvailability(lmstudio) = false, want true")
}
if !called {
t.Fatal("probeOpenAICompatibleModelFunc was not called for lmstudio")
}
}