fix(provider,web,asr): normalize elevenlabs configs and gate default chat models
This commit is contained in:
parent
6a096a0d46
commit
b3520d930e
18 changed files with 424 additions and 210 deletions
|
|
@ -82,7 +82,8 @@ Notes:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "elevenlabs-asr",
|
"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 |
|
| 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. |
|
| 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. |
|
| 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`.
|
1. **Preferred path**: resolve `voice.model_name` against `model_list`.
|
||||||
2. If that resolved model is:
|
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 OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber.
|
||||||
- an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`.
|
- 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.
|
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_list": [
|
||||||
{
|
{
|
||||||
"model_name": "elevenlabs-asr",
|
"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` 接口。 |
|
| Whisper 接口模型 | `openai/whisper-1`、`groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 |
|
||||||
| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 |
|
| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 |
|
||||||
|
|
||||||
|
|
@ -142,7 +143,7 @@ PicoClaw 目前主要支持三种 ASR 路径:
|
||||||
|
|
||||||
1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。
|
1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。
|
||||||
2. 如果找到的模型属于以下类型:
|
2. 如果找到的模型属于以下类型:
|
||||||
- `elevenlabs/...`,则使用 ElevenLabs transcriber。
|
- `provider=elevenlabs` 的模型,则使用 ElevenLabs transcriber。
|
||||||
- OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。
|
- OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。
|
||||||
- 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。
|
- 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。
|
||||||
3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。
|
3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。
|
||||||
|
|
|
||||||
|
|
@ -78,21 +78,7 @@ func isElevenLabsTranscriptionModel(modelCfg *config.ModelConfig) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
protocol, _ := providers.ExtractProtocol(modelCfg)
|
||||||
if protocol == "elevenlabs" {
|
return protocol == "elevenlabs"
|
||||||
return true
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(modelCfg.Provider) != "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
legacyProvider, legacyModel, found := strings.Cut(strings.TrimSpace(modelCfg.Model), "/")
|
|
||||||
if !found || strings.TrimSpace(legacyModel) == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep legacy elevenlabs/... ASR configs working even though the shared
|
|
||||||
// provider catalog no longer treats elevenlabs as a general model provider.
|
|
||||||
return providers.NormalizeProvider(legacyProvider) == "elevenlabs"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
|
func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,21 @@ func TestDetectTranscriber(t *testing.T) {
|
||||||
},
|
},
|
||||||
wantName: "elevenlabs",
|
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",
|
name: "voice model name alias selects whisper transcriber for groq",
|
||||||
cfg: &config.Config{
|
cfg: &config.Config{
|
||||||
|
|
|
||||||
|
|
@ -987,6 +987,16 @@ func TestModelProviderOptions(t *testing.T) {
|
||||||
} else if !option.CreateAllowed {
|
} else if !option.CreateAllowed {
|
||||||
t.Fatal("bedrock should be creatable and defer credential/build errors to runtime")
|
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 {
|
if option, ok := seen["antigravity"]; !ok {
|
||||||
t.Fatal("antigravity option missing")
|
t.Fatal("antigravity option missing")
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ type ModelProviderOption struct {
|
||||||
DefaultAPIBase string `json:"default_api_base"`
|
DefaultAPIBase string `json:"default_api_base"`
|
||||||
EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"`
|
EmptyAPIKeyAllowed bool `json:"empty_api_key_allowed"`
|
||||||
CreateAllowed bool `json:"create_allowed"`
|
CreateAllowed bool `json:"create_allowed"`
|
||||||
|
DefaultModelAllowed bool `json:"default_model_allowed"`
|
||||||
DefaultAuthMethod string `json:"default_auth_method,omitempty"`
|
DefaultAuthMethod string `json:"default_auth_method,omitempty"`
|
||||||
AuthMethodLocked bool `json:"auth_method_locked,omitempty"`
|
AuthMethodLocked bool `json:"auth_method_locked,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -18,6 +19,7 @@ type ModelProviderOption struct {
|
||||||
type attachedModelProviderMeta struct {
|
type attachedModelProviderMeta struct {
|
||||||
protocolMeta
|
protocolMeta
|
||||||
createAllowed bool
|
createAllowed bool
|
||||||
|
defaultModelAllowed bool
|
||||||
defaultAuthMethod string
|
defaultAuthMethod string
|
||||||
authMethodLocked bool
|
authMethodLocked bool
|
||||||
}
|
}
|
||||||
|
|
@ -27,20 +29,39 @@ type attachedModelProviderMeta struct {
|
||||||
// kept out of the core HTTP metadata map because they have special auth/runtime
|
// kept out of the core HTTP metadata map because they have special auth/runtime
|
||||||
// semantics.
|
// semantics.
|
||||||
var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{
|
var attachedModelProviderMetaByName = map[string]attachedModelProviderMeta{
|
||||||
"azure": {createAllowed: true},
|
"azure": {createAllowed: true, defaultModelAllowed: true},
|
||||||
"anthropic": {
|
"anthropic": {
|
||||||
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
|
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
|
||||||
createAllowed: true,
|
createAllowed: true,
|
||||||
|
defaultModelAllowed: true,
|
||||||
},
|
},
|
||||||
"anthropic-messages": {
|
"anthropic-messages": {
|
||||||
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
|
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
|
||||||
createAllowed: true,
|
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,
|
||||||
},
|
},
|
||||||
"bedrock": {createAllowed: true},
|
|
||||||
"antigravity": {createAllowed: true, defaultAuthMethod: "oauth", authMethodLocked: true},
|
|
||||||
"claude-cli": {createAllowed: true},
|
|
||||||
"codex-cli": {createAllowed: true},
|
|
||||||
"github-copilot": {protocolMeta: protocolMeta{defaultAPIBase: "localhost:4321"}, createAllowed: true},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelProviderOptions returns the canonical provider catalog exposed to the Web UI.
|
// ModelProviderOptions returns the canonical provider catalog exposed to the Web UI.
|
||||||
|
|
@ -55,6 +76,7 @@ func ModelProviderOptions() []ModelProviderOption {
|
||||||
DefaultAPIBase: DefaultAPIBaseForProtocol(provider),
|
DefaultAPIBase: DefaultAPIBaseForProtocol(provider),
|
||||||
EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider),
|
EmptyAPIKeyAllowed: IsEmptyAPIKeyAllowedForProtocol(provider),
|
||||||
CreateAllowed: true,
|
CreateAllowed: true,
|
||||||
|
DefaultModelAllowed: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for provider, meta := range attachedModelProviderMetaByName {
|
for provider, meta := range attachedModelProviderMetaByName {
|
||||||
|
|
@ -66,6 +88,7 @@ func ModelProviderOptions() []ModelProviderOption {
|
||||||
DefaultAPIBase: meta.defaultAPIBase,
|
DefaultAPIBase: meta.defaultAPIBase,
|
||||||
EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed,
|
EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed,
|
||||||
CreateAllowed: meta.createAllowed,
|
CreateAllowed: meta.createAllowed,
|
||||||
|
DefaultModelAllowed: meta.defaultModelAllowed,
|
||||||
DefaultAuthMethod: meta.defaultAuthMethod,
|
DefaultAuthMethod: meta.defaultAuthMethod,
|
||||||
AuthMethodLocked: meta.authMethodLocked,
|
AuthMethodLocked: meta.authMethodLocked,
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +132,21 @@ func IsCreatableModelProvider(provider string) bool {
|
||||||
return ok && meta.createAllowed
|
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
|
// SplitModelProviderAndID separates a legacy "provider/model" string into its
|
||||||
// effective provider and canonical model ID. Unknown prefixes are treated as
|
// effective provider and canonical model ID. Unknown prefixes are treated as
|
||||||
// part of the model ID and fall back to defaultProvider.
|
// part of the model ID and fall back to defaultProvider.
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,9 @@ func (h *Handler) gatewayStartReady() (bool, string, error) {
|
||||||
if modelCfg == nil {
|
if modelCfg == nil {
|
||||||
return false, fmt.Sprintf("default model %q is invalid", modelName), 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) {
|
if !hasModelConfiguration(modelCfg) {
|
||||||
return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil
|
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) {
|
func TestLooksLikeGatewayCommandLine(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -49,48 +49,7 @@ type modelResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
IsVirtual bool `json:"is_virtual"`
|
IsVirtual bool `json:"is_virtual"`
|
||||||
}
|
DefaultModelAllowed bool `json:"default_model_allowed"`
|
||||||
|
|
||||||
func legacyUnsupportedASRProviderAndModel(rawModel string) (provider, modelID string, ok bool) {
|
|
||||||
provider, modelID, found := strings.Cut(strings.TrimSpace(rawModel), "/")
|
|
||||||
if !found {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
provider = providers.NormalizeProvider(provider)
|
|
||||||
modelID = strings.TrimSpace(modelID)
|
|
||||||
if modelID == "" {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
switch provider {
|
|
||||||
case "elevenlabs":
|
|
||||||
// Keep the documented legacy ASR-only form elevenlabs/scribe_v1 stable
|
|
||||||
// even though elevenlabs is not part of the general model provider
|
|
||||||
// catalog exposed by the Web model-management UI.
|
|
||||||
return provider, modelID, true
|
|
||||||
default:
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isLegacyUnsupportedASRModelConfig(mc *config.ModelConfig) bool {
|
|
||||||
if mc == nil || strings.TrimSpace(mc.Provider) != "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, ok := legacyUnsupportedASRProviderAndModel(mc.Model)
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func responseProviderAndModel(mc *config.ModelConfig) (provider, modelID string) {
|
|
||||||
if strings.TrimSpace(mc.Provider) == "" {
|
|
||||||
if legacyProvider, legacyModelID, ok := legacyUnsupportedASRProviderAndModel(mc.Model); ok {
|
|
||||||
return legacyProvider, legacyModelID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return providers.ExtractProtocol(mc)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
|
func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
|
||||||
|
|
@ -121,9 +80,19 @@ func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
|
||||||
mc.Provider = normalizedProvider
|
mc.Provider = normalizedProvider
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
return changed
|
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 isLegacyUnsupportedASRModelConfig(mc) {
|
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,12 +120,17 @@ func normalizeIncomingModelConfig(mc *config.ModelConfig) {
|
||||||
mc.Provider = strings.TrimSpace(mc.Provider)
|
mc.Provider = strings.TrimSpace(mc.Provider)
|
||||||
mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod))
|
mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod))
|
||||||
if mc.Provider == "" {
|
if mc.Provider == "" {
|
||||||
if isLegacyUnsupportedASRModelConfig(mc) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai")
|
mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai")
|
||||||
} else {
|
} else {
|
||||||
mc.Provider = providers.NormalizeProvider(mc.Provider)
|
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 == "" {
|
if mc.Provider == "antigravity" && mc.AuthMethod == "" {
|
||||||
mc.AuthMethod = "oauth"
|
mc.AuthMethod = "oauth"
|
||||||
|
|
@ -199,6 +173,11 @@ func modelProviderOptionsForResponse() []providers.ModelProviderOption {
|
||||||
return options
|
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 {
|
func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelConfig) error {
|
||||||
if mc == nil {
|
if mc == nil {
|
||||||
return fmt.Errorf("model config is required")
|
return fmt.Errorf("model config is required")
|
||||||
|
|
@ -207,9 +186,6 @@ func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelC
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(mc.Provider) == "" {
|
if strings.TrimSpace(mc.Provider) == "" {
|
||||||
if existing != nil && isLegacyUnsupportedASRModelConfig(existing) && isLegacyUnsupportedASRModelConfig(mc) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("provider is required")
|
return fmt.Errorf("provider is required")
|
||||||
}
|
}
|
||||||
if !providers.IsSupportedModelProvider(mc.Provider) {
|
if !providers.IsSupportedModelProvider(mc.Provider) {
|
||||||
|
|
@ -266,7 +242,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
models := make([]modelResponse, 0, len(cfg.ModelList))
|
models := make([]modelResponse, 0, len(cfg.ModelList))
|
||||||
for i, m := range cfg.ModelList {
|
for i, m := range cfg.ModelList {
|
||||||
provider, modelID := responseProviderAndModel(m)
|
provider, modelID := providers.ExtractProtocol(m)
|
||||||
models = append(models, modelResponse{
|
models = append(models, modelResponse{
|
||||||
Index: i,
|
Index: i,
|
||||||
ModelName: m.ModelName,
|
ModelName: m.ModelName,
|
||||||
|
|
@ -289,6 +265,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
Status: modelStatuses[i].Status,
|
Status: modelStatuses[i].Status,
|
||||||
IsDefault: m.ModelName == defaultModel,
|
IsDefault: m.ModelName == defaultModel,
|
||||||
IsVirtual: m.IsVirtual(),
|
IsVirtual: m.IsVirtual(),
|
||||||
|
DefaultModelAllowed: defaultModelAllowedForModelConfig(m),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,21 +416,6 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
|
if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
|
||||||
existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
|
existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
|
||||||
incomingModel := strings.TrimSpace(mc.Model)
|
incomingModel := strings.TrimSpace(mc.Model)
|
||||||
if legacyProvider, legacyModelID, ok := legacyUnsupportedASRProviderAndModel(existingRawModel); ok {
|
|
||||||
if incomingModel != "" {
|
|
||||||
if incomingModel == legacyModelID {
|
|
||||||
mc.Model = existingRawModel
|
|
||||||
} else if strings.Contains(incomingModel, "/") && !strings.Contains(legacyModelID, "/") {
|
|
||||||
// Older clients only saw the visible legacy ASR model ID
|
|
||||||
// (for example "scribe_v1"). If they now send an explicit
|
|
||||||
// provider/model string, keep that full intent instead of
|
|
||||||
// silently re-applying the hidden ElevenLabs prefix.
|
|
||||||
mc.Model = incomingModel
|
|
||||||
} else if !strings.HasPrefix(incomingModel, legacyProvider+"/") {
|
|
||||||
mc.Model = legacyProvider + "/" + incomingModel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
|
existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
|
||||||
if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
|
if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
|
||||||
if incomingModel == existingModelID {
|
if incomingModel == existingModelID {
|
||||||
|
|
@ -470,13 +432,18 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
normalizeIncomingModelConfig(&mc.ModelConfig)
|
normalizeIncomingModelConfig(&mc.ModelConfig)
|
||||||
if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil {
|
if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
|
||||||
return
|
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
|
cfg.ModelList[idx] = &mc.ModelConfig
|
||||||
normalizeStoredModelProviders(cfg)
|
normalizeStoredModelProviders(cfg)
|
||||||
|
|
@ -579,6 +546,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)
|
http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest)
|
||||||
return
|
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
|
cfg.Agents.Defaults.ModelName = req.ModelName
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -820,7 +820,7 @@ func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleAddModel_PreservesLegacyElevenLabsASRConfig(t *testing.T) {
|
func TestHandleAddModel_NormalizesLegacyElevenLabsASRConfig(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
|
|
@ -862,11 +862,11 @@ func TestHandleAddModel_PreservesLegacyElevenLabsASRConfig(t *testing.T) {
|
||||||
if len(updated.ModelList) != 2 {
|
if len(updated.ModelList) != 2 {
|
||||||
t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList))
|
t.Fatalf("len(model_list) = %d, want 2", len(updated.ModelList))
|
||||||
}
|
}
|
||||||
if got := updated.ModelList[0].Provider; got != "" {
|
if got := updated.ModelList[0].Provider; got != "elevenlabs" {
|
||||||
t.Fatalf("provider = %q, want preserved empty provider for legacy ElevenLabs ASR config", got)
|
t.Fatalf("provider = %q, want %q after normalization", got, "elevenlabs")
|
||||||
}
|
}
|
||||||
if got := updated.ModelList[0].Model; got != "elevenlabs/scribe_v1" {
|
if got := updated.ModelList[0].Model; got != "scribe_v1" {
|
||||||
t.Fatalf("model = %q, want preserved legacy ElevenLabs model ref", got)
|
t.Fatalf("model = %q, want %q after normalization", got, "scribe_v1")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1208,7 +1208,7 @@ func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleListModels_ExposesLegacyElevenLabsASRProvider(t *testing.T) {
|
func TestHandleListModels_ExposesElevenLabsASRProvider(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
|
|
@ -1247,10 +1247,13 @@ func TestHandleListModels_ExposesLegacyElevenLabsASRProvider(t *testing.T) {
|
||||||
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||||
}
|
}
|
||||||
if got := resp.Models[0].Provider; got != "elevenlabs" {
|
if got := resp.Models[0].Provider; got != "elevenlabs" {
|
||||||
t.Fatalf("provider = %q, want %q for legacy unsupported ASR entry", got, "elevenlabs")
|
t.Fatalf("provider = %q, want %q", got, "elevenlabs")
|
||||||
}
|
}
|
||||||
if got := resp.Models[0].Model; got != "scribe_v1" {
|
if got := resp.Models[0].Model; got != "scribe_v1" {
|
||||||
t.Fatalf("model = %q, want %q for legacy unsupported ASR entry", got, "scribe_v1")
|
t.Fatalf("model = %q, want %q", got, "scribe_v1")
|
||||||
|
}
|
||||||
|
if resp.Models[0].DefaultModelAllowed {
|
||||||
|
t.Fatal("elevenlabs ASR model should not be allowed as the default chat model")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1324,7 +1327,7 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *test
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleUpdateModel_PreservesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) {
|
func TestHandleUpdateModel_MigratesLegacyElevenLabsASRWhenProviderOmitted(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
|
|
@ -1386,17 +1389,63 @@ func TestHandleUpdateModel_PreservesLegacyElevenLabsASRWhenProviderOmitted(t *te
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
if got := updated.ModelList[0].Provider; got != "" {
|
if got := updated.ModelList[0].Provider; got != "elevenlabs" {
|
||||||
t.Fatalf("provider = %q, want preserved empty provider", got)
|
t.Fatalf("provider = %q, want %q", got, "elevenlabs")
|
||||||
}
|
}
|
||||||
if got := updated.ModelList[0].Model; got != "elevenlabs/scribe_v1" {
|
if got := updated.ModelList[0].Model; got != "scribe_v1" {
|
||||||
t.Fatalf("model = %q, want preserved legacy model ref", got)
|
t.Fatalf("model = %q, want %q", got, "scribe_v1")
|
||||||
}
|
}
|
||||||
if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" {
|
if got := updated.ModelList[0].APIBase; got != "https://api.elevenlabs.io" {
|
||||||
t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io")
|
t.Fatalf("api_base = %q, want %q", got, "https://api.elevenlabs.io")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleUpdateModel_ClearsDefaultWhenSavingASROnlyModel(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"
|
||||||
|
if err = config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
|
||||||
|
"model_name":"elevenlabs-asr",
|
||||||
|
"provider":"elevenlabs",
|
||||||
|
"model":"scribe_v1",
|
||||||
|
"api_base":"https://api.elevenlabs.io"
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := updated.Agents.Defaults.ModelName; got != "" {
|
||||||
|
t.Fatalf("default model = %q, want cleared default", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) {
|
func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
@ -1512,6 +1561,16 @@ func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration
|
||||||
} else if option.DefaultAPIBase != "localhost:4321" {
|
} else if option.DefaultAPIBase != "localhost:4321" {
|
||||||
t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321")
|
t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321")
|
||||||
}
|
}
|
||||||
|
if option, ok := optionsByID["elevenlabs"]; !ok {
|
||||||
|
t.Fatal("elevenlabs provider 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 marked as not allowed for default chat model selection")
|
||||||
|
}
|
||||||
|
}
|
||||||
if option, ok := optionsByID["lmstudio"]; !ok {
|
if option, ok := optionsByID["lmstudio"]; !ok {
|
||||||
t.Fatal("lmstudio provider option missing")
|
t.Fatal("lmstudio provider option missing")
|
||||||
} else if !option.EmptyAPIKeyAllowed {
|
} else if !option.EmptyAPIKeyAllowed {
|
||||||
|
|
@ -1809,6 +1868,45 @@ func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleSetDefaultModel_RejectsElevenLabsASRProvider(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"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{
|
||||||
|
"model_name": "elevenlabs-asr"
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "cannot be used as the default chat model") {
|
||||||
|
t.Fatalf("body = %q, want default chat model rejection", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMaskAPIKey(t *testing.T) {
|
func TestMaskAPIKey(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export interface ModelInfo {
|
||||||
status: "available" | "unconfigured" | "unreachable"
|
status: "available" | "unconfigured" | "unreachable"
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
is_virtual: boolean
|
is_virtual: boolean
|
||||||
|
default_model_allowed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelProviderOption {
|
export interface ModelProviderOption {
|
||||||
|
|
@ -33,6 +34,7 @@ export interface ModelProviderOption {
|
||||||
default_api_base: string
|
default_api_base: string
|
||||||
empty_api_key_allowed: boolean
|
empty_api_key_allowed: boolean
|
||||||
create_allowed: boolean
|
create_allowed: boolean
|
||||||
|
default_model_allowed: boolean
|
||||||
default_auth_method?: string
|
default_auth_method?: string
|
||||||
auth_method_locked?: boolean
|
auth_method_locked?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,8 @@ export function AddModelSheet({
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
const isOAuth = effectiveAuthMethod === "oauth"
|
const isOAuth = effectiveAuthMethod === "oauth"
|
||||||
|
const defaultModelAllowed =
|
||||||
|
selectedProviderOption?.default_model_allowed !== false
|
||||||
const apiBasePlaceholder =
|
const apiBasePlaceholder =
|
||||||
getProviderDefaultAPIBase(form.provider, providerOptions) ||
|
getProviderDefaultAPIBase(form.provider, providerOptions) ||
|
||||||
"https://api.example.com/v1"
|
"https://api.example.com/v1"
|
||||||
|
|
@ -189,6 +191,10 @@ export function AddModelSheet({
|
||||||
}
|
}
|
||||||
return { ...f, provider: value, authMethod }
|
return { ...f, provider: value, authMethod }
|
||||||
})
|
})
|
||||||
|
const nextOption = findProviderOption(value, providerOptions)
|
||||||
|
if (nextOption?.default_model_allowed === false) {
|
||||||
|
setSetAsDefault(false)
|
||||||
|
}
|
||||||
if (fieldErrors.provider) {
|
if (fieldErrors.provider) {
|
||||||
setFieldErrors((prev) => ({ ...prev, provider: undefined }))
|
setFieldErrors((prev) => ({ ...prev, provider: undefined }))
|
||||||
}
|
}
|
||||||
|
|
@ -345,9 +351,14 @@ export function AddModelSheet({
|
||||||
|
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("models.defaultOnSave.label")}
|
label={t("models.defaultOnSave.label")}
|
||||||
hint={t("models.defaultOnSave.description")}
|
hint={
|
||||||
|
defaultModelAllowed
|
||||||
|
? t("models.defaultOnSave.description")
|
||||||
|
: t("models.defaultOnSave.unsupportedProvider")
|
||||||
|
}
|
||||||
checked={setAsDefault}
|
checked={setAsDefault}
|
||||||
onCheckedChange={setSetAsDefault}
|
onCheckedChange={setSetAsDefault}
|
||||||
|
disabled={!defaultModelAllowed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdvancedSection>
|
<AdvancedSection>
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,10 @@ export function EditModelSheet({
|
||||||
const providerError = selectedProviderOption
|
const providerError = selectedProviderOption
|
||||||
? ""
|
? ""
|
||||||
: t("models.field.providerInvalid")
|
: t("models.field.providerInvalid")
|
||||||
|
const defaultModelAllowed =
|
||||||
|
selectedProviderOption?.default_model_allowed !== false
|
||||||
|
const willClearDefaultOnSave =
|
||||||
|
model?.is_default === true && defaultModelAllowed === false
|
||||||
const apiBasePlaceholder =
|
const apiBasePlaceholder =
|
||||||
getProviderDefaultAPIBase(form.provider, providerOptions) ||
|
getProviderDefaultAPIBase(form.provider, providerOptions) ||
|
||||||
"https://api.example.com/v1"
|
"https://api.example.com/v1"
|
||||||
|
|
@ -167,7 +171,7 @@ export function EditModelSheet({
|
||||||
initialForm.authMethod = option.default_auth_method ?? ""
|
initialForm.authMethod = option.default_auth_method ?? ""
|
||||||
}
|
}
|
||||||
setForm(initialForm)
|
setForm(initialForm)
|
||||||
setSetAsDefault(model.is_default)
|
setSetAsDefault(model.is_default && model.default_model_allowed !== false)
|
||||||
setError("")
|
setError("")
|
||||||
}
|
}
|
||||||
}, [model, providerOptions])
|
}, [model, providerOptions])
|
||||||
|
|
@ -199,6 +203,10 @@ export function EditModelSheet({
|
||||||
}
|
}
|
||||||
return { ...f, provider: value, authMethod }
|
return { ...f, provider: value, authMethod }
|
||||||
})
|
})
|
||||||
|
const nextOption = findProviderOption(value, providerOptions)
|
||||||
|
if (nextOption?.default_model_allowed === false) {
|
||||||
|
setSetAsDefault(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|
@ -358,9 +366,16 @@ export function EditModelSheet({
|
||||||
|
|
||||||
<SwitchCardField
|
<SwitchCardField
|
||||||
label={t("models.defaultOnSave.label")}
|
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}
|
checked={setAsDefault}
|
||||||
onCheckedChange={setSetAsDefault}
|
onCheckedChange={setSetAsDefault}
|
||||||
|
disabled={!defaultModelAllowed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdvancedSection>
|
<AdvancedSection>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ export function ModelCard({
|
||||||
const status = model.status
|
const status = model.status
|
||||||
const statusLabel = t(`models.status.${status}`)
|
const statusLabel = t(`models.status.${status}`)
|
||||||
const canSetDefault =
|
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 setDefaultLabel = t("models.action.setDefault")
|
||||||
const setDefaultDisabledReason = (() => {
|
const setDefaultDisabledReason = (() => {
|
||||||
|
|
@ -45,6 +48,9 @@ export function ModelCard({
|
||||||
return t("models.action.setDefaultDisabled.unavailable")
|
return t("models.action.setDefaultDisabled.unavailable")
|
||||||
if (model.is_default) return t("models.action.setDefaultDisabled.isDefault")
|
if (model.is_default) return t("models.action.setDefaultDisabled.isDefault")
|
||||||
if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual")
|
if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual")
|
||||||
|
if (model.default_model_allowed === false) {
|
||||||
|
return t("models.action.setDefaultDisabled.unsupportedProvider")
|
||||||
|
}
|
||||||
return setDefaultLabel
|
return setDefaultLabel
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useMemo, useState } from "react"
|
||||||
|
|
||||||
const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
||||||
openai: "openai",
|
openai: "openai",
|
||||||
|
elevenlabs: "elevenlabs",
|
||||||
anthropic: "anthropic",
|
anthropic: "anthropic",
|
||||||
azure: "microsoftazure",
|
azure: "microsoftazure",
|
||||||
gemini: "googlegemini",
|
gemini: "googlegemini",
|
||||||
|
|
@ -21,6 +22,7 @@ const PROVIDER_ICON_SLUGS: Record<string, string> = {
|
||||||
|
|
||||||
const PROVIDER_DOMAINS: Record<string, string> = {
|
const PROVIDER_DOMAINS: Record<string, string> = {
|
||||||
openai: "openai.com",
|
openai: "openai.com",
|
||||||
|
elevenlabs: "elevenlabs.io",
|
||||||
anthropic: "anthropic.com",
|
anthropic: "anthropic.com",
|
||||||
azure: "azure.com",
|
azure: "azure.com",
|
||||||
gemini: "gemini.google.com",
|
gemini: "gemini.google.com",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { ModelProviderOption } from "@/api/models"
|
||||||
const PROVIDER_LABELS: Record<string, string> = {
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
openai: "OpenAI",
|
openai: "OpenAI",
|
||||||
bedrock: "AWS Bedrock",
|
bedrock: "AWS Bedrock",
|
||||||
|
elevenlabs: "ElevenLabs ASR",
|
||||||
anthropic: "Anthropic",
|
anthropic: "Anthropic",
|
||||||
"anthropic-messages": "Anthropic Messages",
|
"anthropic-messages": "Anthropic Messages",
|
||||||
azure: "Azure OpenAI",
|
azure: "Azure OpenAI",
|
||||||
|
|
@ -57,39 +58,40 @@ export const PROVIDER_PRIORITY: Record<string, number> = {
|
||||||
gemini: 2,
|
gemini: 2,
|
||||||
anthropic: 3,
|
anthropic: 3,
|
||||||
bedrock: 4,
|
bedrock: 4,
|
||||||
"anthropic-messages": 5,
|
elevenlabs: 5,
|
||||||
zhipu: 6,
|
"anthropic-messages": 6,
|
||||||
deepseek: 7,
|
zhipu: 7,
|
||||||
openrouter: 8,
|
deepseek: 8,
|
||||||
"qwen-portal": 9,
|
openrouter: 9,
|
||||||
"qwen-intl": 10,
|
"qwen-portal": 10,
|
||||||
"qwen-us": 11,
|
"qwen-intl": 11,
|
||||||
moonshot: 12,
|
"qwen-us": 12,
|
||||||
groq: 13,
|
moonshot: 13,
|
||||||
"coding-plan": 14,
|
groq: 14,
|
||||||
"coding-plan-anthropic": 15,
|
"coding-plan": 15,
|
||||||
"github-copilot": 16,
|
"coding-plan-anthropic": 16,
|
||||||
antigravity: 17,
|
"github-copilot": 17,
|
||||||
nvidia: 18,
|
antigravity: 18,
|
||||||
cerebras: 19,
|
nvidia: 19,
|
||||||
shengsuanyun: 20,
|
cerebras: 20,
|
||||||
venice: 21,
|
shengsuanyun: 21,
|
||||||
vivgrid: 22,
|
venice: 22,
|
||||||
minimax: 23,
|
vivgrid: 23,
|
||||||
longcat: 24,
|
minimax: 24,
|
||||||
modelscope: 25,
|
longcat: 25,
|
||||||
mistral: 26,
|
modelscope: 26,
|
||||||
avian: 27,
|
mistral: 27,
|
||||||
novita: 28,
|
avian: 28,
|
||||||
azure: 29,
|
novita: 29,
|
||||||
litellm: 30,
|
azure: 30,
|
||||||
ollama: 31,
|
litellm: 31,
|
||||||
vllm: 32,
|
ollama: 32,
|
||||||
lmstudio: 33,
|
vllm: 33,
|
||||||
"claude-cli": 34,
|
lmstudio: 34,
|
||||||
"codex-cli": 35,
|
"claude-cli": 35,
|
||||||
zai: 36,
|
"codex-cli": 36,
|
||||||
mimo: 37,
|
zai: 37,
|
||||||
|
mimo: 38,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProviderKey(provider?: string): string {
|
export function getProviderKey(provider?: string): string {
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,8 @@
|
||||||
"setting": "Setting as default...",
|
"setting": "Setting as default...",
|
||||||
"unavailable": "Cannot set unavailable model as default",
|
"unavailable": "Cannot set unavailable model as default",
|
||||||
"isDefault": "Already the default model",
|
"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": {
|
"deleteDisabled": {
|
||||||
"isDefault": "Cannot delete the default model"
|
"isDefault": "Cannot delete the default model"
|
||||||
|
|
@ -244,7 +245,9 @@
|
||||||
},
|
},
|
||||||
"defaultOnSave": {
|
"defaultOnSave": {
|
||||||
"label": "Default Model",
|
"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": {
|
"add": {
|
||||||
"button": "Add Model",
|
"button": "Add Model",
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,8 @@
|
||||||
"setting": "正在设为默认...",
|
"setting": "正在设为默认...",
|
||||||
"unavailable": "无法将不可用的模型设为默认",
|
"unavailable": "无法将不可用的模型设为默认",
|
||||||
"isDefault": "该模型已是默认模型",
|
"isDefault": "该模型已是默认模型",
|
||||||
"isVirtual": "无法将虚拟模型设为默认"
|
"isVirtual": "无法将虚拟模型设为默认",
|
||||||
|
"unsupportedProvider": "该 Provider 仅用于 ASR,不能设为默认聊天模型"
|
||||||
},
|
},
|
||||||
"deleteDisabled": {
|
"deleteDisabled": {
|
||||||
"isDefault": "无法删除默认模型"
|
"isDefault": "无法删除默认模型"
|
||||||
|
|
@ -244,7 +245,9 @@
|
||||||
},
|
},
|
||||||
"defaultOnSave": {
|
"defaultOnSave": {
|
||||||
"label": "默认模型",
|
"label": "默认模型",
|
||||||
"description": "保存后自动将该模型设置为默认模型。"
|
"description": "保存后自动将该模型设置为默认模型。",
|
||||||
|
"unsupportedProvider": "该 Provider 可以保存在 model_list 中,但不能作为默认聊天模型使用。",
|
||||||
|
"clearOnSave": "保存这个仅用于 ASR 的模型后,会清除当前的默认聊天模型设置。"
|
||||||
},
|
},
|
||||||
"add": {
|
"add": {
|
||||||
"button": "添加模型",
|
"button": "添加模型",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue