diff --git a/config/config.example.json b/config/config.example.json index ff2969dcb..814c82503 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -48,6 +48,10 @@ "model": "deepseek/deepseek-chat", "api_key": "sk-your-deepseek-key" }, + { + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" + }, { "model_name": "longcat", "model": "longcat/LongCat-Flash-Thinking", diff --git a/docs/configuration.md b/docs/configuration.md index 61eb24b14..58930cbfa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -563,6 +563,7 @@ For complete documentation, see [`security_configuration.md`](security_configura | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | @@ -710,6 +711,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' +
+LM Studio (local) + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.
+PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server. + +
+
Custom Proxy / LiteLLM diff --git a/docs/providers.md b/docs/providers.md index 43cba55b5..f45aa5f3b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -56,6 +56,7 @@ This design also enables **multi-agent support** with flexible provider selectio | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | @@ -226,6 +227,18 @@ For direct Anthropic API access or custom endpoints that only support Anthropic' } ``` +**LM Studio (local)** + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.
+PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server. + **Custom Proxy/API** ```json diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index d4366b6a8..a405df09c 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -365,6 +365,7 @@ Agent 读取 HEARTBEAT.md | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | @@ -506,6 +507,21 @@ Agent 读取 HEARTBEAT.md
+
+LM Studio(本地) + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。 +PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。 + +
+
自定义代理 / LiteLLM diff --git a/docs/zh/providers.md b/docs/zh/providers.md index d19dcdd89..04b2f7a88 100644 --- a/docs/zh/providers.md +++ b/docs/zh/providers.md @@ -53,6 +53,7 @@ | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | +| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | @@ -211,6 +212,18 @@ } ``` +**LM Studio(本地)** + +```json +{ + "model_name": "lmstudio-local", + "model": "lmstudio/openai/gpt-oss-20b" +} +``` + +`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。 +PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`。 + **自定义代理/API** ```json diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 962e6ae19..fc4c59e08 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -154,13 +154,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } return provider, modelID, nil - case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", + case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "coding-plan", "alibaba-coding", "qwen-coding", "mimo": // All other OpenAI-compatible HTTP providers - if cfg.APIKey() == "" && cfg.APIBase == "" { + if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase @@ -294,6 +294,15 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } } +func isEmptyAPIKeyAllowed(protocol string) bool { + switch protocol { + case "ollama", "vllm", "lmstudio": + return true + default: + return false + } +} + // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { switch protocol { @@ -303,6 +312,8 @@ func getDefaultAPIBase(protocol string) string { return "https://openrouter.ai/api/v1" case "litellm": return "http://localhost:4000/v1" + case "lmstudio": + return "http://localhost:1234/v1" case "novita": return "https://api.novita.ai/openai" case "groq": diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index f1fe02cc2..ebd887220 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -121,6 +121,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { {"vllm", "vllm"}, {"deepseek", "deepseek"}, {"ollama", "ollama"}, + {"lmstudio", "lmstudio"}, {"longcat", "longcat"}, {"modelscope", "modelscope"}, {"mimo", "mimo"}, @@ -153,6 +154,12 @@ func TestGetDefaultAPIBase_LiteLLM(t *testing.T) { } } +func TestGetDefaultAPIBase_LMStudio(t *testing.T) { + if got := getDefaultAPIBase("lmstudio"); got != "http://localhost:1234/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "lmstudio", got, "http://localhost:1234/v1") + } +} + func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-litellm", @@ -173,6 +180,135 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { } } +func TestCreateProviderFromConfig_LMStudio_WithAPIKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-lmstudio", + Model: "lmstudio/openai/gpt-oss-20b", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "openai/gpt-oss-20b" { + t.Errorf("modelID = %q, want %q", modelID, "openai/gpt-oss-20b") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestCreateProviderFromConfig_LMStudio_NoAPIKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-lmstudio", + Model: "lmstudio/openai/gpt-oss-20b", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "openai/gpt-oss-20b" { + t.Errorf("modelID = %q, want %q", modelID, "openai/gpt-oss-20b") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestCreateProviderFromConfig_Ollama_WithAPIKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-ollama", + Model: "ollama/llama3.1:8b", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "llama3.1:8b" { + t.Errorf("modelID = %q, want %q", modelID, "llama3.1:8b") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestCreateProviderFromConfig_Ollama_NoAPIKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-ollama", + Model: "ollama/llama3.1:8b", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "llama3.1:8b" { + t.Errorf("modelID = %q, want %q", modelID, "llama3.1:8b") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestCreateProviderFromConfig_VLLM_WithAPIKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-vllm", + Model: "vllm/Qwen/Qwen3-8B", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "Qwen/Qwen3-8B" { + t.Errorf("modelID = %q, want %q", modelID, "Qwen/Qwen3-8B") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + +func TestCreateProviderFromConfig_VLLM_NoAPIKey(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-vllm", + Model: "vllm/Qwen/Qwen3-8B", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "Qwen/Qwen3-8B" { + t.Errorf("modelID = %q, want %q", modelID, "Qwen/Qwen3-8B") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + func TestCreateProviderFromConfig_LongCat(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-longcat",