mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -48,6 +48,10 @@
|
|||||||
"model": "deepseek/deepseek-chat",
|
"model": "deepseek/deepseek-chat",
|
||||||
"api_key": "sk-your-deepseek-key"
|
"api_key": "sk-your-deepseek-key"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"model_name": "lmstudio-local",
|
||||||
|
"model": "lmstudio/openai/gpt-oss-20b"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"model_name": "longcat",
|
"model_name": "longcat",
|
||||||
"model": "longcat/LongCat-Flash-Thinking",
|
"model": "longcat/LongCat-Flash-Thinking",
|
||||||
|
|||||||
@@ -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) |
|
| **通义千问 (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) |
|
| **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) |
|
| **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) |
|
| **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 |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||||
@@ -710,6 +711,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>LM Studio (local)</b></summary>
|
||||||
|
|
||||||
|
```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.<br/>
|
||||||
|
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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Custom Proxy / LiteLLM</b></summary>
|
<summary><b>Custom Proxy / LiteLLM</b></summary>
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
| **通义千问 (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) |
|
| **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) |
|
| **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) |
|
| **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 |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
| **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.<br/>
|
||||||
|
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**
|
**Custom Proxy/API**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ Agent 读取 HEARTBEAT.md
|
|||||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) |
|
| **通义千问 (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) |
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
|
||||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key) |
|
| **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) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
|
||||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||||
@@ -506,6 +507,21 @@ Agent 读取 HEARTBEAT.md
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>LM Studio(本地)</b></summary>
|
||||||
|
|
||||||
|
```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`。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>自定义代理 / LiteLLM</b></summary>
|
<summary><b>自定义代理 / LiteLLM</b></summary>
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
|
| **通义千问 (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) |
|
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
|
||||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
|
| **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) |
|
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||||
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 |
|
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 |
|
||||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
| **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**
|
**自定义代理/API**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -17,6 +17,48 @@ import (
|
|||||||
"github.com/sipeed/picoclaw/pkg/providers/bedrock"
|
"github.com/sipeed/picoclaw/pkg/providers/bedrock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type protocolMeta struct {
|
||||||
|
defaultAPIBase string
|
||||||
|
emptyAPIKeyAllowed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var protocolMetaByName = map[string]protocolMeta{
|
||||||
|
"openai": {defaultAPIBase: "https://api.openai.com/v1"},
|
||||||
|
"openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"},
|
||||||
|
"litellm": {defaultAPIBase: "http://localhost:4000/v1"},
|
||||||
|
"lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true},
|
||||||
|
"novita": {defaultAPIBase: "https://api.novita.ai/openai"},
|
||||||
|
"groq": {defaultAPIBase: "https://api.groq.com/openai/v1"},
|
||||||
|
"zhipu": {defaultAPIBase: "https://open.bigmodel.cn/api/paas/v4"},
|
||||||
|
"gemini": {defaultAPIBase: "https://generativelanguage.googleapis.com/v1beta"},
|
||||||
|
"nvidia": {defaultAPIBase: "https://integrate.api.nvidia.com/v1"},
|
||||||
|
"ollama": {defaultAPIBase: "http://localhost:11434/v1", emptyAPIKeyAllowed: true},
|
||||||
|
"moonshot": {defaultAPIBase: "https://api.moonshot.cn/v1"},
|
||||||
|
"shengsuanyun": {defaultAPIBase: "https://router.shengsuanyun.com/api/v1"},
|
||||||
|
"deepseek": {defaultAPIBase: "https://api.deepseek.com/v1"},
|
||||||
|
"cerebras": {defaultAPIBase: "https://api.cerebras.ai/v1"},
|
||||||
|
"vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"},
|
||||||
|
"volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"},
|
||||||
|
"qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"},
|
||||||
|
"qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
|
||||||
|
"qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
|
||||||
|
"dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
|
||||||
|
"qwen-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"},
|
||||||
|
"dashscope-us": {defaultAPIBase: "https://dashscope-us.aliyuncs.com/compatible-mode/v1"},
|
||||||
|
"coding-plan": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
|
||||||
|
"alibaba-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
|
||||||
|
"qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
|
||||||
|
"coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
|
||||||
|
"alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
|
||||||
|
"vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true},
|
||||||
|
"mistral": {defaultAPIBase: "https://api.mistral.ai/v1"},
|
||||||
|
"avian": {defaultAPIBase: "https://api.avian.io/v1"},
|
||||||
|
"minimax": {defaultAPIBase: "https://api.minimaxi.com/v1"},
|
||||||
|
"longcat": {defaultAPIBase: "https://api.longcat.chat/openai"},
|
||||||
|
"modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"},
|
||||||
|
"mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"},
|
||||||
|
}
|
||||||
|
|
||||||
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
|
||||||
func createClaudeAuthProvider() (LLMProvider, error) {
|
func createClaudeAuthProvider() (LLMProvider, error) {
|
||||||
cred, err := getCredential("anthropic")
|
cred, err := getCredential("anthropic")
|
||||||
@@ -154,13 +196,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||||||
}
|
}
|
||||||
return provider, modelID, nil
|
return provider, modelID, nil
|
||||||
|
|
||||||
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
|
||||||
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
|
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
|
||||||
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
|
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
|
||||||
// All other OpenAI-compatible HTTP providers
|
// 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)
|
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||||
}
|
}
|
||||||
apiBase := cfg.APIBase
|
apiBase := cfg.APIBase
|
||||||
@@ -294,64 +336,30 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isEmptyAPIKeyAllowed(protocol string) bool {
|
||||||
|
meta, ok := protocolMetaByName[protocol]
|
||||||
|
return ok && meta.emptyAPIKeyAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmptyAPIKeyAllowedForProtocol reports whether a protocol allows requests
|
||||||
|
// without api_key when using its default local endpoint.
|
||||||
|
func IsEmptyAPIKeyAllowedForProtocol(protocol string) bool {
|
||||||
|
protocol = strings.ToLower(strings.TrimSpace(protocol))
|
||||||
|
return isEmptyAPIKeyAllowed(protocol)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultAPIBaseForProtocol returns the configured default API base for a protocol.
|
||||||
|
// It returns empty string if the protocol has no default base.
|
||||||
|
func DefaultAPIBaseForProtocol(protocol string) string {
|
||||||
|
protocol = strings.ToLower(strings.TrimSpace(protocol))
|
||||||
|
return getDefaultAPIBase(protocol)
|
||||||
|
}
|
||||||
|
|
||||||
// getDefaultAPIBase returns the default API base URL for a given protocol.
|
// getDefaultAPIBase returns the default API base URL for a given protocol.
|
||||||
func getDefaultAPIBase(protocol string) string {
|
func getDefaultAPIBase(protocol string) string {
|
||||||
switch protocol {
|
meta, ok := protocolMetaByName[protocol]
|
||||||
case "openai":
|
if !ok {
|
||||||
return "https://api.openai.com/v1"
|
|
||||||
case "openrouter":
|
|
||||||
return "https://openrouter.ai/api/v1"
|
|
||||||
case "litellm":
|
|
||||||
return "http://localhost:4000/v1"
|
|
||||||
case "novita":
|
|
||||||
return "https://api.novita.ai/openai"
|
|
||||||
case "groq":
|
|
||||||
return "https://api.groq.com/openai/v1"
|
|
||||||
case "zhipu":
|
|
||||||
return "https://open.bigmodel.cn/api/paas/v4"
|
|
||||||
case "gemini":
|
|
||||||
return "https://generativelanguage.googleapis.com/v1beta"
|
|
||||||
case "nvidia":
|
|
||||||
return "https://integrate.api.nvidia.com/v1"
|
|
||||||
case "ollama":
|
|
||||||
return "http://localhost:11434/v1"
|
|
||||||
case "moonshot":
|
|
||||||
return "https://api.moonshot.cn/v1"
|
|
||||||
case "shengsuanyun":
|
|
||||||
return "https://router.shengsuanyun.com/api/v1"
|
|
||||||
case "deepseek":
|
|
||||||
return "https://api.deepseek.com/v1"
|
|
||||||
case "cerebras":
|
|
||||||
return "https://api.cerebras.ai/v1"
|
|
||||||
case "vivgrid":
|
|
||||||
return "https://api.vivgrid.com/v1"
|
|
||||||
case "volcengine":
|
|
||||||
return "https://ark.cn-beijing.volces.com/api/v3"
|
|
||||||
case "qwen":
|
|
||||||
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
||||||
case "qwen-intl", "qwen-international", "dashscope-intl":
|
|
||||||
return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
|
||||||
case "qwen-us", "dashscope-us":
|
|
||||||
return "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
|
|
||||||
case "coding-plan", "alibaba-coding", "qwen-coding":
|
|
||||||
return "https://coding-intl.dashscope.aliyuncs.com/v1"
|
|
||||||
case "coding-plan-anthropic", "alibaba-coding-anthropic":
|
|
||||||
return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
|
||||||
case "vllm":
|
|
||||||
return "http://localhost:8000/v1"
|
|
||||||
case "mistral":
|
|
||||||
return "https://api.mistral.ai/v1"
|
|
||||||
case "avian":
|
|
||||||
return "https://api.avian.io/v1"
|
|
||||||
case "minimax":
|
|
||||||
return "https://api.minimaxi.com/v1"
|
|
||||||
case "longcat":
|
|
||||||
return "https://api.longcat.chat/openai"
|
|
||||||
case "modelscope":
|
|
||||||
return "https://api-inference.modelscope.cn/v1"
|
|
||||||
case "mimo":
|
|
||||||
return "https://api.xiaomimimo.com/v1"
|
|
||||||
default:
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
return meta.defaultAPIBase
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
|
|||||||
{"vllm", "vllm"},
|
{"vllm", "vllm"},
|
||||||
{"deepseek", "deepseek"},
|
{"deepseek", "deepseek"},
|
||||||
{"ollama", "ollama"},
|
{"ollama", "ollama"},
|
||||||
|
{"lmstudio", "lmstudio"},
|
||||||
{"longcat", "longcat"},
|
{"longcat", "longcat"},
|
||||||
{"modelscope", "modelscope"},
|
{"modelscope", "modelscope"},
|
||||||
{"mimo", "mimo"},
|
{"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) {
|
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-litellm",
|
ModelName: "test-litellm",
|
||||||
@@ -173,6 +180,85 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_LocalProviders(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
modelName string
|
||||||
|
model string
|
||||||
|
apiKey string
|
||||||
|
wantModelID string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "LMStudio with API key",
|
||||||
|
modelName: "test-lmstudio",
|
||||||
|
model: "lmstudio/openai/gpt-oss-20b",
|
||||||
|
apiKey: "test-key",
|
||||||
|
wantModelID: "openai/gpt-oss-20b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LMStudio without API key",
|
||||||
|
modelName: "test-lmstudio",
|
||||||
|
model: "lmstudio/openai/gpt-oss-20b",
|
||||||
|
apiKey: "",
|
||||||
|
wantModelID: "openai/gpt-oss-20b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ollama with API key",
|
||||||
|
modelName: "test-ollama",
|
||||||
|
model: "ollama/llama3.1:8b",
|
||||||
|
apiKey: "test-key",
|
||||||
|
wantModelID: "llama3.1:8b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ollama without API key",
|
||||||
|
modelName: "test-ollama",
|
||||||
|
model: "ollama/llama3.1:8b",
|
||||||
|
apiKey: "",
|
||||||
|
wantModelID: "llama3.1:8b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VLLM with API key",
|
||||||
|
modelName: "test-vllm",
|
||||||
|
model: "vllm/Qwen/Qwen3-8B",
|
||||||
|
apiKey: "test-key",
|
||||||
|
wantModelID: "Qwen/Qwen3-8B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VLLM without API key",
|
||||||
|
modelName: "test-vllm",
|
||||||
|
model: "vllm/Qwen/Qwen3-8B",
|
||||||
|
apiKey: "",
|
||||||
|
wantModelID: "Qwen/Qwen3-8B",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: tt.modelName,
|
||||||
|
Model: tt.model,
|
||||||
|
}
|
||||||
|
if tt.apiKey != "" {
|
||||||
|
cfg.SetAPIKey(tt.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 != tt.wantModelID {
|
||||||
|
t.Errorf("modelID = %q, want %q", modelID, tt.wantModelID)
|
||||||
|
}
|
||||||
|
if _, ok := provider.(*HTTPProvider); !ok {
|
||||||
|
t.Fatalf("expected *HTTPProvider, got %T", provider)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateProviderFromConfig_LongCat(t *testing.T) {
|
func TestCreateProviderFromConfig_LongCat(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-longcat",
|
ModelName: "test-longcat",
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ type Option func(*Provider)
|
|||||||
|
|
||||||
const defaultRequestTimeout = common.DefaultRequestTimeout
|
const defaultRequestTimeout = common.DefaultRequestTimeout
|
||||||
|
|
||||||
|
var stripModelPrefixProviders = map[string]struct{}{
|
||||||
|
"litellm": {},
|
||||||
|
"moonshot": {},
|
||||||
|
"nvidia": {},
|
||||||
|
"groq": {},
|
||||||
|
"ollama": {},
|
||||||
|
"deepseek": {},
|
||||||
|
"google": {},
|
||||||
|
"openrouter": {},
|
||||||
|
"zhipu": {},
|
||||||
|
"mistral": {},
|
||||||
|
"vivgrid": {},
|
||||||
|
"minimax": {},
|
||||||
|
"novita": {},
|
||||||
|
"lmstudio": {},
|
||||||
|
}
|
||||||
|
|
||||||
func WithMaxTokensField(maxTokensField string) Option {
|
func WithMaxTokensField(maxTokensField string) Option {
|
||||||
return func(p *Provider) {
|
return func(p *Provider) {
|
||||||
p.maxTokensField = maxTokensField
|
p.maxTokensField = maxTokensField
|
||||||
@@ -397,13 +414,11 @@ func normalizeModel(model, apiBase string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefix := strings.ToLower(before)
|
prefix := strings.ToLower(before)
|
||||||
switch prefix {
|
if _, ok := stripModelPrefixProviders[prefix]; ok {
|
||||||
case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
|
|
||||||
"openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita":
|
|
||||||
return after
|
return after
|
||||||
default:
|
|
||||||
return model
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any {
|
func buildToolsList(tools []ToolDefinition, nativeSearch bool) []any {
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T) {
|
func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) {
|
||||||
var requestBody map[string]any
|
var requestBody map[string]any
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -474,6 +474,11 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T
|
|||||||
input: "ollama/qwen2.5:14b",
|
input: "ollama/qwen2.5:14b",
|
||||||
wantModel: "qwen2.5:14b",
|
wantModel: "qwen2.5:14b",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "strips lmstudio prefix and keeps nested model",
|
||||||
|
input: "lmstudio/openai/gpt-oss-20b",
|
||||||
|
wantModel: "openai/gpt-oss-20b",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "strips deepseek prefix",
|
name: "strips deepseek prefix",
|
||||||
input: "deepseek/deepseek-chat",
|
input: "deepseek/deepseek-chat",
|
||||||
@@ -579,6 +584,9 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
|
|||||||
if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" {
|
if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" {
|
||||||
t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat")
|
t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat")
|
||||||
}
|
}
|
||||||
|
if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" {
|
||||||
|
t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b")
|
||||||
|
}
|
||||||
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
|
if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
|
||||||
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
|
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
const modelProbeTimeout = 800 * time.Millisecond
|
const modelProbeTimeout = 800 * time.Millisecond
|
||||||
@@ -60,10 +61,14 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
switch modelProtocol(m.Model) {
|
protocol := modelProtocol(m.Model)
|
||||||
|
|
||||||
|
switch protocol {
|
||||||
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
|
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
|
||||||
return true
|
return true
|
||||||
case "ollama", "vllm":
|
}
|
||||||
|
|
||||||
|
if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
|
||||||
apiBase := strings.TrimSpace(m.APIBase)
|
apiBase := strings.TrimSpace(m.APIBase)
|
||||||
return apiBase == "" || hasLocalAPIBase(apiBase)
|
return apiBase == "" || hasLocalAPIBase(apiBase)
|
||||||
}
|
}
|
||||||
@@ -81,7 +86,7 @@ func probeLocalModelAvailability(m *config.ModelConfig) bool {
|
|||||||
switch protocol {
|
switch protocol {
|
||||||
case "ollama":
|
case "ollama":
|
||||||
return probeOllamaModelFunc(apiBase, modelID)
|
return probeOllamaModelFunc(apiBase, modelID)
|
||||||
case "vllm":
|
case "vllm", "lmstudio":
|
||||||
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
||||||
case "github-copilot", "copilot":
|
case "github-copilot", "copilot":
|
||||||
return probeTCPServiceFunc(apiBase)
|
return probeTCPServiceFunc(apiBase)
|
||||||
@@ -100,11 +105,12 @@ func modelProbeAPIBase(m *config.ModelConfig) string {
|
|||||||
return normalizeModelProbeAPIBase(apiBase)
|
return normalizeModelProbeAPIBase(apiBase)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch modelProtocol(m.Model) {
|
protocol := modelProtocol(m.Model)
|
||||||
case "ollama":
|
if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
|
||||||
return "http://localhost:11434/v1"
|
return providers.DefaultAPIBaseForProtocol(protocol)
|
||||||
case "vllm":
|
}
|
||||||
return "http://localhost:8000/v1"
|
|
||||||
|
switch protocol {
|
||||||
case "github-copilot", "copilot":
|
case "github-copilot", "copilot":
|
||||||
return "localhost:4321"
|
return "localhost:4321"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -35,3 +35,53 @@ func TestProbeLocalModelAvailability_OpenAICompatibleIncludesAPIKey(t *testing.T
|
|||||||
t.Fatal("probeLocalModelAvailability() = false, want true when api_key is configured")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user