mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-07-28 01:27:58 +00:00
feat(provider,web,asr): enhance model management with explicit provider metadata (#2701)
* feat(provider,web): enhance model management with provider options * fix(asr): enhance compatibility for ElevenLabs transcription model * fix(provider,web): align provider availability predicates and add flow gating * fix(web,asr): preserve legacy elevenlabs transcription configs * fix(provider,web,asr): normalize elevenlabs configs and gate default chat models * fix: tighten provider catalog and elevenlabs compatibility
This commit is contained in:
@@ -82,7 +82,8 @@ Notes:
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "elevenlabs-asr",
|
||||
"model": "elevenlabs/scribe_v1"
|
||||
"provider": "elevenlabs",
|
||||
"model": "scribe_v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -130,7 +131,7 @@ PicoClaw currently supports three main ASR routes:
|
||||
|
||||
| Route | Example models | Behavior |
|
||||
| --- | --- | --- |
|
||||
| ElevenLabs ASR | `elevenlabs/scribe_v1` | Uses the ElevenLabs transcription API. |
|
||||
| ElevenLabs ASR | `provider: elevenlabs`, `model: scribe_v1` | Uses the ElevenLabs transcription API. |
|
||||
| Whisper endpoint models | `openai/whisper-1`, `groq/whisper-large-v3` | Uses an OpenAI-compatible `/audio/transcriptions` endpoint. |
|
||||
| Audio-capable chat models **(Under construction)** | `openai/gpt-4o-audio-preview`, `gemini/gemini-2.5-flash` | Sends audio to a multimodal chat model and asks it to transcribe. |
|
||||
|
||||
@@ -142,7 +143,7 @@ If you are unsure which one to pick, choose Groq Whisper or ElevenLabs first.
|
||||
|
||||
1. **Preferred path**: resolve `voice.model_name` against `model_list`.
|
||||
2. If that resolved model is:
|
||||
- `elevenlabs/...`, PicoClaw uses the ElevenLabs transcriber.
|
||||
- an `elevenlabs` provider model, PicoClaw uses the ElevenLabs transcriber.
|
||||
- an OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber.
|
||||
- an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`.
|
||||
3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries.
|
||||
|
||||
@@ -82,7 +82,8 @@ model_list:
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "elevenlabs-asr",
|
||||
"model": "elevenlabs/scribe_v1"
|
||||
"provider": "elevenlabs",
|
||||
"model": "scribe_v1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -130,7 +131,7 @@ PicoClaw 目前主要支持三种 ASR 路径:
|
||||
|
||||
| 路径 | 示例模型 | 行为说明 |
|
||||
| --- | --- | --- |
|
||||
| ElevenLabs ASR | `elevenlabs/scribe_v1` | 使用 ElevenLabs 的语音转录接口。 |
|
||||
| ElevenLabs ASR | `provider: elevenlabs`,`model: scribe_v1` | 使用 ElevenLabs 的语音转录接口。 |
|
||||
| Whisper 接口模型 | `openai/whisper-1`、`groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 |
|
||||
| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 |
|
||||
|
||||
@@ -142,7 +143,7 @@ PicoClaw 目前主要支持三种 ASR 路径:
|
||||
|
||||
1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。
|
||||
2. 如果找到的模型属于以下类型:
|
||||
- `elevenlabs/...`,则使用 ElevenLabs transcriber。
|
||||
- `provider=elevenlabs` 的模型,则使用 ElevenLabs transcriber。
|
||||
- OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。
|
||||
- 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。
|
||||
3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。
|
||||
|
||||
+21
-6
@@ -8,6 +8,12 @@ import (
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
const elevenLabsSupportedModelID = "scribe_v1"
|
||||
|
||||
func ElevenLabsSupportedModelID() string {
|
||||
return elevenLabsSupportedModelID
|
||||
}
|
||||
|
||||
type Transcriber interface {
|
||||
Name() string
|
||||
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
|
||||
@@ -72,14 +78,23 @@ func whisperModelID(modelCfg *config.ModelConfig) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func isElevenLabsTranscriptionModel(modelCfg *config.ModelConfig) bool {
|
||||
if modelCfg == nil || modelCfg.APIKey() == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
return protocol == "elevenlabs"
|
||||
}
|
||||
|
||||
func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
|
||||
if modelCfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
|
||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
|
||||
if isElevenLabsTranscriptionModel(modelCfg) {
|
||||
_, modelID := providers.ExtractProtocol(modelCfg)
|
||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID)
|
||||
}
|
||||
if modelID := whisperModelID(modelCfg); modelID != "" {
|
||||
return NewWhisperTranscriber(modelCfg)
|
||||
@@ -95,9 +110,9 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe
|
||||
return nil
|
||||
}
|
||||
|
||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
|
||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
|
||||
if isElevenLabsTranscriptionModel(modelCfg) {
|
||||
_, modelID := providers.ExtractProtocol(modelCfg)
|
||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID)
|
||||
}
|
||||
if modelID := whisperModelID(modelCfg); modelID != "" {
|
||||
return NewWhisperTranscriber(modelCfg)
|
||||
|
||||
@@ -46,6 +46,21 @@ func TestDetectTranscriber(t *testing.T) {
|
||||
},
|
||||
wantName: "elevenlabs",
|
||||
},
|
||||
{
|
||||
name: "explicit elevenlabs provider selects elevenlabs transcriber",
|
||||
cfg: &config.Config{
|
||||
Voice: config.VoiceConfig{ModelName: "my-asr-model"},
|
||||
ModelList: []*config.ModelConfig{
|
||||
{
|
||||
ModelName: "my-asr-model",
|
||||
Provider: "elevenlabs",
|
||||
Model: "scribe_v1",
|
||||
APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantName: "elevenlabs",
|
||||
},
|
||||
{
|
||||
name: "voice model name alias selects whisper transcriber for groq",
|
||||
cfg: &config.Config{
|
||||
|
||||
@@ -20,19 +20,24 @@ import (
|
||||
type ElevenLabsTranscriber struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
modelID string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewElevenLabsTranscriber(apiKey, apiBase string) *ElevenLabsTranscriber {
|
||||
func NewElevenLabsTranscriber(apiKey, apiBase, modelID string) *ElevenLabsTranscriber {
|
||||
logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""})
|
||||
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.elevenlabs.io"
|
||||
}
|
||||
if modelID == "" || modelID != ElevenLabsSupportedModelID() {
|
||||
modelID = ElevenLabsSupportedModelID()
|
||||
}
|
||||
|
||||
return &ElevenLabsTranscriber{
|
||||
apiKey: apiKey,
|
||||
apiBase: apiBase,
|
||||
modelID: modelID,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 120 * time.Second,
|
||||
},
|
||||
@@ -74,7 +79,7 @@ func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath st
|
||||
return nil, fmt.Errorf("failed to copy file content: %w", err)
|
||||
}
|
||||
|
||||
if err = writer.WriteField("model_id", "scribe_v1"); err != nil {
|
||||
if err = writer.WriteField("model_id", t.modelID); err != nil {
|
||||
return nil, fmt.Errorf("failed to write model_id field: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,14 @@ package asr
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -14,7 +18,7 @@ import (
|
||||
var _ Transcriber = (*ElevenLabsTranscriber)(nil)
|
||||
|
||||
func TestElevenLabsTranscriberName(t *testing.T) {
|
||||
tr := NewElevenLabsTranscriber("sk_test", "")
|
||||
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
|
||||
if got := tr.Name(); got != "elevenlabs" {
|
||||
t.Errorf("Name() = %q, want %q", got, "elevenlabs")
|
||||
}
|
||||
@@ -35,6 +39,35 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||
if r.Header.Get("Xi-Api-Key") != "sk_test" {
|
||||
t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key"))
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMediaType() error = %v", err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content-type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
reader := multipart.NewReader(r.Body, params["boundary"])
|
||||
var gotModelID string
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NextPart() error = %v", err)
|
||||
}
|
||||
if part.FormName() != "model_id" {
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll(part) error = %v", err)
|
||||
}
|
||||
gotModelID = strings.TrimSpace(string(body))
|
||||
}
|
||||
if gotModelID != "scribe_v1" {
|
||||
t.Fatalf("model_id = %q, want %q", gotModelID, "scribe_v1")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(TranscriptionResponse{
|
||||
Text: "hello from elevenlabs",
|
||||
@@ -43,7 +76,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tr := NewElevenLabsTranscriber("sk_test", "")
|
||||
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
|
||||
tr.apiBase = srv.URL
|
||||
|
||||
resp, err := tr.Transcribe(context.Background(), audioPath)
|
||||
@@ -64,7 +97,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tr := NewElevenLabsTranscriber("sk_bad", "")
|
||||
tr := NewElevenLabsTranscriber("sk_bad", "", "scribe_v1")
|
||||
tr.apiBase = srv.URL
|
||||
|
||||
_, err := tr.Transcribe(context.Background(), audioPath)
|
||||
@@ -74,10 +107,54 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("missing file", func(t *testing.T) {
|
||||
tr := NewElevenLabsTranscriber("sk_test", "")
|
||||
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
|
||||
_, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing file, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported model falls back to scribe_v1", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMediaType() error = %v", err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content-type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
reader := multipart.NewReader(r.Body, params["boundary"])
|
||||
var gotModelID string
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NextPart() error = %v", err)
|
||||
}
|
||||
if part.FormName() != "model_id" {
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll(part) error = %v", err)
|
||||
}
|
||||
gotModelID = strings.TrimSpace(string(body))
|
||||
}
|
||||
if gotModelID != "scribe_v1" {
|
||||
t.Fatalf("model_id = %q, want runtime fallback to %q", gotModelID, "scribe_v1")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(TranscriptionResponse{Text: "ok"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
tr := NewElevenLabsTranscriber("sk_test", "", "unsupported-model")
|
||||
tr.apiBase = srv.URL
|
||||
|
||||
if _, err := tr.Transcribe(context.Background(), audioPath); err != nil {
|
||||
t.Fatalf("Transcribe() error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,19 +110,7 @@ func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) {
|
||||
if provider := strings.TrimSpace(cfg.Provider); provider != "" {
|
||||
return NormalizeProvider(provider), model
|
||||
}
|
||||
if model == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
protocol, rest, found := strings.Cut(model, "/")
|
||||
if !found {
|
||||
return "openai", model
|
||||
}
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
if protocol == "" {
|
||||
return "", strings.TrimSpace(rest)
|
||||
}
|
||||
return NormalizeProvider(protocol), strings.TrimSpace(rest)
|
||||
return SplitModelProviderAndID(model, "openai")
|
||||
}
|
||||
|
||||
// ResolveAPIBase returns the configured API base, or the protocol default when
|
||||
@@ -154,6 +142,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
}
|
||||
|
||||
protocol, modelID := ExtractProtocol(cfg)
|
||||
authMethod := strings.ToLower(strings.TrimSpace(cfg.AuthMethod))
|
||||
|
||||
userAgent := cfg.UserAgent
|
||||
if userAgent == "" {
|
||||
@@ -163,7 +152,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
switch protocol {
|
||||
case "openai":
|
||||
// OpenAI with OAuth/token auth (Codex-style)
|
||||
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
||||
if authMethod == "oauth" || authMethod == "token" {
|
||||
provider, err := createCodexAuthProvider()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
@@ -320,7 +309,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||
return finalizeProviderFromConfig(provider, modelID, cfg)
|
||||
|
||||
case "anthropic":
|
||||
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
||||
if authMethod == "oauth" || authMethod == "token" {
|
||||
// Use OAuth credentials from auth store
|
||||
provider, err := createClaudeAuthProvider()
|
||||
if err != nil {
|
||||
@@ -431,7 +420,7 @@ func finalizeProviderFromConfig(
|
||||
}
|
||||
|
||||
func isEmptyAPIKeyAllowed(protocol string) bool {
|
||||
meta, ok := protocolMetaByName[protocol]
|
||||
meta, ok := protocolMetaForName(protocol)
|
||||
return ok && meta.emptyAPIKeyAllowed
|
||||
}
|
||||
|
||||
@@ -451,9 +440,19 @@ func DefaultAPIBaseForProtocol(protocol string) string {
|
||||
|
||||
// getDefaultAPIBase returns the default API base URL for a given protocol.
|
||||
func getDefaultAPIBase(protocol string) string {
|
||||
meta, ok := protocolMetaByName[protocol]
|
||||
meta, ok := protocolMetaForName(protocol)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return meta.defaultAPIBase
|
||||
}
|
||||
|
||||
func protocolMetaForName(protocol string) (protocolMeta, bool) {
|
||||
if meta, ok := protocolMetaByName[protocol]; ok {
|
||||
return meta, true
|
||||
}
|
||||
if meta, ok := attachedModelProviderMetaByName[protocol]; ok {
|
||||
return meta.protocolMeta, true
|
||||
}
|
||||
return protocolMeta{}, false
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/auth"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
@@ -101,6 +102,12 @@ func TestExtractProtocol(t *testing.T) {
|
||||
wantProtocol: "",
|
||||
wantModelID: "gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "unknown prefix falls back to openai",
|
||||
config: &config.ModelConfig{Model: "meta-llama/Llama-3.1-8B-Instruct"},
|
||||
wantProtocol: "openai",
|
||||
wantModelID: "meta-llama/Llama-3.1-8B-Instruct",
|
||||
},
|
||||
{
|
||||
name: "nil config",
|
||||
wantProtocol: "",
|
||||
@@ -605,6 +612,41 @@ func TestCreateProviderFromConfig_CodexCLI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_OpenAIMixedCaseAuthMethodUsesOAuthBranch(t *testing.T) {
|
||||
origGetCredential := getCredential
|
||||
getCredential = func(provider string) (*auth.AuthCredential, error) {
|
||||
if provider != "openai" {
|
||||
t.Fatalf("provider = %q, want %q", provider, "openai")
|
||||
}
|
||||
return &auth.AuthCredential{
|
||||
AccessToken: "test-token",
|
||||
AccountID: "acct-test",
|
||||
Provider: "openai",
|
||||
AuthMethod: "oauth",
|
||||
}, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
getCredential = origGetCredential
|
||||
})
|
||||
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-openai-oauth",
|
||||
Model: "openai/gpt-5.4",
|
||||
AuthMethod: "OAuth",
|
||||
}
|
||||
|
||||
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 != "gpt-5.4" {
|
||||
t.Errorf("modelID = %q, want %q", modelID, "gpt-5.4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-no-key",
|
||||
@@ -619,8 +661,9 @@ func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) {
|
||||
|
||||
func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-unknown",
|
||||
Model: "unknown-protocol/model",
|
||||
ModelName: "test-unknown-provider",
|
||||
Provider: "unknown-protocol",
|
||||
Model: "model",
|
||||
}
|
||||
cfg.SetAPIKey("test-key")
|
||||
|
||||
@@ -630,6 +673,26 @@ func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_UnknownModelPrefixDefaultsToOpenAI(t *testing.T) {
|
||||
cfg := &config.ModelConfig{
|
||||
ModelName: "test-unknown-model-prefix",
|
||||
Model: "meta-llama/Llama-3.1-8B-Instruct",
|
||||
APIBase: "https://api.example.com/v1",
|
||||
}
|
||||
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 != "meta-llama/Llama-3.1-8B-Instruct" {
|
||||
t.Fatalf("modelID = %q, want full model ID", modelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_NilConfig(t *testing.T) {
|
||||
_, _, err := CreateProviderFromConfig(nil)
|
||||
if err == nil {
|
||||
@@ -889,6 +952,71 @@ func TestGetDefaultAPIBase_QwenUSAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelProviderOptions(t *testing.T) {
|
||||
options := ModelProviderOptions()
|
||||
if len(options) == 0 {
|
||||
t.Fatal("ModelProviderOptions() returned no options")
|
||||
}
|
||||
|
||||
seen := make(map[string]ModelProviderOption, len(options))
|
||||
for _, option := range options {
|
||||
seen[option.ID] = option
|
||||
}
|
||||
|
||||
if _, ok := seen["openai"]; !ok {
|
||||
t.Fatal("openai option missing")
|
||||
}
|
||||
if option, ok := seen["openai"]; ok && !option.CreateAllowed {
|
||||
t.Fatal("openai should be creatable")
|
||||
}
|
||||
if option, ok := seen["lmstudio"]; !ok {
|
||||
t.Fatal("lmstudio option missing")
|
||||
} else if !option.EmptyAPIKeyAllowed {
|
||||
t.Fatal("lmstudio should allow empty API keys")
|
||||
}
|
||||
if option, ok := seen["anthropic"]; !ok {
|
||||
t.Fatal("anthropic option missing")
|
||||
} else if option.DefaultAPIBase != "https://api.anthropic.com/v1" {
|
||||
t.Fatalf("anthropic default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.anthropic.com/v1")
|
||||
}
|
||||
if _, ok := seen["azure"]; !ok {
|
||||
t.Fatal("azure option missing")
|
||||
}
|
||||
if option, ok := seen["bedrock"]; !ok {
|
||||
t.Fatal("bedrock option missing")
|
||||
} else if !option.CreateAllowed {
|
||||
t.Fatal("bedrock should be creatable and defer credential/build errors to runtime")
|
||||
}
|
||||
if option, ok := seen["elevenlabs"]; !ok {
|
||||
t.Fatal("elevenlabs option missing")
|
||||
} else {
|
||||
if option.DefaultAPIBase != "https://api.elevenlabs.io" {
|
||||
t.Fatalf("elevenlabs default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.elevenlabs.io")
|
||||
}
|
||||
if option.DefaultModelAllowed {
|
||||
t.Fatal("elevenlabs should be ASR-only and therefore not allowed as a default chat model")
|
||||
}
|
||||
}
|
||||
if option, ok := seen["antigravity"]; !ok {
|
||||
t.Fatal("antigravity option missing")
|
||||
} else {
|
||||
if !option.CreateAllowed {
|
||||
t.Fatal("antigravity should be creatable")
|
||||
}
|
||||
if option.DefaultAuthMethod != "oauth" {
|
||||
t.Fatalf("antigravity default_auth_method = %q, want %q", option.DefaultAuthMethod, "oauth")
|
||||
}
|
||||
if !option.AuthMethodLocked {
|
||||
t.Fatal("antigravity auth method should be locked")
|
||||
}
|
||||
}
|
||||
if option, ok := seen["github-copilot"]; !ok {
|
||||
t.Fatal("github-copilot option missing")
|
||||
} else if option.DefaultAPIBase != "localhost:4321" {
|
||||
t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) {
|
||||
var requestBody map[string]any
|
||||
|
||||
|
||||
+15
-10
@@ -17,18 +17,13 @@ func ParseModelRef(raw string, defaultProvider string) *ModelRef {
|
||||
return nil
|
||||
}
|
||||
|
||||
if idx := strings.Index(raw, "/"); idx > 0 {
|
||||
provider := NormalizeProvider(raw[:idx])
|
||||
model := strings.TrimSpace(raw[idx+1:])
|
||||
if model == "" {
|
||||
return nil
|
||||
}
|
||||
return &ModelRef{Provider: provider, Model: model}
|
||||
provider, model := SplitModelProviderAndID(raw, defaultProvider)
|
||||
if model == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ModelRef{
|
||||
Provider: NormalizeProvider(defaultProvider),
|
||||
Model: raw,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +48,8 @@ func NormalizeProvider(provider string) string {
|
||||
return "zhipu"
|
||||
case "google":
|
||||
return "gemini"
|
||||
case "google-antigravity":
|
||||
return "antigravity"
|
||||
case "alibaba-coding", "qwen-coding":
|
||||
return "coding-plan"
|
||||
case "alibaba-coding-anthropic":
|
||||
@@ -61,6 +58,14 @@ func NormalizeProvider(provider string) string {
|
||||
return "qwen-intl"
|
||||
case "dashscope-us":
|
||||
return "qwen-us"
|
||||
case "azure-openai":
|
||||
return "azure"
|
||||
case "claudecli":
|
||||
return "claude-cli"
|
||||
case "codexcli":
|
||||
return "codex-cli"
|
||||
case "copilot":
|
||||
return "github-copilot"
|
||||
}
|
||||
|
||||
return p
|
||||
|
||||
@@ -72,7 +72,12 @@ func TestNormalizeProvider(t *testing.T) {
|
||||
{"claude", "anthropic"},
|
||||
{"glm", "zhipu"},
|
||||
{"google", "gemini"},
|
||||
{"google-antigravity", "antigravity"},
|
||||
{"groq", "groq"},
|
||||
{"azure-openai", "azure"},
|
||||
{"claudecli", "claude-cli"},
|
||||
{"codexcli", "codex-cli"},
|
||||
{"copilot", "github-copilot"},
|
||||
// Alibaba Coding Plan aliases
|
||||
{"alibaba-coding", "coding-plan"},
|
||||
{"qwen-coding", "coding-plan"},
|
||||
@@ -131,3 +136,42 @@ func TestParseModelRef_DefaultProviderNormalization(t *testing.T) {
|
||||
t.Errorf("provider = %q, want openai (normalized from GPT)", ref.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelRef_UnknownPrefixFallsBackToDefaultProvider(t *testing.T) {
|
||||
ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "openai")
|
||||
if ref == nil {
|
||||
t.Fatal("expected non-nil ref")
|
||||
}
|
||||
if ref.Provider != "openai" {
|
||||
t.Fatalf("provider = %q, want openai", ref.Provider)
|
||||
}
|
||||
if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" {
|
||||
t.Fatalf("model = %q, want full original model ID", ref.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelRef_UnknownPrefixPreservesEmptyDefaultProvider(t *testing.T) {
|
||||
ref := ParseModelRef("meta-llama/Llama-3.1-8B-Instruct", "")
|
||||
if ref == nil {
|
||||
t.Fatal("expected non-nil ref")
|
||||
}
|
||||
if ref.Provider != "" {
|
||||
t.Fatalf("provider = %q, want empty", ref.Provider)
|
||||
}
|
||||
if ref.Model != "meta-llama/Llama-3.1-8B-Instruct" {
|
||||
t.Fatalf("model = %q, want full original model ID", ref.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModelRef_KnownNonSelectableProvider(t *testing.T) {
|
||||
ref := ParseModelRef("bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0", "openai")
|
||||
if ref == nil {
|
||||
t.Fatal("expected non-nil ref")
|
||||
}
|
||||
if ref.Provider != "bedrock" {
|
||||
t.Fatalf("provider = %q, want bedrock", ref.Provider)
|
||||
}
|
||||
if ref.Model != "us.anthropic.claude-sonnet-4-20250514-v1:0" {
|
||||
t.Fatalf("model = %q, want preserved bedrock model ID", ref.Model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ModelProviderOption describes a canonical provider entry exposed to the Web UI.
|
||||
type ModelProviderOption struct {
|
||||
ID string `json:"id"`
|
||||
DefaultAPIBase string `json:"default_api_base"`
|
||||
EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"`
|
||||
CreateAllowed bool `json:"create_allowed"`
|
||||
DefaultModelAllowed bool `json:"default_model_allowed"`
|
||||
DefaultAuthMethod string `json:"default_auth_method,omitempty"`
|
||||
AuthMethodLocked bool `json:"auth_method_locked,omitempty"`
|
||||
}
|
||||
|
||||
type attachedModelProviderMeta struct {
|
||||
protocolMeta
|
||||
createAllowed bool
|
||||
defaultModelAllowed bool
|
||||
defaultAuthMethod string
|
||||
authMethodLocked bool
|
||||
}
|
||||
|
||||
// attachedModelProviderMetaByName augments protocolMetaByName for provider
|
||||
// families that are implemented in CreateProviderFromConfig but intentionally
|
||||
// kept out of the core HTTP metadata map because they have special auth/runtime
|
||||
// semantics.
|
||||
var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{
|
||||
"azure": {createAllowed: true, defaultModelAllowed: true},
|
||||
"anthropic": {
|
||||
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
|
||||
createAllowed: true,
|
||||
defaultModelAllowed: true,
|
||||
},
|
||||
"anthropic-messages": {
|
||||
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
|
||||
createAllowed: true,
|
||||
defaultModelAllowed: true,
|
||||
},
|
||||
"bedrock": {createAllowed: true, defaultModelAllowed: true},
|
||||
"antigravity": {
|
||||
createAllowed: true,
|
||||
defaultModelAllowed: true,
|
||||
defaultAuthMethod: "oauth",
|
||||
authMethodLocked: true,
|
||||
},
|
||||
"claude-cli": {createAllowed: true, defaultModelAllowed: true},
|
||||
"codex-cli": {createAllowed: true, defaultModelAllowed: true},
|
||||
"github-copilot": {
|
||||
protocolMeta: protocolMeta{defaultAPIBase: "localhost:4321"},
|
||||
createAllowed: true,
|
||||
defaultModelAllowed: true,
|
||||
},
|
||||
// ElevenLabs is intentionally exposed only as an ASR-capable provider. It
|
||||
// belongs in the shared model catalog because ASR is configured via
|
||||
// model_list, but it must not be selectable as the default chat model.
|
||||
"elevenlabs": {
|
||||
protocolMeta: protocolMeta{defaultAPIBase: "https://api.elevenlabs.io"},
|
||||
createAllowed: true,
|
||||
defaultModelAllowed: false,
|
||||
},
|
||||
}
|
||||
|
||||
// ModelProviderOptions returns the canonical provider catalog exposed to the Web UI.
|
||||
func ModelProviderOptions() []ModelProviderOption {
|
||||
optionsByID := make(map[string]ModelProviderOption, len(protocolMetaByName)+len(attachedModelProviderMetaByName))
|
||||
for provider := range protocolMetaByName {
|
||||
if NormalizeProvider(provider) != provider {
|
||||
continue
|
||||
}
|
||||
optionsByID[provider] = ModelProviderOption{
|
||||
ID: provider,
|
||||
DefaultAPIBase: DefaultAPIBaseForProtocol(provider),
|
||||
EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider),
|
||||
CreateAllowed: true,
|
||||
DefaultModelAllowed: true,
|
||||
}
|
||||
}
|
||||
for provider, meta := range attachedModelProviderMetaByName {
|
||||
if NormalizeProvider(provider) != provider {
|
||||
continue
|
||||
}
|
||||
optionsByID[provider] = ModelProviderOption{
|
||||
ID: provider,
|
||||
DefaultAPIBase: meta.defaultAPIBase,
|
||||
EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed,
|
||||
CreateAllowed: meta.createAllowed,
|
||||
DefaultModelAllowed: meta.defaultModelAllowed,
|
||||
DefaultAuthMethod: meta.defaultAuthMethod,
|
||||
AuthMethodLocked: meta.authMethodLocked,
|
||||
}
|
||||
}
|
||||
|
||||
options := make([]ModelProviderOption, 0, len(optionsByID))
|
||||
for _, option := range optionsByID {
|
||||
options = append(options, option)
|
||||
}
|
||||
sort.Slice(options, func(i, j int) bool {
|
||||
return options[i].ID < options[j].ID
|
||||
})
|
||||
return options
|
||||
}
|
||||
|
||||
// IsSupportedModelProvider reports whether provider resolves to a provider ID
|
||||
// returned by ModelProviderOptions.
|
||||
func IsSupportedModelProvider(provider string) bool {
|
||||
normalized := NormalizeProvider(provider)
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := protocolMetaByName[normalized]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := attachedModelProviderMetaByName[normalized]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsCreatableModelProvider reports whether provider can be selected for a new
|
||||
// model entry from the Web UI.
|
||||
func IsCreatableModelProvider(provider string) bool {
|
||||
normalized := NormalizeProvider(provider)
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := protocolMetaByName[normalized]; ok {
|
||||
return true
|
||||
}
|
||||
meta, ok := attachedModelProviderMetaByName[normalized]
|
||||
return ok && meta.createAllowed
|
||||
}
|
||||
|
||||
// IsDefaultModelProvider reports whether provider can be used as the default
|
||||
// chat model. Some providers such as ASR-only entries are intentionally
|
||||
// exposed in model_list management but cannot drive the gateway default model.
|
||||
func IsDefaultModelProvider(provider string) bool {
|
||||
normalized := NormalizeProvider(provider)
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := protocolMetaByName[normalized]; ok {
|
||||
return true
|
||||
}
|
||||
meta, ok := attachedModelProviderMetaByName[normalized]
|
||||
return ok && meta.defaultModelAllowed
|
||||
}
|
||||
|
||||
// SplitModelProviderAndID separates a legacy "provider/model" string into its
|
||||
// effective provider and canonical model ID. Unknown prefixes are treated as
|
||||
// part of the model ID and fall back to defaultProvider.
|
||||
func SplitModelProviderAndID(model, defaultProvider string) (provider, modelID string) {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
provider, modelID = splitKnownProviderModel(model)
|
||||
if provider != "" || modelID != "" {
|
||||
return provider, modelID
|
||||
}
|
||||
|
||||
return NormalizeProvider(defaultProvider), model
|
||||
}
|
||||
|
||||
func splitKnownProviderModel(model string) (provider, modelID string) {
|
||||
provider, modelID, found := strings.Cut(strings.TrimSpace(model), "/")
|
||||
if !found {
|
||||
return "", ""
|
||||
}
|
||||
provider = strings.TrimSpace(provider)
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if provider == "" {
|
||||
return "", modelID
|
||||
}
|
||||
if !IsSupportedModelProvider(provider) {
|
||||
return "", ""
|
||||
}
|
||||
return NormalizeProvider(provider), modelID
|
||||
}
|
||||
@@ -382,6 +382,9 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
|
||||
if modelCfg == nil {
|
||||
return false, fmt.Sprintf("default model %q is invalid", modelName), nil
|
||||
}
|
||||
if !defaultModelAllowedForModelConfig(modelCfg) {
|
||||
return false, fmt.Sprintf("default model %q is not usable for chat", modelName), nil
|
||||
}
|
||||
|
||||
if !hasModelConfiguration(modelCfg) {
|
||||
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
|
||||
|
||||
@@ -357,6 +357,44 @@ func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayStartReady_RejectsASROnlyDefaultModel(t *testing.T) {
|
||||
configPath, cleanup := setupOAuthTestEnv(t)
|
||||
defer cleanup()
|
||||
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error = %v", err)
|
||||
}
|
||||
cfg.ModelList = []*config.ModelConfig{{
|
||||
ModelName: "elevenlabs-asr",
|
||||
Provider: "elevenlabs",
|
||||
Model: "scribe_v1",
|
||||
APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
|
||||
}}
|
||||
cfg.Agents.Defaults.ModelName = "elevenlabs-asr"
|
||||
|
||||
err = config.SaveConfig(configPath, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
h := NewHandler(configPath)
|
||||
ready, reason, err := h.gatewayStartReady()
|
||||
if err != nil {
|
||||
t.Fatalf("gatewayStartReady() error = %v", err)
|
||||
}
|
||||
if ready {
|
||||
t.Fatal("gatewayStartReady() ready = true, want false")
|
||||
}
|
||||
if reason != `default model "elevenlabs-asr" is not usable for chat` {
|
||||
t.Fatalf(
|
||||
"gatewayStartReady() reason = %q, want %q",
|
||||
reason,
|
||||
`default model "elevenlabs-asr" is not usable for chat`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksLikeGatewayCommandLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -47,6 +48,7 @@ var (
|
||||
probeTCPServiceFunc = probeTCPService
|
||||
probeOllamaModelFunc = probeOllamaModel
|
||||
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
|
||||
probeCommandAvailableFunc = probeCommandAvailable
|
||||
modelProbeNowFunc = time.Now
|
||||
modelProbeState = newModelProbeCacheState()
|
||||
)
|
||||
@@ -83,17 +85,23 @@ func (s *modelProbeCacheState) resetForTest() {
|
||||
}
|
||||
|
||||
func hasModelConfiguration(m *config.ModelConfig) bool {
|
||||
protocol := modelProtocol(m)
|
||||
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
||||
apiKey := strings.TrimSpace(m.APIKey())
|
||||
|
||||
if authMethod == "oauth" || authMethod == "token" {
|
||||
if provider, ok := oauthProviderForModel(m); ok {
|
||||
cred, err := oauthGetCredential(provider)
|
||||
if err != nil || cred == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != ""
|
||||
if configured, checked := hasStoredOAuthCredential(m); checked {
|
||||
return configured
|
||||
}
|
||||
}
|
||||
|
||||
if authMethod == "" && providerUsesImplicitOAuth(protocol) {
|
||||
if configured, checked := hasStoredOAuthCredential(m); checked {
|
||||
return configured
|
||||
}
|
||||
}
|
||||
|
||||
if providerUsesAmbientCredentials(protocol) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -104,6 +112,40 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
|
||||
return apiKey != ""
|
||||
}
|
||||
|
||||
func hasStoredOAuthCredential(m *config.ModelConfig) (bool, bool) {
|
||||
provider, ok := oauthProviderForModel(m)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
cred, err := oauthGetCredential(provider)
|
||||
if err != nil || cred == nil {
|
||||
return false, true
|
||||
}
|
||||
return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "", true
|
||||
}
|
||||
|
||||
func providerUsesImplicitOAuth(protocol string) bool {
|
||||
switch protocol {
|
||||
case "antigravity", "google-antigravity":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func providerUsesAmbientCredentials(protocol string) bool {
|
||||
switch protocol {
|
||||
case "bedrock":
|
||||
// Bedrock relies on the AWS SDK credential chain instead of an explicit
|
||||
// API key stored in ModelConfig. We cannot reliably preflight every AWS
|
||||
// credential source here, so avoid misclassifying valid environments as
|
||||
// "unconfigured" and defer concrete credential failures to runtime.
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary {
|
||||
if !hasModelConfiguration(m) {
|
||||
return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
|
||||
@@ -180,8 +222,10 @@ func runLocalModelProbe(m *config.ModelConfig) bool {
|
||||
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
||||
case "github-copilot", "copilot":
|
||||
return probeTCPServiceFunc(apiBase)
|
||||
case "claude-cli", "claudecli", "codex-cli", "codexcli":
|
||||
return true
|
||||
case "claude-cli", "claudecli":
|
||||
return probeCommandAvailableFunc("claude")
|
||||
case "codex-cli", "codexcli":
|
||||
return probeCommandAvailableFunc("codex")
|
||||
default:
|
||||
if hasLocalAPIBase(apiBase) {
|
||||
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
||||
@@ -190,6 +234,11 @@ func runLocalModelProbe(m *config.ModelConfig) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func probeCommandAvailable(command string) bool {
|
||||
_, err := exec.LookPath(command)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func modelProbeCacheKey(m *config.ModelConfig) string {
|
||||
protocol, modelID := splitModel(m)
|
||||
|
||||
|
||||
+219
-15
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/audio/asr"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
@@ -45,11 +46,184 @@ type modelResponse struct {
|
||||
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
||||
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
|
||||
// Meta
|
||||
Enabled bool `json:"enabled"`
|
||||
Available bool `json:"available"`
|
||||
Status string `json:"status"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsVirtual bool `json:"is_virtual"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Available bool `json:"available"`
|
||||
Status string `json:"status"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsVirtual bool `json:"is_virtual"`
|
||||
DefaultModelAllowed bool `json:"default_model_allowed"`
|
||||
}
|
||||
|
||||
func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
|
||||
if mc == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
changed := false
|
||||
model := strings.TrimSpace(mc.Model)
|
||||
if model != mc.Model {
|
||||
mc.Model = model
|
||||
changed = true
|
||||
}
|
||||
provider := strings.TrimSpace(mc.Provider)
|
||||
if provider != mc.Provider {
|
||||
mc.Provider = provider
|
||||
changed = true
|
||||
}
|
||||
authMethod := strings.ToLower(strings.TrimSpace(mc.AuthMethod))
|
||||
if authMethod != mc.AuthMethod {
|
||||
mc.AuthMethod = authMethod
|
||||
changed = true
|
||||
}
|
||||
|
||||
if provider != "" {
|
||||
normalizedProvider := providers.NormalizeProvider(provider)
|
||||
if providers.IsSupportedModelProvider(normalizedProvider) && normalizedProvider != provider {
|
||||
mc.Provider = normalizedProvider
|
||||
changed = true
|
||||
}
|
||||
if mc.Provider == "elevenlabs" {
|
||||
if _, strippedModel, found := strings.Cut(
|
||||
model,
|
||||
"/",
|
||||
); found &&
|
||||
providers.NormalizeProvider(strings.TrimSpace(provider)) == "elevenlabs" {
|
||||
strippedModel = strings.TrimSpace(strippedModel)
|
||||
if strippedModel != "" && strippedModel != mc.Model {
|
||||
mc.Model = strippedModel
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() {
|
||||
mc.Model = asr.ElevenLabsSupportedModelID()
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai")
|
||||
if effectiveProvider == "" {
|
||||
return changed
|
||||
}
|
||||
if mc.Provider != effectiveProvider {
|
||||
mc.Provider = effectiveProvider
|
||||
changed = true
|
||||
}
|
||||
if mc.Model != modelID {
|
||||
mc.Model = modelID
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func normalizeIncomingModelConfig(mc *config.ModelConfig) {
|
||||
if mc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
mc.Model = strings.TrimSpace(mc.Model)
|
||||
mc.Provider = strings.TrimSpace(mc.Provider)
|
||||
mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod))
|
||||
if mc.Provider == "" {
|
||||
mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai")
|
||||
} else {
|
||||
mc.Provider = providers.NormalizeProvider(mc.Provider)
|
||||
if mc.Provider == "elevenlabs" {
|
||||
if _, strippedModel, found := strings.Cut(mc.Model, "/"); found {
|
||||
strippedModel = strings.TrimSpace(strippedModel)
|
||||
if strippedModel != "" {
|
||||
mc.Model = strippedModel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if mc.Provider == "antigravity" && mc.AuthMethod == "" {
|
||||
mc.AuthMethod = "oauth"
|
||||
}
|
||||
}
|
||||
|
||||
func createAllowedForProvider(provider string) bool {
|
||||
normalized := providers.NormalizeProvider(provider)
|
||||
switch normalized {
|
||||
case "bedrock":
|
||||
// Bedrock currently authenticates through the AWS SDK credential chain
|
||||
// (env vars, shared profiles, IAM roles, etc.), and this Web layer does
|
||||
// not yet have a reliable preflight check for those credential sources.
|
||||
// Keep it creatable in the catalog and let provider construction/runtime
|
||||
// return the concrete AWS error when the environment is incomplete.
|
||||
return true
|
||||
case "claude-cli", "codex-cli":
|
||||
return cliProviderCreateAllowedFromCurrentStatus(normalized)
|
||||
default:
|
||||
return providers.IsCreatableModelProvider(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
// cliProviderCreateAllowedFromCurrentStatus intentionally reuses the existing
|
||||
// local model status pipeline so provider catalog gating follows the same CLI
|
||||
// executable probe used by launcher readiness.
|
||||
func cliProviderCreateAllowedFromCurrentStatus(provider string) bool {
|
||||
status := modelConfigurationStatus(&config.ModelConfig{
|
||||
Provider: provider,
|
||||
Model: provider,
|
||||
})
|
||||
return status.Available
|
||||
}
|
||||
|
||||
func modelProviderOptionsForResponse() []providers.ModelProviderOption {
|
||||
options := providers.ModelProviderOptions()
|
||||
for i := range options {
|
||||
options[i].CreateAllowed = createAllowedForProvider(options[i].ID)
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func defaultModelAllowedForModelConfig(mc *config.ModelConfig) bool {
|
||||
provider, _ := providers.ExtractProtocol(mc)
|
||||
return providers.IsDefaultModelProvider(provider)
|
||||
}
|
||||
|
||||
func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelConfig) error {
|
||||
if mc == nil {
|
||||
return fmt.Errorf("model config is required")
|
||||
}
|
||||
if err := mc.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(mc.Provider) == "" {
|
||||
return fmt.Errorf("provider is required")
|
||||
}
|
||||
if !providers.IsSupportedModelProvider(mc.Provider) {
|
||||
return fmt.Errorf("provider %q is not supported", mc.Provider)
|
||||
}
|
||||
if mc.Provider == "elevenlabs" && strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() {
|
||||
return fmt.Errorf("provider %q only supports model %q", mc.Provider, asr.ElevenLabsSupportedModelID())
|
||||
}
|
||||
if !createAllowedForProvider(mc.Provider) {
|
||||
if existing == nil {
|
||||
return fmt.Errorf("provider %q is not available for new models", mc.Provider)
|
||||
}
|
||||
existingProvider, _ := providers.ExtractProtocol(existing)
|
||||
if providers.NormalizeProvider(existingProvider) != mc.Provider {
|
||||
return fmt.Errorf("provider %q is not available for selection", mc.Provider)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeStoredModelProviders(cfg *config.Config) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, model := range cfg.ModelList {
|
||||
if normalizeStoredModelConfig(model) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// handleListModels returns all model_list entries with masked API keys.
|
||||
@@ -62,6 +236,10 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize legacy provider/model storage in memory so GET can round-trip
|
||||
// through the current API shape without mutating the on-disk config.
|
||||
normalizeStoredModelProviders(cfg)
|
||||
|
||||
defaultModel := cfg.Agents.Defaults.GetModelName()
|
||||
modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
|
||||
|
||||
@@ -101,14 +279,16 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||
Status: modelStatuses[i].Status,
|
||||
IsDefault: m.ModelName == defaultModel,
|
||||
IsVirtual: m.IsVirtual(),
|
||||
DefaultModelAllowed: defaultModelAllowedForModelConfig(m),
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"models": models,
|
||||
"total": len(models),
|
||||
"default_model": defaultModel,
|
||||
"models": models,
|
||||
"total": len(models),
|
||||
"default_model": defaultModel,
|
||||
"provider_options": modelProviderOptionsForResponse(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,7 +314,9 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = mc.Validate(); err != nil {
|
||||
normalizeIncomingModelConfig(&mc.ModelConfig)
|
||||
|
||||
if err = validateIncomingModelConfig(&mc.ModelConfig, nil); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -150,6 +332,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig)
|
||||
normalizeStoredModelProviders(cfg)
|
||||
|
||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||
@@ -200,11 +383,6 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = mc.Validate(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.LoadConfig(h.configPath)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||
@@ -253,9 +431,9 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||
// This keeps provider-omitted updates backward-compatible even when an
|
||||
// older client edits the visible model ID.
|
||||
if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
|
||||
existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
|
||||
existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
|
||||
incomingModel := strings.TrimSpace(mc.Model)
|
||||
existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
|
||||
if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
|
||||
if incomingModel == existingModelID {
|
||||
mc.Model = existingRawModel
|
||||
@@ -272,7 +450,20 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
normalizeIncomingModelConfig(&mc.ModelConfig)
|
||||
if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if cfg.Agents.Defaults.ModelName == cfg.ModelList[idx].ModelName &&
|
||||
!defaultModelAllowedForModelConfig(&mc.ModelConfig) {
|
||||
// Allow users to recover from legacy/invalid defaults by saving the model
|
||||
// and clearing the default chat model reference in the same write.
|
||||
cfg.Agents.Defaults.ModelName = ""
|
||||
}
|
||||
|
||||
cfg.ModelList[idx] = &mc.ModelConfig
|
||||
normalizeStoredModelProviders(cfg)
|
||||
|
||||
logger.Debugf("update model config: %#v", mc.ModelConfig)
|
||||
|
||||
@@ -372,6 +563,19 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, m := range cfg.ModelList {
|
||||
if m.ModelName == req.ModelName {
|
||||
if !defaultModelAllowedForModelConfig(m) {
|
||||
http.Error(
|
||||
w,
|
||||
fmt.Sprintf("Model %q cannot be used as the default chat model", req.ModelName),
|
||||
http.StatusBadRequest,
|
||||
)
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
cfg.Agents.Defaults.ModelName = req.ModelName
|
||||
|
||||
|
||||
+1021
-19
File diff suppressed because it is too large
Load Diff
@@ -27,12 +27,24 @@ export interface ModelInfo {
|
||||
status: "available" | "unconfigured" | "unreachable"
|
||||
is_default: boolean
|
||||
is_virtual: boolean
|
||||
default_model_allowed?: boolean
|
||||
}
|
||||
|
||||
export interface ModelProviderOption {
|
||||
id: string
|
||||
default_api_base: string
|
||||
empty_api_key_allowed: boolean
|
||||
create_allowed: boolean
|
||||
default_model_allowed: boolean
|
||||
default_auth_method?: string
|
||||
auth_method_locked?: boolean
|
||||
}
|
||||
|
||||
interface ModelsListResponse {
|
||||
models: ModelInfo[]
|
||||
total: number
|
||||
default_model: string
|
||||
provider_options: ModelProviderOption[]
|
||||
}
|
||||
|
||||
interface ModelActionResponse {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { addModel, setDefaultModel } from "@/api/models"
|
||||
import {
|
||||
type ModelProviderOption,
|
||||
addModel,
|
||||
setDefaultModel,
|
||||
} from "@/api/models"
|
||||
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import {
|
||||
@@ -13,6 +17,13 @@ import {
|
||||
} from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -25,6 +36,15 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||
import { refreshGatewayState } from "@/store/gateway"
|
||||
|
||||
import {
|
||||
findProviderOption,
|
||||
getProviderDefaultAPIBase,
|
||||
getProviderDefaultAuthMethod,
|
||||
getProviderLabel,
|
||||
getSortedProviderOptions,
|
||||
isProviderAuthMethodLocked,
|
||||
} from "./provider-label"
|
||||
|
||||
interface AddForm {
|
||||
modelName: string
|
||||
provider: string
|
||||
@@ -46,7 +66,7 @@ interface AddForm {
|
||||
|
||||
const EMPTY_ADD_FORM: AddForm = {
|
||||
modelName: "",
|
||||
provider: "",
|
||||
provider: "openai",
|
||||
model: "",
|
||||
apiBase: "",
|
||||
apiKey: "",
|
||||
@@ -68,6 +88,7 @@ interface AddModelSheetProps {
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
existingModelNames: string[]
|
||||
providerOptions: ModelProviderOption[]
|
||||
}
|
||||
|
||||
export function AddModelSheet({
|
||||
@@ -75,6 +96,7 @@ export function AddModelSheet({
|
||||
onClose,
|
||||
onSaved,
|
||||
existingModelNames,
|
||||
providerOptions,
|
||||
}: AddModelSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
|
||||
@@ -88,6 +110,37 @@ export function AddModelSheet({
|
||||
form.apiKey,
|
||||
t("models.field.apiKeyPlaceholder"),
|
||||
)
|
||||
const sortedProviderOptions = useMemo(
|
||||
() => getSortedProviderOptions(providerOptions),
|
||||
[providerOptions],
|
||||
)
|
||||
const creatableProviderOptions = useMemo(
|
||||
() => sortedProviderOptions.filter((option) => option.create_allowed),
|
||||
[sortedProviderOptions],
|
||||
)
|
||||
const selectedProviderOption = findProviderOption(
|
||||
form.provider,
|
||||
providerOptions,
|
||||
)
|
||||
const authMethodLocked = isProviderAuthMethodLocked(
|
||||
form.provider,
|
||||
providerOptions,
|
||||
)
|
||||
const defaultAuthMethod = getProviderDefaultAuthMethod(
|
||||
form.provider,
|
||||
providerOptions,
|
||||
)
|
||||
const effectiveAuthMethod = (
|
||||
authMethodLocked ? defaultAuthMethod : form.authMethod
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const isOAuth = effectiveAuthMethod === "oauth"
|
||||
const defaultModelAllowed =
|
||||
selectedProviderOption?.default_model_allowed !== false
|
||||
const apiBasePlaceholder =
|
||||
getProviderDefaultAPIBase(form.provider, providerOptions) ||
|
||||
"https://api.example.com/v1"
|
||||
const isDirty =
|
||||
JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault
|
||||
|
||||
@@ -108,6 +161,9 @@ export function AddModelSheet({
|
||||
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
|
||||
errors.modelName = t("models.add.errorDuplicateModelName")
|
||||
}
|
||||
if (!selectedProviderOption) {
|
||||
errors.provider = t("models.field.providerInvalid")
|
||||
}
|
||||
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
|
||||
setFieldErrors(errors)
|
||||
return Object.keys(errors).length === 0
|
||||
@@ -122,22 +178,47 @@ export function AddModelSheet({
|
||||
}
|
||||
}
|
||||
|
||||
const setProvider = (value: string) => {
|
||||
setForm((f) => {
|
||||
const previousOption = findProviderOption(f.provider, providerOptions)
|
||||
const nextOption = findProviderOption(value, providerOptions)
|
||||
let authMethod = f.authMethod
|
||||
if (nextOption?.auth_method_locked) {
|
||||
authMethod = nextOption.default_auth_method ?? ""
|
||||
} else if (
|
||||
previousOption?.auth_method_locked &&
|
||||
f.authMethod === (previousOption.default_auth_method ?? "")
|
||||
) {
|
||||
authMethod = ""
|
||||
}
|
||||
return { ...f, provider: value, authMethod }
|
||||
})
|
||||
const nextOption = findProviderOption(value, providerOptions)
|
||||
if (nextOption?.default_model_allowed === false) {
|
||||
setSetAsDefault(false)
|
||||
}
|
||||
if (fieldErrors.provider) {
|
||||
setFieldErrors((prev) => ({ ...prev, provider: undefined }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validate()) return
|
||||
setSaving(true)
|
||||
setServerError("")
|
||||
try {
|
||||
const modelName = form.modelName.trim()
|
||||
const provider = form.provider.trim()
|
||||
const modelId = form.model.trim()
|
||||
await addModel({
|
||||
model_name: modelName,
|
||||
provider: provider || undefined,
|
||||
provider: form.provider.trim(),
|
||||
model: modelId,
|
||||
api_base: form.apiBase.trim() || undefined,
|
||||
api_key: form.apiKey.trim() || undefined,
|
||||
proxy: form.proxy.trim() || undefined,
|
||||
auth_method: form.authMethod.trim() || undefined,
|
||||
auth_method: authMethodLocked
|
||||
? defaultAuthMethod || undefined
|
||||
: form.authMethod.trim() || undefined,
|
||||
connect_mode: form.connectMode.trim() || undefined,
|
||||
workspace: form.workspace.trim() || undefined,
|
||||
rpm: form.rpm ? Number(form.rpm) : undefined,
|
||||
@@ -208,12 +289,29 @@ export function AddModelSheet({
|
||||
<Field
|
||||
label={t("models.field.provider")}
|
||||
hint={t("models.field.providerHint")}
|
||||
error={fieldErrors.provider}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={form.provider}
|
||||
onChange={setField("provider")}
|
||||
placeholder={t("models.field.providerPlaceholder")}
|
||||
/>
|
||||
<Select
|
||||
value={selectedProviderOption?.id}
|
||||
onValueChange={setProvider}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-invalid={!!fieldErrors.provider}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t("models.field.providerPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{creatableProviderOptions.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{getProviderLabel(option.id)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
@@ -232,27 +330,38 @@ export function AddModelSheet({
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label={t("models.field.apiKey")}>
|
||||
<KeyInput
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||
placeholder={apiKeyPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
{!isOAuth && (
|
||||
<Field label={t("models.field.apiKey")}>
|
||||
<KeyInput
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||
placeholder={apiKeyPlaceholder}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label={t("models.field.apiBase")}>
|
||||
<Field
|
||||
label={t("models.field.apiBase")}
|
||||
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
|
||||
>
|
||||
<Input
|
||||
value={form.apiBase}
|
||||
onChange={setField("apiBase")}
|
||||
placeholder="https://api.example.com/v1"
|
||||
placeholder={apiBasePlaceholder}
|
||||
disabled={isOAuth}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("models.defaultOnSave.label")}
|
||||
hint={t("models.defaultOnSave.description")}
|
||||
hint={
|
||||
defaultModelAllowed
|
||||
? t("models.defaultOnSave.description")
|
||||
: t("models.defaultOnSave.unsupportedProvider")
|
||||
}
|
||||
checked={setAsDefault}
|
||||
onCheckedChange={setSetAsDefault}
|
||||
disabled={!defaultModelAllowed}
|
||||
/>
|
||||
|
||||
<AdvancedSection>
|
||||
@@ -269,12 +378,17 @@ export function AddModelSheet({
|
||||
|
||||
<Field
|
||||
label={t("models.field.authMethod")}
|
||||
hint={t("models.field.authMethodHint")}
|
||||
hint={
|
||||
authMethodLocked
|
||||
? t("models.field.authMethodManagedHint")
|
||||
: t("models.field.authMethodHint")
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={form.authMethod}
|
||||
value={authMethodLocked ? defaultAuthMethod : form.authMethod}
|
||||
onChange={setField("authMethod")}
|
||||
placeholder="oauth"
|
||||
disabled={authMethodLocked}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { IconLoader2 } from "@tabler/icons-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models"
|
||||
import {
|
||||
type ModelInfo,
|
||||
type ModelProviderOption,
|
||||
setDefaultModel,
|
||||
updateModel,
|
||||
} from "@/api/models"
|
||||
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||
import {
|
||||
@@ -13,6 +18,13 @@ import {
|
||||
} from "@/components/shared-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -25,6 +37,15 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||
import { refreshGatewayState } from "@/store/gateway"
|
||||
|
||||
import {
|
||||
findProviderOption,
|
||||
getProviderDefaultAPIBase,
|
||||
getProviderDefaultAuthMethod,
|
||||
getProviderLabel,
|
||||
getSortedProviderOptions,
|
||||
isProviderAuthMethodLocked,
|
||||
} from "./provider-label"
|
||||
|
||||
interface EditForm {
|
||||
provider: string
|
||||
modelId: string
|
||||
@@ -45,6 +66,7 @@ interface EditForm {
|
||||
|
||||
interface EditModelSheetProps {
|
||||
model: ModelInfo | null
|
||||
providerOptions: ModelProviderOption[]
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
@@ -76,6 +98,7 @@ function buildInitialEditForm(model: ModelInfo): EditForm {
|
||||
|
||||
export function EditModelSheet({
|
||||
model,
|
||||
providerOptions,
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
@@ -102,26 +125,99 @@ export function EditModelSheet({
|
||||
const [setAsDefault, setSetAsDefault] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const initialForm = model ? buildInitialEditForm(model) : null
|
||||
const sortedProviderOptions = useMemo(
|
||||
() => getSortedProviderOptions(providerOptions),
|
||||
[providerOptions],
|
||||
)
|
||||
const currentProviderID = model
|
||||
? (findProviderOption(model.provider, providerOptions)?.id ??
|
||||
model.provider?.trim().toLowerCase() ??
|
||||
"")
|
||||
: ""
|
||||
const selectedProviderOption = findProviderOption(
|
||||
form.provider,
|
||||
providerOptions,
|
||||
)
|
||||
const authMethodLocked = isProviderAuthMethodLocked(
|
||||
form.provider,
|
||||
providerOptions,
|
||||
)
|
||||
const defaultAuthMethod = getProviderDefaultAuthMethod(
|
||||
form.provider,
|
||||
providerOptions,
|
||||
)
|
||||
const effectiveAuthMethod = (
|
||||
authMethodLocked ? defaultAuthMethod : form.authMethod
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const providerError = selectedProviderOption
|
||||
? ""
|
||||
: t("models.field.providerInvalid")
|
||||
const defaultModelAllowed =
|
||||
selectedProviderOption?.default_model_allowed !== false
|
||||
const willClearDefaultOnSave =
|
||||
model?.is_default === true && defaultModelAllowed === false
|
||||
const apiBasePlaceholder =
|
||||
getProviderDefaultAPIBase(form.provider, providerOptions) ||
|
||||
"https://api.example.com/v1"
|
||||
const isDirty =
|
||||
model != null &&
|
||||
(JSON.stringify(form) !== JSON.stringify(initialForm) ||
|
||||
setAsDefault !== model.is_default)
|
||||
|
||||
useEffect(() => {
|
||||
if (model) {
|
||||
setForm(buildInitialEditForm(model))
|
||||
setSetAsDefault(model.is_default)
|
||||
setError("")
|
||||
if (model) {
|
||||
const initialForm = buildInitialEditForm(model)
|
||||
const option = findProviderOption(initialForm.provider, providerOptions)
|
||||
if (option?.auth_method_locked && !initialForm.authMethod) {
|
||||
initialForm.authMethod = option.default_auth_method ?? ""
|
||||
}
|
||||
}, [model])
|
||||
setForm(initialForm)
|
||||
setSetAsDefault(model.is_default && model.default_model_allowed !== false)
|
||||
setError("")
|
||||
}
|
||||
}, [model, providerOptions])
|
||||
|
||||
const setField =
|
||||
(key: keyof EditForm) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (error) {
|
||||
setError("")
|
||||
}
|
||||
setForm((f) => ({ ...f, [key]: e.target.value }))
|
||||
}
|
||||
|
||||
const setProvider = (value: string) => {
|
||||
if (error) {
|
||||
setError("")
|
||||
}
|
||||
setForm((f) => {
|
||||
const previousOption = findProviderOption(f.provider, providerOptions)
|
||||
const nextOption = findProviderOption(value, providerOptions)
|
||||
let authMethod = f.authMethod
|
||||
if (nextOption?.auth_method_locked) {
|
||||
authMethod = nextOption.default_auth_method ?? ""
|
||||
} else if (
|
||||
previousOption?.auth_method_locked &&
|
||||
f.authMethod === (previousOption.default_auth_method ?? "")
|
||||
) {
|
||||
authMethod = ""
|
||||
}
|
||||
return { ...f, provider: value, authMethod }
|
||||
})
|
||||
const nextOption = findProviderOption(value, providerOptions)
|
||||
if (nextOption?.default_model_allowed === false) {
|
||||
setSetAsDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!model) return
|
||||
if (!selectedProviderOption) {
|
||||
setError(providerError)
|
||||
return
|
||||
}
|
||||
if (!form.modelId.trim()) {
|
||||
setError(t("models.add.errorRequired"))
|
||||
return
|
||||
@@ -136,7 +232,9 @@ export function EditModelSheet({
|
||||
api_base: form.apiBase || undefined,
|
||||
api_key: form.apiKey || undefined,
|
||||
proxy: form.proxy || undefined,
|
||||
auth_method: form.authMethod || undefined,
|
||||
auth_method: authMethodLocked
|
||||
? defaultAuthMethod || undefined
|
||||
: form.authMethod || undefined,
|
||||
connect_mode: form.connectMode || undefined,
|
||||
workspace: form.workspace || undefined,
|
||||
rpm: form.rpm ? Number(form.rpm) : undefined,
|
||||
@@ -172,7 +270,7 @@ export function EditModelSheet({
|
||||
}
|
||||
}
|
||||
|
||||
const isOAuth = model?.auth_method === "oauth"
|
||||
const isOAuth = effectiveAuthMethod === "oauth"
|
||||
const hasSavedAPIKey = Boolean(model?.api_key)
|
||||
const apiKeyPlaceholder = hasSavedAPIKey
|
||||
? maskedSecretPlaceholder(
|
||||
@@ -201,12 +299,36 @@ export function EditModelSheet({
|
||||
<Field
|
||||
label={t("models.field.provider")}
|
||||
hint={t("models.field.providerHint")}
|
||||
error={providerError}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={form.provider}
|
||||
onChange={setField("provider")}
|
||||
placeholder={t("models.field.providerPlaceholder")}
|
||||
/>
|
||||
<Select
|
||||
value={selectedProviderOption?.id}
|
||||
onValueChange={setProvider}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-invalid={!!providerError}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t("models.field.providerPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sortedProviderOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
disabled={
|
||||
!option.create_allowed &&
|
||||
option.id !== currentProviderID
|
||||
}
|
||||
>
|
||||
{getProviderLabel(option.id)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
@@ -241,16 +363,23 @@ export function EditModelSheet({
|
||||
<Input
|
||||
value={form.apiBase}
|
||||
onChange={setField("apiBase")}
|
||||
placeholder="https://api.example.com/v1"
|
||||
placeholder={apiBasePlaceholder}
|
||||
disabled={isOAuth}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<SwitchCardField
|
||||
label={t("models.defaultOnSave.label")}
|
||||
hint={t("models.defaultOnSave.description")}
|
||||
hint={
|
||||
willClearDefaultOnSave
|
||||
? t("models.defaultOnSave.clearOnSave")
|
||||
: defaultModelAllowed
|
||||
? t("models.defaultOnSave.description")
|
||||
: t("models.defaultOnSave.unsupportedProvider")
|
||||
}
|
||||
checked={setAsDefault}
|
||||
onCheckedChange={setSetAsDefault}
|
||||
disabled={!defaultModelAllowed}
|
||||
/>
|
||||
|
||||
<AdvancedSection>
|
||||
@@ -267,12 +396,17 @@ export function EditModelSheet({
|
||||
|
||||
<Field
|
||||
label={t("models.field.authMethod")}
|
||||
hint={t("models.field.authMethodHint")}
|
||||
hint={
|
||||
authMethodLocked
|
||||
? t("models.field.authMethodManagedHint")
|
||||
: t("models.field.authMethodHint")
|
||||
}
|
||||
>
|
||||
<Input
|
||||
value={form.authMethod}
|
||||
value={authMethodLocked ? defaultAuthMethod : form.authMethod}
|
||||
onChange={setField("authMethod")}
|
||||
placeholder="oauth"
|
||||
disabled={authMethodLocked}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
|
||||
@@ -36,7 +36,10 @@ export function ModelCard({
|
||||
const status = model.status
|
||||
const statusLabel = t(`models.status.${status}`)
|
||||
const canSetDefault =
|
||||
model.available && !model.is_default && !model.is_virtual
|
||||
model.available &&
|
||||
!model.is_default &&
|
||||
!model.is_virtual &&
|
||||
model.default_model_allowed !== false
|
||||
|
||||
const setDefaultLabel = t("models.action.setDefault")
|
||||
const setDefaultDisabledReason = (() => {
|
||||
@@ -45,6 +48,9 @@ export function ModelCard({
|
||||
return t("models.action.setDefaultDisabled.unavailable")
|
||||
if (model.is_default) return t("models.action.setDefaultDisabled.isDefault")
|
||||
if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual")
|
||||
if (model.default_model_allowed === false) {
|
||||
return t("models.action.setDefaultDisabled.unsupportedProvider")
|
||||
}
|
||||
return setDefaultLabel
|
||||
})()
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@ import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
|
||||
import {
|
||||
type ModelInfo,
|
||||
type ModelProviderOption,
|
||||
getModels,
|
||||
setDefaultModel,
|
||||
} from "@/api/models"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||
@@ -12,41 +17,13 @@ import { refreshGatewayState } from "@/store/gateway"
|
||||
import { AddModelSheet } from "./add-model-sheet"
|
||||
import { DeleteModelDialog } from "./delete-model-dialog"
|
||||
import { EditModelSheet } from "./edit-model-sheet"
|
||||
import { getProviderKey, getProviderLabel } from "./provider-label"
|
||||
import {
|
||||
PROVIDER_PRIORITY,
|
||||
getProviderKey,
|
||||
getProviderLabel,
|
||||
} from "./provider-label"
|
||||
import { ProviderSection } from "./provider-section"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
volcengine: 0,
|
||||
openai: 1,
|
||||
gemini: 2,
|
||||
anthropic: 3,
|
||||
zhipu: 4,
|
||||
deepseek: 5,
|
||||
openrouter: 6,
|
||||
"qwen-portal": 7,
|
||||
"qwen-intl": 8,
|
||||
moonshot: 9,
|
||||
groq: 10,
|
||||
"github-copilot": 11,
|
||||
antigravity: 12,
|
||||
nvidia: 13,
|
||||
cerebras: 14,
|
||||
shengsuanyun: 15,
|
||||
venice: 16,
|
||||
vivgrid: 17,
|
||||
minimax: 18,
|
||||
longcat: 19,
|
||||
modelscope: 20,
|
||||
mistral: 21,
|
||||
avian: 22,
|
||||
azure: 23,
|
||||
ollama: 24,
|
||||
vllm: 25,
|
||||
lmstudio: 26,
|
||||
zai: 27,
|
||||
mimo: 28,
|
||||
}
|
||||
|
||||
interface ProviderGroup {
|
||||
key: string
|
||||
label: string
|
||||
@@ -58,6 +35,9 @@ interface ProviderGroup {
|
||||
export function ModelsPage() {
|
||||
const { t } = useTranslation()
|
||||
const [models, setModels] = useState<ModelInfo[]>([])
|
||||
const [providerOptions, setProviderOptions] = useState<ModelProviderOption[]>(
|
||||
[],
|
||||
)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [fetchError, setFetchError] = useState("")
|
||||
|
||||
@@ -67,6 +47,7 @@ export function ModelsPage() {
|
||||
const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(
|
||||
null,
|
||||
)
|
||||
const addDisabled = loading || providerOptions.length === 0
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
try {
|
||||
@@ -79,6 +60,7 @@ export function ModelsPage() {
|
||||
return a.model_name.localeCompare(b.model_name)
|
||||
})
|
||||
setModels(sorted)
|
||||
setProviderOptions(data.provider_options ?? [])
|
||||
setFetchError("")
|
||||
} catch (e) {
|
||||
setFetchError(e instanceof Error ? e.message : t("models.loadError"))
|
||||
@@ -160,7 +142,12 @@ export function ModelsPage() {
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader title={t("navigation.models")}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={addDisabled}
|
||||
onClick={() => setAddOpen(true)}
|
||||
>
|
||||
<IconPlus className="size-4" />
|
||||
{t("models.add.button")}
|
||||
</Button>
|
||||
@@ -213,6 +200,7 @@ export function ModelsPage() {
|
||||
|
||||
<EditModelSheet
|
||||
model={editingModel}
|
||||
providerOptions={providerOptions}
|
||||
open={editingModel !== null}
|
||||
onClose={() => setEditingModel(null)}
|
||||
onSaved={fetchModels}
|
||||
@@ -220,6 +208,7 @@ export function ModelsPage() {
|
||||
|
||||
<AddModelSheet
|
||||
open={addOpen}
|
||||
providerOptions={providerOptions}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onSaved={fetchModels}
|
||||
existingModelNames={models.map((model) => model.model_name)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react"
|
||||
|
||||
const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
||||
openai: "openai",
|
||||
elevenlabs: "elevenlabs",
|
||||
anthropic: "anthropic",
|
||||
azure: "microsoftazure",
|
||||
gemini: "googlegemini",
|
||||
@@ -21,6 +22,7 @@ const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
||||
|
||||
const PROVIDER_DOMAINS: Record<string, string> = {
|
||||
openai: "openai.com",
|
||||
elevenlabs: "elevenlabs.io",
|
||||
anthropic: "anthropic.com",
|
||||
azure: "azure.com",
|
||||
gemini: "gemini.google.com",
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import type { ModelProviderOption } from "@/api/models"
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
openai: "OpenAI",
|
||||
bedrock: "AWS Bedrock",
|
||||
elevenlabs: "ElevenLabs ASR",
|
||||
anthropic: "Anthropic",
|
||||
"anthropic-messages": "Anthropic Messages",
|
||||
azure: "Azure OpenAI",
|
||||
gemini: "Google Gemini",
|
||||
deepseek: "DeepSeek",
|
||||
"coding-plan": "Alibaba Coding Plan",
|
||||
"coding-plan-anthropic": "Alibaba Coding Plan (Anthropic)",
|
||||
"qwen-portal": "Qwen (阿里云)",
|
||||
"qwen-intl": "Qwen International",
|
||||
"qwen-us": "Qwen US",
|
||||
moonshot: "Moonshot (月之暗面)",
|
||||
groq: "Groq",
|
||||
openrouter: "OpenRouter",
|
||||
@@ -15,8 +23,11 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||
shengsuanyun: "ShengsuanYun (神算云)",
|
||||
antigravity: "Google Code Assist",
|
||||
"github-copilot": "GitHub Copilot",
|
||||
"claude-cli": "Claude CLI (local)",
|
||||
"codex-cli": "Codex CLI (local)",
|
||||
ollama: "Ollama (local)",
|
||||
lmstudio: "LM Studio (local)",
|
||||
litellm: "LiteLLM",
|
||||
mistral: "Mistral AI",
|
||||
avian: "Avian",
|
||||
vllm: "VLLM (local)",
|
||||
@@ -28,6 +39,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||
minimax: "MiniMax",
|
||||
longcat: "LongCat",
|
||||
modelscope: "ModelScope (魔搭社区)",
|
||||
novita: "Novita AI",
|
||||
}
|
||||
|
||||
const PROVIDER_ALIASES: Record<string, string> = {
|
||||
@@ -40,6 +52,48 @@ const PROVIDER_ALIASES: Record<string, string> = {
|
||||
"google-antigravity": "antigravity",
|
||||
}
|
||||
|
||||
export const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
volcengine: 0,
|
||||
openai: 1,
|
||||
gemini: 2,
|
||||
anthropic: 3,
|
||||
bedrock: 4,
|
||||
elevenlabs: 5,
|
||||
"anthropic-messages": 6,
|
||||
zhipu: 7,
|
||||
deepseek: 8,
|
||||
openrouter: 9,
|
||||
"qwen-portal": 10,
|
||||
"qwen-intl": 11,
|
||||
"qwen-us": 12,
|
||||
moonshot: 13,
|
||||
groq: 14,
|
||||
"coding-plan": 15,
|
||||
"coding-plan-anthropic": 16,
|
||||
"github-copilot": 17,
|
||||
antigravity: 18,
|
||||
nvidia: 19,
|
||||
cerebras: 20,
|
||||
shengsuanyun: 21,
|
||||
venice: 22,
|
||||
vivgrid: 23,
|
||||
minimax: 24,
|
||||
longcat: 25,
|
||||
modelscope: 26,
|
||||
mistral: 27,
|
||||
avian: 28,
|
||||
novita: 29,
|
||||
azure: 30,
|
||||
litellm: 31,
|
||||
ollama: 32,
|
||||
vllm: 33,
|
||||
lmstudio: 34,
|
||||
"claude-cli": 35,
|
||||
"codex-cli": 36,
|
||||
zai: 37,
|
||||
mimo: 38,
|
||||
}
|
||||
|
||||
export function getProviderKey(provider?: string): string {
|
||||
const normalized = provider?.trim().toLowerCase()
|
||||
if (!normalized) return "openai"
|
||||
@@ -50,3 +104,45 @@ export function getProviderLabel(provider?: string): string {
|
||||
const prefix = getProviderKey(provider)
|
||||
return PROVIDER_LABELS[prefix] ?? prefix
|
||||
}
|
||||
|
||||
export function findProviderOption(
|
||||
provider: string | undefined,
|
||||
options: ModelProviderOption[],
|
||||
): ModelProviderOption | undefined {
|
||||
const providerKey = getProviderKey(provider)
|
||||
return options.find((option) => option.id === providerKey)
|
||||
}
|
||||
|
||||
export function getProviderDefaultAPIBase(
|
||||
provider: string | undefined,
|
||||
options: ModelProviderOption[],
|
||||
): string {
|
||||
return findProviderOption(provider, options)?.default_api_base ?? ""
|
||||
}
|
||||
|
||||
export function getSortedProviderOptions(
|
||||
options: ModelProviderOption[],
|
||||
): ModelProviderOption[] {
|
||||
return [...options].sort((a, b) => {
|
||||
const aPriority = PROVIDER_PRIORITY[a.id] ?? Number.MAX_SAFE_INTEGER
|
||||
const bPriority = PROVIDER_PRIORITY[b.id] ?? Number.MAX_SAFE_INTEGER
|
||||
if (aPriority !== bPriority) {
|
||||
return aPriority - bPriority
|
||||
}
|
||||
return getProviderLabel(a.id).localeCompare(getProviderLabel(b.id))
|
||||
})
|
||||
}
|
||||
|
||||
export function getProviderDefaultAuthMethod(
|
||||
provider: string | undefined,
|
||||
options: ModelProviderOption[],
|
||||
): string {
|
||||
return findProviderOption(provider, options)?.default_auth_method ?? ""
|
||||
}
|
||||
|
||||
export function isProviderAuthMethodLocked(
|
||||
provider: string | undefined,
|
||||
options: ModelProviderOption[],
|
||||
): boolean {
|
||||
return findProviderOption(provider, options)?.auth_method_locked === true
|
||||
}
|
||||
|
||||
@@ -27,17 +27,26 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||
const [defaultModelName, setDefaultModelName] = useState("")
|
||||
const setDefaultRequestIdRef = useRef(0)
|
||||
|
||||
const syncDefaultModelName = useCallback(
|
||||
(models: ModelInfo[], defaultModel: string) => {
|
||||
if (models.some((m) => m.model_name === defaultModel)) {
|
||||
setDefaultModelName(defaultModel)
|
||||
return
|
||||
}
|
||||
setDefaultModelName("")
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
const data = await getModels()
|
||||
setModelList(data.models)
|
||||
if (data.models.some((m) => m.model_name === data.default_model)) {
|
||||
setDefaultModelName(data.default_model)
|
||||
}
|
||||
syncDefaultModelName(data.models, data.default_model)
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, [])
|
||||
}, [syncDefaultModelName])
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = setTimeout(() => {
|
||||
@@ -60,9 +69,7 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||
}
|
||||
|
||||
setModelList(data.models)
|
||||
if (data.models.some((m) => m.model_name === data.default_model)) {
|
||||
setDefaultModelName(data.default_model)
|
||||
}
|
||||
syncDefaultModelName(data.models, data.default_model)
|
||||
const gateway = await refreshGatewayState({ force: true })
|
||||
showSaveSuccessOrRestartToast(
|
||||
t,
|
||||
@@ -75,30 +82,41 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||
toast.error(err instanceof Error ? err.message : t("models.loadError"))
|
||||
}
|
||||
},
|
||||
[defaultModelName, t],
|
||||
[defaultModelName, syncDefaultModelName, t],
|
||||
)
|
||||
|
||||
const defaultSelectableModels = useMemo(
|
||||
() =>
|
||||
modelList.filter(
|
||||
(m) => m.default_model_allowed !== false && m.is_virtual !== true,
|
||||
),
|
||||
[modelList],
|
||||
)
|
||||
|
||||
const hasAvailableModels = useMemo(
|
||||
() => modelList.some((m) => m.available),
|
||||
[modelList],
|
||||
() => defaultSelectableModels.some((m) => m.available),
|
||||
[defaultSelectableModels],
|
||||
)
|
||||
|
||||
const oauthModels = useMemo(
|
||||
() => modelList.filter((m) => m.available && m.auth_method === "oauth"),
|
||||
[modelList],
|
||||
() =>
|
||||
defaultSelectableModels.filter(
|
||||
(m) => m.available && m.auth_method === "oauth",
|
||||
),
|
||||
[defaultSelectableModels],
|
||||
)
|
||||
|
||||
const localModels = useMemo(
|
||||
() => modelList.filter((m) => m.available && isLocalModel(m)),
|
||||
[modelList],
|
||||
() => defaultSelectableModels.filter((m) => m.available && isLocalModel(m)),
|
||||
[defaultSelectableModels],
|
||||
)
|
||||
|
||||
const apiKeyModels = useMemo(
|
||||
() =>
|
||||
modelList.filter(
|
||||
defaultSelectableModels.filter(
|
||||
(m) => m.available && m.auth_method !== "oauth" && !isLocalModel(m),
|
||||
),
|
||||
[modelList],
|
||||
[defaultSelectableModels],
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -236,7 +236,8 @@
|
||||
"setting": "Setting as default...",
|
||||
"unavailable": "Cannot set unavailable model as default",
|
||||
"isDefault": "Already the default model",
|
||||
"isVirtual": "Cannot set virtual model as default"
|
||||
"isVirtual": "Cannot set virtual model as default",
|
||||
"unsupportedProvider": "This provider is ASR-only and cannot be the default chat model"
|
||||
},
|
||||
"deleteDisabled": {
|
||||
"isDefault": "Cannot delete the default model"
|
||||
@@ -244,7 +245,9 @@
|
||||
},
|
||||
"defaultOnSave": {
|
||||
"label": "Default Model",
|
||||
"description": "Automatically set this model as default after saving."
|
||||
"description": "Automatically set this model as default after saving.",
|
||||
"unsupportedProvider": "This provider can be saved in model_list, but it cannot be used as the default chat model.",
|
||||
"clearOnSave": "Saving this ASR-only model will clear the current default chat model selection."
|
||||
},
|
||||
"add": {
|
||||
"button": "Add Model",
|
||||
@@ -255,7 +258,7 @@
|
||||
"modelNameHint": "A short name used to identify this model in conversations.",
|
||||
"modelId": "Model Identifier",
|
||||
"modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
|
||||
"modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.",
|
||||
"modelIdHint": "This field is sent as the canonical model ID for the selected Provider. If the model ID itself contains slashes, such as openai/gpt-5.4, it is preserved as-is instead of being split again.",
|
||||
"errorRequired": "This field is required.",
|
||||
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
||||
"saveError": "Failed to add model",
|
||||
@@ -272,8 +275,9 @@
|
||||
},
|
||||
"field": {
|
||||
"provider": "Provider",
|
||||
"providerPlaceholder": "e.g. openai",
|
||||
"providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.",
|
||||
"providerPlaceholder": "Select a provider",
|
||||
"providerHint": "Choose a Provider from the backend catalog. The Model Identifier field is interpreted as that Provider's canonical model ID.",
|
||||
"providerInvalid": "The current Provider is invalid. Select a supported Provider.",
|
||||
"apiBase": "API Base URL",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyPlaceholder": "Enter your API key",
|
||||
@@ -282,6 +286,7 @@
|
||||
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
|
||||
"authMethod": "Auth Method",
|
||||
"authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.",
|
||||
"authMethodManagedHint": "This Provider manages its authentication mode automatically.",
|
||||
"connectMode": "Connect Mode",
|
||||
"connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.",
|
||||
"workspace": "Workspace Path",
|
||||
|
||||
@@ -236,7 +236,8 @@
|
||||
"setting": "正在设为默认...",
|
||||
"unavailable": "无法将不可用的模型设为默认",
|
||||
"isDefault": "该模型已是默认模型",
|
||||
"isVirtual": "无法将虚拟模型设为默认"
|
||||
"isVirtual": "无法将虚拟模型设为默认",
|
||||
"unsupportedProvider": "该 Provider 仅用于 ASR,不能设为默认聊天模型"
|
||||
},
|
||||
"deleteDisabled": {
|
||||
"isDefault": "无法删除默认模型"
|
||||
@@ -244,7 +245,9 @@
|
||||
},
|
||||
"defaultOnSave": {
|
||||
"label": "默认模型",
|
||||
"description": "保存后自动将该模型设置为默认模型。"
|
||||
"description": "保存后自动将该模型设置为默认模型。",
|
||||
"unsupportedProvider": "该 Provider 可以保存在 model_list 中,但不能作为默认聊天模型使用。",
|
||||
"clearOnSave": "保存这个仅用于 ASR 的模型后,会清除当前的默认聊天模型设置。"
|
||||
},
|
||||
"add": {
|
||||
"button": "添加模型",
|
||||
@@ -255,7 +258,7 @@
|
||||
"modelNameHint": "用于在对话中识别此模型的简短名称。",
|
||||
"modelId": "模型标识符",
|
||||
"modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o",
|
||||
"modelIdHint": "未指定 Provider 时,诸如 openai/gpt-4o 的值将按 provider/model 格式解析。已指定 Provider 时,此字段将作为规范模型 ID 使用,不再解析其中的 provider 前缀。",
|
||||
"modelIdHint": "此字段将作为所选 Provider 的规范模型 ID 使用。若模型标识符本身包含斜杠(如 openai/gpt-5.4),将作为完整 ID 保留,不会再次拆分 Provider。",
|
||||
"errorRequired": "此字段为必填项。",
|
||||
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
|
||||
"saveError": "添加模型失败",
|
||||
@@ -272,8 +275,9 @@
|
||||
},
|
||||
"field": {
|
||||
"provider": "Provider",
|
||||
"providerPlaceholder": "例如 openai",
|
||||
"providerHint": "可选。指定后,将以该值作为最终 provider,并将“模型标识符”字段解释为规范模型 ID。",
|
||||
"providerPlaceholder": "请选择 Provider",
|
||||
"providerHint": "请选择一个由后端 catalog 提供的 Provider;“模型标识符”字段会按该 Provider 的规范模型 ID 解释。",
|
||||
"providerInvalid": "当前 Provider 无效,请重新选择一个受支持的 Provider。",
|
||||
"apiBase": "API Base URL",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyPlaceholder": "请输入 API Key",
|
||||
@@ -282,6 +286,7 @@
|
||||
"proxyHint": "可选。例如 http://127.0.0.1:7890",
|
||||
"authMethod": "认证方式",
|
||||
"authMethodHint": "认证方式:oauth、token。留空表示使用 API Key 认证。",
|
||||
"authMethodManagedHint": "该 Provider 的认证方式由系统自动管理。",
|
||||
"connectMode": "连接模式",
|
||||
"connectModeHint": "CLI 型服务商的连接模式:stdio 或 grpc。",
|
||||
"workspace": "工作目录",
|
||||
|
||||
Reference in New Issue
Block a user