feat(provider,web): enhance model management with provider options
This commit is contained in:
parent
9b109dc7a8
commit
ad538d6c5d
15 changed files with 1513 additions and 131 deletions
|
|
@ -110,19 +110,7 @@ func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) {
|
||||||
if provider := strings.TrimSpace(cfg.Provider); provider != "" {
|
if provider := strings.TrimSpace(cfg.Provider); provider != "" {
|
||||||
return NormalizeProvider(provider), model
|
return NormalizeProvider(provider), model
|
||||||
}
|
}
|
||||||
if model == "" {
|
return SplitModelProviderAndID(model, "openai")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveAPIBase returns the configured API base, or the protocol default when
|
// 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)
|
protocol, modelID := ExtractProtocol(cfg)
|
||||||
|
authMethod := strings.ToLower(strings.TrimSpace(cfg.AuthMethod))
|
||||||
|
|
||||||
userAgent := cfg.UserAgent
|
userAgent := cfg.UserAgent
|
||||||
if userAgent == "" {
|
if userAgent == "" {
|
||||||
|
|
@ -163,7 +152,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
switch protocol {
|
switch protocol {
|
||||||
case "openai":
|
case "openai":
|
||||||
// OpenAI with OAuth/token auth (Codex-style)
|
// OpenAI with OAuth/token auth (Codex-style)
|
||||||
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
if authMethod == "oauth" || authMethod == "token" {
|
||||||
provider, err := createCodexAuthProvider()
|
provider, err := createCodexAuthProvider()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
|
|
@ -320,7 +309,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
return provider, modelID, nil
|
return provider, modelID, nil
|
||||||
|
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
if authMethod == "oauth" || authMethod == "token" {
|
||||||
// Use OAuth credentials from auth store
|
// Use OAuth credentials from auth store
|
||||||
provider, err := createClaudeAuthProvider()
|
provider, err := createClaudeAuthProvider()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -419,7 +408,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func isEmptyAPIKeyAllowed(protocol string) bool {
|
func isEmptyAPIKeyAllowed(protocol string) bool {
|
||||||
meta, ok := protocolMetaByName[protocol]
|
meta, ok := protocolMetaForName(protocol)
|
||||||
return ok && meta.emptyAPIKeyAllowed
|
return ok && meta.emptyAPIKeyAllowed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,9 +428,19 @@ func DefaultAPIBaseForProtocol(protocol string) string {
|
||||||
|
|
||||||
// getDefaultAPIBase returns the default API base URL for a given protocol.
|
// getDefaultAPIBase returns the default API base URL for a given protocol.
|
||||||
func getDefaultAPIBase(protocol string) string {
|
func getDefaultAPIBase(protocol string) string {
|
||||||
meta, ok := protocolMetaByName[protocol]
|
meta, ok := protocolMetaForName(protocol)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return meta.defaultAPIBase
|
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"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -101,6 +102,12 @@ func TestExtractProtocol(t *testing.T) {
|
||||||
wantProtocol: "",
|
wantProtocol: "",
|
||||||
wantModelID: "gpt-4o",
|
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",
|
name: "nil config",
|
||||||
wantProtocol: "",
|
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) {
|
func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-no-key",
|
ModelName: "test-no-key",
|
||||||
|
|
@ -619,8 +661,9 @@ func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) {
|
||||||
|
|
||||||
func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
|
func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
|
||||||
cfg := &config.ModelConfig{
|
cfg := &config.ModelConfig{
|
||||||
ModelName: "test-unknown",
|
ModelName: "test-unknown-provider",
|
||||||
Model: "unknown-protocol/model",
|
Provider: "unknown-protocol",
|
||||||
|
Model: "model",
|
||||||
}
|
}
|
||||||
cfg.SetAPIKey("test-key")
|
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) {
|
func TestCreateProviderFromConfig_NilConfig(t *testing.T) {
|
||||||
_, _, err := CreateProviderFromConfig(nil)
|
_, _, err := CreateProviderFromConfig(nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -889,6 +952,61 @@ 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 not be creatable from the web form")
|
||||||
|
}
|
||||||
|
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) {
|
func TestCreateProviderFromConfig_MinimaxInjectsReasoningSplit(t *testing.T) {
|
||||||
var requestBody map[string]any
|
var requestBody map[string]any
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,13 @@ func ParseModelRef(raw string, defaultProvider string) *ModelRef {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if idx := strings.Index(raw, "/"); idx > 0 {
|
provider, model := SplitModelProviderAndID(raw, defaultProvider)
|
||||||
provider := NormalizeProvider(raw[:idx])
|
|
||||||
model := strings.TrimSpace(raw[idx+1:])
|
|
||||||
if model == "" {
|
if model == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &ModelRef{Provider: provider, Model: model}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ModelRef{
|
return &ModelRef{
|
||||||
Provider: NormalizeProvider(defaultProvider),
|
Provider: provider,
|
||||||
Model: raw,
|
Model: model,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +48,8 @@ func NormalizeProvider(provider string) string {
|
||||||
return "zhipu"
|
return "zhipu"
|
||||||
case "google":
|
case "google":
|
||||||
return "gemini"
|
return "gemini"
|
||||||
|
case "google-antigravity":
|
||||||
|
return "antigravity"
|
||||||
case "alibaba-coding", "qwen-coding":
|
case "alibaba-coding", "qwen-coding":
|
||||||
return "coding-plan"
|
return "coding-plan"
|
||||||
case "alibaba-coding-anthropic":
|
case "alibaba-coding-anthropic":
|
||||||
|
|
@ -61,6 +58,14 @@ func NormalizeProvider(provider string) string {
|
||||||
return "qwen-intl"
|
return "qwen-intl"
|
||||||
case "dashscope-us":
|
case "dashscope-us":
|
||||||
return "qwen-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
|
return p
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,12 @@ func TestNormalizeProvider(t *testing.T) {
|
||||||
{"claude", "anthropic"},
|
{"claude", "anthropic"},
|
||||||
{"glm", "zhipu"},
|
{"glm", "zhipu"},
|
||||||
{"google", "gemini"},
|
{"google", "gemini"},
|
||||||
|
{"google-antigravity", "antigravity"},
|
||||||
{"groq", "groq"},
|
{"groq", "groq"},
|
||||||
|
{"azure-openai", "azure"},
|
||||||
|
{"claudecli", "claude-cli"},
|
||||||
|
{"codexcli", "codex-cli"},
|
||||||
|
{"copilot", "github-copilot"},
|
||||||
// Alibaba Coding Plan aliases
|
// Alibaba Coding Plan aliases
|
||||||
{"alibaba-coding", "coding-plan"},
|
{"alibaba-coding", "coding-plan"},
|
||||||
{"qwen-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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
143
pkg/providers/provider_catalog.go
Normal file
143
pkg/providers/provider_catalog.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
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"`
|
||||||
|
DefaultAuthMethod string `json:"default_auth_method,omitempty"`
|
||||||
|
AuthMethodLocked bool `json:"auth_method_locked,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type attachedModelProviderMeta struct {
|
||||||
|
protocolMeta
|
||||||
|
createAllowed 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},
|
||||||
|
"anthropic": {
|
||||||
|
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
|
||||||
|
createAllowed: true,
|
||||||
|
},
|
||||||
|
"anthropic-messages": {
|
||||||
|
protocolMeta: protocolMeta{defaultAPIBase: "https://api.anthropic.com/v1"},
|
||||||
|
createAllowed: true,
|
||||||
|
},
|
||||||
|
"bedrock": {},
|
||||||
|
"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.
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for provider, meta := range attachedModelProviderMetaByName {
|
||||||
|
if NormalizeProvider(provider) != provider {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
optionsByID[provider] = ModelProviderOption{
|
||||||
|
ID: provider,
|
||||||
|
DefaultAPIBase: meta.defaultAPIBase,
|
||||||
|
EmptyAPIKeyAllowed: meta.emptyAPIKeyAllowed,
|
||||||
|
CreateAllowed: meta.createAllowed,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -47,6 +48,7 @@ var (
|
||||||
probeTCPServiceFunc = probeTCPService
|
probeTCPServiceFunc = probeTCPService
|
||||||
probeOllamaModelFunc = probeOllamaModel
|
probeOllamaModelFunc = probeOllamaModel
|
||||||
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
|
probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel
|
||||||
|
probeCommandAvailableFunc = probeCommandAvailable
|
||||||
modelProbeNowFunc = time.Now
|
modelProbeNowFunc = time.Now
|
||||||
modelProbeState = newModelProbeCacheState()
|
modelProbeState = newModelProbeCacheState()
|
||||||
)
|
)
|
||||||
|
|
@ -83,17 +85,24 @@ func (s *modelProbeCacheState) resetForTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasModelConfiguration(m *config.ModelConfig) bool {
|
func hasModelConfiguration(m *config.ModelConfig) bool {
|
||||||
|
protocol := modelProtocol(m)
|
||||||
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod))
|
||||||
apiKey := strings.TrimSpace(m.APIKey())
|
apiKey := strings.TrimSpace(m.APIKey())
|
||||||
|
|
||||||
if authMethod == "oauth" || authMethod == "token" {
|
if authMethod == "oauth" || authMethod == "token" {
|
||||||
if provider, ok := oauthProviderForModel(m); ok {
|
if configured, checked := hasStoredOAuthCredential(m); checked {
|
||||||
cred, err := oauthGetCredential(provider)
|
return configured
|
||||||
if err != nil || cred == nil {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != ""
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if authMethod == "" && providerUsesImplicitOAuth(protocol) {
|
||||||
|
if configured, checked := hasStoredOAuthCredential(m); checked {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if providerUsesAmbientCredentials(protocol) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,6 +113,40 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
|
||||||
return apiKey != ""
|
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 {
|
func modelConfigurationStatus(m *config.ModelConfig) modelConfigurationSummary {
|
||||||
if !hasModelConfiguration(m) {
|
if !hasModelConfiguration(m) {
|
||||||
return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
|
return modelConfigurationSummary{Available: false, Status: modelStatusUnconfigured}
|
||||||
|
|
@ -180,8 +223,10 @@ func runLocalModelProbe(m *config.ModelConfig) bool {
|
||||||
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
||||||
case "github-copilot", "copilot":
|
case "github-copilot", "copilot":
|
||||||
return probeTCPServiceFunc(apiBase)
|
return probeTCPServiceFunc(apiBase)
|
||||||
case "claude-cli", "claudecli", "codex-cli", "codexcli":
|
case "claude-cli", "claudecli":
|
||||||
return true
|
return probeCommandAvailableFunc("claude")
|
||||||
|
case "codex-cli", "codexcli":
|
||||||
|
return probeCommandAvailableFunc("codex")
|
||||||
default:
|
default:
|
||||||
if hasLocalAPIBase(apiBase) {
|
if hasLocalAPIBase(apiBase) {
|
||||||
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
return probeOpenAICompatibleModelFunc(apiBase, modelID, m.APIKey())
|
||||||
|
|
@ -190,6 +235,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 {
|
func modelProbeCacheKey(m *config.ModelConfig) string {
|
||||||
protocol, modelID := splitModel(m)
|
protocol, modelID := splitModel(m)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,145 @@ type modelResponse struct {
|
||||||
IsVirtual bool `json:"is_virtual"`
|
IsVirtual bool `json:"is_virtual"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 == "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 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 !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.
|
// handleListModels returns all model_list entries with masked API keys.
|
||||||
//
|
//
|
||||||
// GET /api/models
|
// GET /api/models
|
||||||
|
|
@ -107,6 +246,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
||||||
"models": models,
|
"models": models,
|
||||||
"total": len(models),
|
"total": len(models),
|
||||||
"default_model": defaultModel,
|
"default_model": defaultModel,
|
||||||
|
"provider_options": modelProviderOptionsForResponse(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,7 +272,9 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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)
|
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -148,6 +290,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig)
|
cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig)
|
||||||
|
normalizeStoredModelProviders(cfg)
|
||||||
|
|
||||||
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
||||||
|
|
@ -198,11 +341,6 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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)
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
||||||
|
|
@ -267,7 +405,14 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
cfg.ModelList[idx] = &mc.ModelConfig
|
cfg.ModelList[idx] = &mc.ModelConfig
|
||||||
|
normalizeStoredModelProviders(cfg)
|
||||||
|
|
||||||
logger.Debugf("update model config: %#v", mc.ModelConfig)
|
logger.Debugf("update model config: %#v", mc.ModelConfig)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func resetModelProbeHooks(t *testing.T) {
|
func resetModelProbeHooks(t *testing.T) {
|
||||||
|
|
@ -20,12 +21,14 @@ func resetModelProbeHooks(t *testing.T) {
|
||||||
origTCPProbe := probeTCPServiceFunc
|
origTCPProbe := probeTCPServiceFunc
|
||||||
origOllamaProbe := probeOllamaModelFunc
|
origOllamaProbe := probeOllamaModelFunc
|
||||||
origOpenAIProbe := probeOpenAICompatibleModelFunc
|
origOpenAIProbe := probeOpenAICompatibleModelFunc
|
||||||
|
origCommandProbe := probeCommandAvailableFunc
|
||||||
origNow := modelProbeNowFunc
|
origNow := modelProbeNowFunc
|
||||||
resetModelProbeCache()
|
resetModelProbeCache()
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
probeTCPServiceFunc = origTCPProbe
|
probeTCPServiceFunc = origTCPProbe
|
||||||
probeOllamaModelFunc = origOllamaProbe
|
probeOllamaModelFunc = origOllamaProbe
|
||||||
probeOpenAICompatibleModelFunc = origOpenAIProbe
|
probeOpenAICompatibleModelFunc = origOpenAIProbe
|
||||||
|
probeCommandAvailableFunc = origCommandProbe
|
||||||
modelProbeNowFunc = origNow
|
modelProbeNowFunc = origNow
|
||||||
resetModelProbeCache()
|
resetModelProbeCache()
|
||||||
})
|
})
|
||||||
|
|
@ -219,6 +222,203 @@ func TestHandleListModels_AvailabilityForOAuthModelWithCredential(t *testing.T)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleListModels_AntigravityImplicitOAuthAvailability(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetOAuthHooks(t)
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
|
ModelName: "gemini-flash",
|
||||||
|
Provider: "antigravity",
|
||||||
|
Model: "gemini-3-flash",
|
||||||
|
}}
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.SetCredential(oauthProviderGoogleAntigravity, &auth.AuthCredential{
|
||||||
|
AccessToken: "antigravity-token",
|
||||||
|
Provider: oauthProviderGoogleAntigravity,
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetCredential() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Models []modelResponse `json:"models"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Models) != 1 {
|
||||||
|
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||||
|
}
|
||||||
|
if !resp.Models[0].Available {
|
||||||
|
t.Fatal("antigravity model available = false, want true with stored credential even without auth_method")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListModels_BedrockUsesAmbientCredentialStatus(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetOAuthHooks(t)
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
|
ModelName: "bedrock-claude",
|
||||||
|
Provider: "bedrock",
|
||||||
|
Model: "us.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||||
|
}}
|
||||||
|
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.MethodGet, "/api/models", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Models []modelResponse `json:"models"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Models) != 1 {
|
||||||
|
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||||
|
}
|
||||||
|
if !resp.Models[0].Available {
|
||||||
|
t.Fatal("bedrock model available = false, want true because Bedrock uses ambient AWS credentials")
|
||||||
|
}
|
||||||
|
if resp.Models[0].Status != modelStatusAvailable {
|
||||||
|
t.Fatalf("bedrock model status = %q, want %q", resp.Models[0].Status, modelStatusAvailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListModels_CLIProvidersRequireInstalledCommands(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetOAuthHooks(t)
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
probeCommandAvailableFunc = func(command string) bool {
|
||||||
|
switch command {
|
||||||
|
case "claude":
|
||||||
|
return false
|
||||||
|
case "codex":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.ModelList = []*config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "claude-cli-model",
|
||||||
|
Provider: "claude-cli",
|
||||||
|
Model: "claude-cli",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "codex-cli-model",
|
||||||
|
Provider: "codex-cli",
|
||||||
|
Model: "codex-cli",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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.MethodGet, "/api/models", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Models []modelResponse `json:"models"`
|
||||||
|
ProviderOptions []providers.ModelProviderOption `json:"provider_options"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modelsByName := make(map[string]modelResponse, len(resp.Models))
|
||||||
|
for _, model := range resp.Models {
|
||||||
|
modelsByName[model.ModelName] = model
|
||||||
|
}
|
||||||
|
if model := modelsByName["claude-cli-model"]; model.Available || model.Status != modelStatusUnreachable {
|
||||||
|
t.Fatalf(
|
||||||
|
"claude-cli status = (%t, %q), want (%t, %q)",
|
||||||
|
model.Available,
|
||||||
|
model.Status,
|
||||||
|
false,
|
||||||
|
modelStatusUnreachable,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if model := modelsByName["codex-cli-model"]; !model.Available || model.Status != modelStatusAvailable {
|
||||||
|
t.Fatalf(
|
||||||
|
"codex-cli status = (%t, %q), want (%t, %q)",
|
||||||
|
model.Available,
|
||||||
|
model.Status,
|
||||||
|
true,
|
||||||
|
modelStatusAvailable,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions))
|
||||||
|
for _, option := range resp.ProviderOptions {
|
||||||
|
optionsByID[option.ID] = option
|
||||||
|
}
|
||||||
|
if option, ok := optionsByID["claude-cli"]; !ok {
|
||||||
|
t.Fatal("claude-cli provider option missing")
|
||||||
|
} else if option.CreateAllowed {
|
||||||
|
t.Fatal("claude-cli should not be creatable when the claude command is missing")
|
||||||
|
}
|
||||||
|
if option, ok := optionsByID["codex-cli"]; !ok {
|
||||||
|
t.Fatal("codex-cli provider option missing")
|
||||||
|
} else if !option.CreateAllowed {
|
||||||
|
t.Fatal("codex-cli should be creatable when the codex command is available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) {
|
func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
@ -508,6 +708,159 @@ func TestHandleAddModel_PersistsProvider(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleAddModel_RejectsUnsupportedProvider(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
|
||||||
|
"model_name":"bad-provider",
|
||||||
|
"provider":"not-supported",
|
||||||
|
"model":"gpt-4o-mini"
|
||||||
|
}`))
|
||||||
|
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(), `provider "not-supported" is not supported`) {
|
||||||
|
t.Fatalf("body = %q, want unsupported provider error", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAddModel_AllowsBedrockProvider(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
|
||||||
|
"model_name":"bedrock-claude",
|
||||||
|
"provider":"bedrock",
|
||||||
|
"model":"us.anthropic.claude-sonnet-4-20250514-v1:0"
|
||||||
|
}`))
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
added := cfg.ModelList[len(cfg.ModelList)-1]
|
||||||
|
if got := added.Provider; got != "bedrock" {
|
||||||
|
t.Fatalf("provider = %q, want %q", got, "bedrock")
|
||||||
|
}
|
||||||
|
if got := added.Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" {
|
||||||
|
t.Fatalf("model = %q, want bedrock model ID", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAddModel_RejectsMissingCLIProviderCommand(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
resetOAuthHooks(t)
|
||||||
|
resetModelProbeHooks(t)
|
||||||
|
|
||||||
|
probeCommandAvailableFunc = func(command string) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
|
||||||
|
"model_name":"claude-cli-model",
|
||||||
|
"provider":"claude-cli",
|
||||||
|
"model":"claude-cli"
|
||||||
|
}`))
|
||||||
|
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(), `provider "claude-cli" is not available for new models`) {
|
||||||
|
t.Fatalf("body = %q, want missing cli command error", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAddModel_DefaultsAntigravityToOAuth(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
|
||||||
|
"model_name":"gemini-flash",
|
||||||
|
"provider":"antigravity",
|
||||||
|
"model":"gemini-3-flash"
|
||||||
|
}`))
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
added := cfg.ModelList[len(cfg.ModelList)-1]
|
||||||
|
if got := added.AuthMethod; got != "oauth" {
|
||||||
|
t.Fatalf("auth_method = %q, want %q", got, "oauth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAddModel_NormalizesMixedCaseAuthMethod(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
|
||||||
|
"model_name":"openai-oauth",
|
||||||
|
"provider":"openai",
|
||||||
|
"model":"gpt-5.4",
|
||||||
|
"auth_method":"OAuth"
|
||||||
|
}`))
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
added := cfg.ModelList[len(cfg.ModelList)-1]
|
||||||
|
if got := added.AuthMethod; got != "oauth" {
|
||||||
|
t.Fatalf("auth_method = %q, want %q", got, "oauth")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) {
|
func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
@ -846,11 +1199,11 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *test
|
||||||
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 != "openrouter" {
|
||||||
t.Fatalf("provider = %q, want empty", got)
|
t.Fatalf("provider = %q, want %q", got, "openrouter")
|
||||||
}
|
}
|
||||||
if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" {
|
if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" {
|
||||||
t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.4")
|
t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -890,11 +1243,114 @@ func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModel
|
||||||
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 != "openrouter" {
|
||||||
t.Fatalf("provider = %q, want empty", got)
|
t.Fatalf("provider = %q, want %q", got, "openrouter")
|
||||||
}
|
}
|
||||||
if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.5" {
|
if got := updated.ModelList[0].Model; got != "openai/gpt-5.5" {
|
||||||
t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.5")
|
t.Fatalf("model = %q, want %q", got, "openai/gpt-5.5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleListModels_ReturnsProviderOptionsWithoutPersistingLegacyMigration(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: "legacy-openrouter",
|
||||||
|
Model: "openrouter/openai/gpt-5.4",
|
||||||
|
}}
|
||||||
|
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.MethodGet, "/api/models", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Models []modelResponse `json:"models"`
|
||||||
|
ProviderOptions []providers.ModelProviderOption `json:"provider_options"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Models) != 1 {
|
||||||
|
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||||
|
}
|
||||||
|
if got := resp.Models[0].Provider; got != "openrouter" {
|
||||||
|
t.Fatalf("provider = %q, want %q", got, "openrouter")
|
||||||
|
}
|
||||||
|
if got := resp.Models[0].Model; got != "openai/gpt-5.4" {
|
||||||
|
t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsByID := make(map[string]providers.ModelProviderOption, len(resp.ProviderOptions))
|
||||||
|
for _, option := range resp.ProviderOptions {
|
||||||
|
optionsByID[option.ID] = option
|
||||||
|
}
|
||||||
|
if len(optionsByID) == 0 {
|
||||||
|
t.Fatal("provider_options should not be empty")
|
||||||
|
}
|
||||||
|
if option, ok := optionsByID["openai"]; !ok {
|
||||||
|
t.Fatal("openai provider option missing")
|
||||||
|
} else if option.DefaultAPIBase != "https://api.openai.com/v1" {
|
||||||
|
t.Fatalf("openai default_api_base = %q, want %q", option.DefaultAPIBase, "https://api.openai.com/v1")
|
||||||
|
}
|
||||||
|
if option, ok := optionsByID["anthropic"]; !ok {
|
||||||
|
t.Fatal("anthropic provider 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 := optionsByID["azure"]; !ok {
|
||||||
|
t.Fatal("azure provider option missing")
|
||||||
|
}
|
||||||
|
if option, ok := optionsByID["github-copilot"]; !ok {
|
||||||
|
t.Fatal("github-copilot provider option missing")
|
||||||
|
} else if option.DefaultAPIBase != "localhost:4321" {
|
||||||
|
t.Fatalf("github-copilot default_api_base = %q, want %q", option.DefaultAPIBase, "localhost:4321")
|
||||||
|
}
|
||||||
|
if option, ok := optionsByID["lmstudio"]; !ok {
|
||||||
|
t.Fatal("lmstudio provider option missing")
|
||||||
|
} else if !option.EmptyAPIKeyAllowed {
|
||||||
|
t.Fatal("lmstudio should allow empty api keys")
|
||||||
|
}
|
||||||
|
if option, ok := optionsByID["bedrock"]; !ok {
|
||||||
|
t.Fatal("bedrock provider option missing")
|
||||||
|
} else if !option.CreateAllowed {
|
||||||
|
t.Fatal("bedrock should stay creatable and defer AWS credential failures to runtime")
|
||||||
|
}
|
||||||
|
if option, ok := optionsByID["antigravity"]; !ok {
|
||||||
|
t.Fatal("antigravity provider option missing")
|
||||||
|
} else {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := updated.ModelList[0].Provider; got != "" {
|
||||||
|
t.Fatalf("persisted provider = %q, want unchanged empty provider", got)
|
||||||
|
}
|
||||||
|
if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" {
|
||||||
|
t.Fatalf("persisted model = %q, want unchanged legacy model", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -942,6 +1398,115 @@ func TestHandleListModels_ReturnsProviderField(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleListModels_PreservesKnownProviderInCatalog(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: "bedrock-claude",
|
||||||
|
Model: "bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||||
|
}}
|
||||||
|
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.MethodGet, "/api/models", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Models []modelResponse `json:"models"`
|
||||||
|
ProviderOptions []providers.ModelProviderOption `json:"provider_options"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Models) != 1 {
|
||||||
|
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
|
||||||
|
}
|
||||||
|
if got := resp.Models[0].Provider; got != "bedrock" {
|
||||||
|
t.Fatalf("provider = %q, want %q", got, "bedrock")
|
||||||
|
}
|
||||||
|
if got := resp.Models[0].Model; got != "us.anthropic.claude-sonnet-4-20250514-v1:0" {
|
||||||
|
t.Fatalf("model = %q, want %q", got, "us.anthropic.claude-sonnet-4-20250514-v1:0")
|
||||||
|
}
|
||||||
|
foundBedrock := false
|
||||||
|
for _, option := range resp.ProviderOptions {
|
||||||
|
if option.ID == "bedrock" {
|
||||||
|
foundBedrock = true
|
||||||
|
if !option.CreateAllowed {
|
||||||
|
t.Fatal("bedrock should stay creatable in provider_options")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundBedrock {
|
||||||
|
t.Fatal("bedrock should be included in provider_options for compatibility")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUpdateModel_AllowsExistingBedrockProvider(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: "bedrock-claude",
|
||||||
|
Provider: "bedrock",
|
||||||
|
Model: "us.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||||
|
APIBase: "us-west-2",
|
||||||
|
}}
|
||||||
|
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":"bedrock-claude",
|
||||||
|
"provider":"bedrock",
|
||||||
|
"model":"us.anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||||
|
"api_base":"us-east-1"
|
||||||
|
}`))
|
||||||
|
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.ModelList[0].Provider; got != "bedrock" {
|
||||||
|
t.Fatalf("provider = %q, want %q", got, "bedrock")
|
||||||
|
}
|
||||||
|
if got := updated.ModelList[0].Model; got != "us.anthropic.claude-3-7-sonnet-20250219-v1:0" {
|
||||||
|
t.Fatalf("model = %q, want updated bedrock model", got)
|
||||||
|
}
|
||||||
|
if got := updated.ModelList[0].APIBase; got != "us-east-1" {
|
||||||
|
t.Fatalf("api_base = %q, want %q", got, "us-east-1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) {
|
func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) {
|
||||||
configPath, cleanup := setupOAuthTestEnv(t)
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,20 @@ export interface ModelInfo {
|
||||||
is_virtual: boolean
|
is_virtual: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModelProviderOption {
|
||||||
|
id: string
|
||||||
|
default_api_base: string
|
||||||
|
empty_api_key_allowed: boolean
|
||||||
|
create_allowed: boolean
|
||||||
|
default_auth_method?: string
|
||||||
|
auth_method_locked?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface ModelsListResponse {
|
interface ModelsListResponse {
|
||||||
models: ModelInfo[]
|
models: ModelInfo[]
|
||||||
total: number
|
total: number
|
||||||
default_model: string
|
default_model: string
|
||||||
|
provider_options: ModelProviderOption[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelActionResponse {
|
interface ModelActionResponse {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
import { IconLoader2 } from "@tabler/icons-react"
|
import { IconLoader2 } from "@tabler/icons-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,6 +17,13 @@ import {
|
||||||
} from "@/components/shared-form"
|
} from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
@ -25,6 +36,15 @@ import { Textarea } from "@/components/ui/textarea"
|
||||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
|
import {
|
||||||
|
findProviderOption,
|
||||||
|
getProviderDefaultAPIBase,
|
||||||
|
getProviderDefaultAuthMethod,
|
||||||
|
getProviderLabel,
|
||||||
|
getSortedProviderOptions,
|
||||||
|
isProviderAuthMethodLocked,
|
||||||
|
} from "./provider-label"
|
||||||
|
|
||||||
interface AddForm {
|
interface AddForm {
|
||||||
modelName: string
|
modelName: string
|
||||||
provider: string
|
provider: string
|
||||||
|
|
@ -45,7 +65,7 @@ interface AddForm {
|
||||||
|
|
||||||
const EMPTY_ADD_FORM: AddForm = {
|
const EMPTY_ADD_FORM: AddForm = {
|
||||||
modelName: "",
|
modelName: "",
|
||||||
provider: "",
|
provider: "openai",
|
||||||
model: "",
|
model: "",
|
||||||
apiBase: "",
|
apiBase: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
|
|
@ -66,6 +86,7 @@ interface AddModelSheetProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
existingModelNames: string[]
|
existingModelNames: string[]
|
||||||
|
providerOptions: ModelProviderOption[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddModelSheet({
|
export function AddModelSheet({
|
||||||
|
|
@ -73,6 +94,7 @@ export function AddModelSheet({
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
existingModelNames,
|
existingModelNames,
|
||||||
|
providerOptions,
|
||||||
}: AddModelSheetProps) {
|
}: AddModelSheetProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
|
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
|
||||||
|
|
@ -86,6 +108,35 @@ export function AddModelSheet({
|
||||||
form.apiKey,
|
form.apiKey,
|
||||||
t("models.field.apiKeyPlaceholder"),
|
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 apiBasePlaceholder =
|
||||||
|
getProviderDefaultAPIBase(form.provider, providerOptions) ||
|
||||||
|
"https://api.example.com/v1"
|
||||||
const isDirty =
|
const isDirty =
|
||||||
JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault
|
JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault
|
||||||
|
|
||||||
|
|
@ -106,6 +157,9 @@ export function AddModelSheet({
|
||||||
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
|
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
|
||||||
errors.modelName = t("models.add.errorDuplicateModelName")
|
errors.modelName = t("models.add.errorDuplicateModelName")
|
||||||
}
|
}
|
||||||
|
if (!selectedProviderOption) {
|
||||||
|
errors.provider = t("models.field.providerInvalid")
|
||||||
|
}
|
||||||
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
|
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
|
||||||
setFieldErrors(errors)
|
setFieldErrors(errors)
|
||||||
return Object.keys(errors).length === 0
|
return Object.keys(errors).length === 0
|
||||||
|
|
@ -120,22 +174,43 @@ 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 }
|
||||||
|
})
|
||||||
|
if (fieldErrors.provider) {
|
||||||
|
setFieldErrors((prev) => ({ ...prev, provider: undefined }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!validate()) return
|
if (!validate()) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setServerError("")
|
setServerError("")
|
||||||
try {
|
try {
|
||||||
const modelName = form.modelName.trim()
|
const modelName = form.modelName.trim()
|
||||||
const provider = form.provider.trim()
|
|
||||||
const modelId = form.model.trim()
|
const modelId = form.model.trim()
|
||||||
await addModel({
|
await addModel({
|
||||||
model_name: modelName,
|
model_name: modelName,
|
||||||
provider: provider || undefined,
|
provider: form.provider.trim(),
|
||||||
model: modelId,
|
model: modelId,
|
||||||
api_base: form.apiBase.trim() || undefined,
|
api_base: form.apiBase.trim() || undefined,
|
||||||
api_key: form.apiKey.trim() || undefined,
|
api_key: form.apiKey.trim() || undefined,
|
||||||
proxy: form.proxy.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,
|
connect_mode: form.connectMode.trim() || undefined,
|
||||||
workspace: form.workspace.trim() || undefined,
|
workspace: form.workspace.trim() || undefined,
|
||||||
rpm: form.rpm ? Number(form.rpm) : undefined,
|
rpm: form.rpm ? Number(form.rpm) : undefined,
|
||||||
|
|
@ -205,12 +280,29 @@ export function AddModelSheet({
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.provider")}
|
label={t("models.field.provider")}
|
||||||
hint={t("models.field.providerHint")}
|
hint={t("models.field.providerHint")}
|
||||||
|
error={fieldErrors.provider}
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Select
|
||||||
value={form.provider}
|
value={selectedProviderOption?.id}
|
||||||
onChange={setField("provider")}
|
onValueChange={setProvider}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={!!fieldErrors.provider}
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
placeholder={t("models.field.providerPlaceholder")}
|
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>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|
@ -229,6 +321,7 @@ export function AddModelSheet({
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{!isOAuth && (
|
||||||
<Field label={t("models.field.apiKey")}>
|
<Field label={t("models.field.apiKey")}>
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
|
|
@ -236,12 +329,17 @@ export function AddModelSheet({
|
||||||
placeholder={apiKeyPlaceholder}
|
placeholder={apiKeyPlaceholder}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
<Field label={t("models.field.apiBase")}>
|
<Field
|
||||||
|
label={t("models.field.apiBase")}
|
||||||
|
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.apiBase}
|
value={form.apiBase}
|
||||||
onChange={setField("apiBase")}
|
onChange={setField("apiBase")}
|
||||||
placeholder="https://api.example.com/v1"
|
placeholder={apiBasePlaceholder}
|
||||||
|
disabled={isOAuth}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|
@ -266,12 +364,17 @@ export function AddModelSheet({
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.authMethod")}
|
label={t("models.field.authMethod")}
|
||||||
hint={t("models.field.authMethodHint")}
|
hint={
|
||||||
|
authMethodLocked
|
||||||
|
? t("models.field.authMethodManagedHint")
|
||||||
|
: t("models.field.authMethodHint")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.authMethod}
|
value={authMethodLocked ? defaultAuthMethod : form.authMethod}
|
||||||
onChange={setField("authMethod")}
|
onChange={setField("authMethod")}
|
||||||
placeholder="oauth"
|
placeholder="oauth"
|
||||||
|
disabled={authMethodLocked}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
import { IconLoader2 } from "@tabler/icons-react"
|
import { IconLoader2 } from "@tabler/icons-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
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 { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,6 +18,13 @@ import {
|
||||||
} from "@/components/shared-form"
|
} from "@/components/shared-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
@ -25,6 +37,15 @@ import { Textarea } from "@/components/ui/textarea"
|
||||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
|
import {
|
||||||
|
findProviderOption,
|
||||||
|
getProviderDefaultAPIBase,
|
||||||
|
getProviderDefaultAuthMethod,
|
||||||
|
getProviderLabel,
|
||||||
|
getSortedProviderOptions,
|
||||||
|
isProviderAuthMethodLocked,
|
||||||
|
} from "./provider-label"
|
||||||
|
|
||||||
interface EditForm {
|
interface EditForm {
|
||||||
provider: string
|
provider: string
|
||||||
modelId: string
|
modelId: string
|
||||||
|
|
@ -44,6 +65,7 @@ interface EditForm {
|
||||||
|
|
||||||
interface EditModelSheetProps {
|
interface EditModelSheetProps {
|
||||||
model: ModelInfo | null
|
model: ModelInfo | null
|
||||||
|
providerOptions: ModelProviderOption[]
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
|
|
@ -74,6 +96,7 @@ function buildInitialEditForm(model: ModelInfo): EditForm {
|
||||||
|
|
||||||
export function EditModelSheet({
|
export function EditModelSheet({
|
||||||
model,
|
model,
|
||||||
|
providerOptions,
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
|
@ -99,6 +122,38 @@ export function EditModelSheet({
|
||||||
const [setAsDefault, setSetAsDefault] = useState(false)
|
const [setAsDefault, setSetAsDefault] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const initialForm = model ? buildInitialEditForm(model) : null
|
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 apiBasePlaceholder =
|
||||||
|
getProviderDefaultAPIBase(form.provider, providerOptions) ||
|
||||||
|
"https://api.example.com/v1"
|
||||||
const isDirty =
|
const isDirty =
|
||||||
model != null &&
|
model != null &&
|
||||||
(JSON.stringify(form) !== JSON.stringify(initialForm) ||
|
(JSON.stringify(form) !== JSON.stringify(initialForm) ||
|
||||||
|
|
@ -106,19 +161,52 @@ export function EditModelSheet({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (model) {
|
if (model) {
|
||||||
setForm(buildInitialEditForm(model))
|
const initialForm = buildInitialEditForm(model)
|
||||||
|
const option = findProviderOption(initialForm.provider, providerOptions)
|
||||||
|
if (option?.auth_method_locked && !initialForm.authMethod) {
|
||||||
|
initialForm.authMethod = option.default_auth_method ?? ""
|
||||||
|
}
|
||||||
|
setForm(initialForm)
|
||||||
setSetAsDefault(model.is_default)
|
setSetAsDefault(model.is_default)
|
||||||
setError("")
|
setError("")
|
||||||
}
|
}
|
||||||
}, [model])
|
}, [model, providerOptions])
|
||||||
|
|
||||||
const setField =
|
const setField =
|
||||||
(key: keyof EditForm) =>
|
(key: keyof EditForm) =>
|
||||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
if (error) {
|
||||||
|
setError("")
|
||||||
|
}
|
||||||
setForm((f) => ({ ...f, [key]: e.target.value }))
|
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 handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!model) return
|
if (!model) return
|
||||||
|
if (!selectedProviderOption) {
|
||||||
|
setError(providerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!form.modelId.trim()) {
|
if (!form.modelId.trim()) {
|
||||||
setError(t("models.add.errorRequired"))
|
setError(t("models.add.errorRequired"))
|
||||||
return
|
return
|
||||||
|
|
@ -133,7 +221,9 @@ export function EditModelSheet({
|
||||||
api_base: form.apiBase || undefined,
|
api_base: form.apiBase || undefined,
|
||||||
api_key: form.apiKey || undefined,
|
api_key: form.apiKey || undefined,
|
||||||
proxy: form.proxy || undefined,
|
proxy: form.proxy || undefined,
|
||||||
auth_method: form.authMethod || undefined,
|
auth_method: authMethodLocked
|
||||||
|
? defaultAuthMethod || undefined
|
||||||
|
: form.authMethod || undefined,
|
||||||
connect_mode: form.connectMode || undefined,
|
connect_mode: form.connectMode || undefined,
|
||||||
workspace: form.workspace || undefined,
|
workspace: form.workspace || undefined,
|
||||||
rpm: form.rpm ? Number(form.rpm) : undefined,
|
rpm: form.rpm ? Number(form.rpm) : undefined,
|
||||||
|
|
@ -168,7 +258,7 @@ export function EditModelSheet({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOAuth = model?.auth_method === "oauth"
|
const isOAuth = effectiveAuthMethod === "oauth"
|
||||||
const hasSavedAPIKey = Boolean(model?.api_key)
|
const hasSavedAPIKey = Boolean(model?.api_key)
|
||||||
const apiKeyPlaceholder = hasSavedAPIKey
|
const apiKeyPlaceholder = hasSavedAPIKey
|
||||||
? maskedSecretPlaceholder(
|
? maskedSecretPlaceholder(
|
||||||
|
|
@ -197,12 +287,36 @@ export function EditModelSheet({
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.provider")}
|
label={t("models.field.provider")}
|
||||||
hint={t("models.field.providerHint")}
|
hint={t("models.field.providerHint")}
|
||||||
|
error={providerError}
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<Input
|
<Select
|
||||||
value={form.provider}
|
value={selectedProviderOption?.id}
|
||||||
onChange={setField("provider")}
|
onValueChange={setProvider}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-full"
|
||||||
|
aria-invalid={!!providerError}
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
placeholder={t("models.field.providerPlaceholder")}
|
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>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
|
@ -237,7 +351,7 @@ export function EditModelSheet({
|
||||||
<Input
|
<Input
|
||||||
value={form.apiBase}
|
value={form.apiBase}
|
||||||
onChange={setField("apiBase")}
|
onChange={setField("apiBase")}
|
||||||
placeholder="https://api.example.com/v1"
|
placeholder={apiBasePlaceholder}
|
||||||
disabled={isOAuth}
|
disabled={isOAuth}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -263,12 +377,17 @@ export function EditModelSheet({
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("models.field.authMethod")}
|
label={t("models.field.authMethod")}
|
||||||
hint={t("models.field.authMethodHint")}
|
hint={
|
||||||
|
authMethodLocked
|
||||||
|
? t("models.field.authMethodManagedHint")
|
||||||
|
: t("models.field.authMethodHint")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={form.authMethod}
|
value={authMethodLocked ? defaultAuthMethod : form.authMethod}
|
||||||
onChange={setField("authMethod")}
|
onChange={setField("authMethod")}
|
||||||
placeholder="oauth"
|
placeholder="oauth"
|
||||||
|
disabled={authMethodLocked}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@ import { useCallback, useEffect, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
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 { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
|
|
@ -12,41 +17,13 @@ import { refreshGatewayState } from "@/store/gateway"
|
||||||
import { AddModelSheet } from "./add-model-sheet"
|
import { AddModelSheet } from "./add-model-sheet"
|
||||||
import { DeleteModelDialog } from "./delete-model-dialog"
|
import { DeleteModelDialog } from "./delete-model-dialog"
|
||||||
import { EditModelSheet } from "./edit-model-sheet"
|
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"
|
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 {
|
interface ProviderGroup {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
|
|
@ -58,6 +35,9 @@ interface ProviderGroup {
|
||||||
export function ModelsPage() {
|
export function ModelsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [models, setModels] = useState<ModelInfo[]>([])
|
const [models, setModels] = useState<ModelInfo[]>([])
|
||||||
|
const [providerOptions, setProviderOptions] = useState<ModelProviderOption[]>(
|
||||||
|
[],
|
||||||
|
)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [fetchError, setFetchError] = useState("")
|
const [fetchError, setFetchError] = useState("")
|
||||||
|
|
||||||
|
|
@ -79,6 +59,7 @@ export function ModelsPage() {
|
||||||
return a.model_name.localeCompare(b.model_name)
|
return a.model_name.localeCompare(b.model_name)
|
||||||
})
|
})
|
||||||
setModels(sorted)
|
setModels(sorted)
|
||||||
|
setProviderOptions(data.provider_options ?? [])
|
||||||
setFetchError("")
|
setFetchError("")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setFetchError(e instanceof Error ? e.message : t("models.loadError"))
|
setFetchError(e instanceof Error ? e.message : t("models.loadError"))
|
||||||
|
|
@ -213,6 +194,7 @@ export function ModelsPage() {
|
||||||
|
|
||||||
<EditModelSheet
|
<EditModelSheet
|
||||||
model={editingModel}
|
model={editingModel}
|
||||||
|
providerOptions={providerOptions}
|
||||||
open={editingModel !== null}
|
open={editingModel !== null}
|
||||||
onClose={() => setEditingModel(null)}
|
onClose={() => setEditingModel(null)}
|
||||||
onSaved={fetchModels}
|
onSaved={fetchModels}
|
||||||
|
|
@ -220,6 +202,7 @@ export function ModelsPage() {
|
||||||
|
|
||||||
<AddModelSheet
|
<AddModelSheet
|
||||||
open={addOpen}
|
open={addOpen}
|
||||||
|
providerOptions={providerOptions}
|
||||||
onClose={() => setAddOpen(false)}
|
onClose={() => setAddOpen(false)}
|
||||||
onSaved={fetchModels}
|
onSaved={fetchModels}
|
||||||
existingModelNames={models.map((model) => model.model_name)}
|
existingModelNames={models.map((model) => model.model_name)}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
|
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",
|
||||||
anthropic: "Anthropic",
|
anthropic: "Anthropic",
|
||||||
|
"anthropic-messages": "Anthropic Messages",
|
||||||
azure: "Azure OpenAI",
|
azure: "Azure OpenAI",
|
||||||
gemini: "Google Gemini",
|
gemini: "Google Gemini",
|
||||||
deepseek: "DeepSeek",
|
deepseek: "DeepSeek",
|
||||||
|
"coding-plan": "Alibaba Coding Plan",
|
||||||
|
"coding-plan-anthropic": "Alibaba Coding Plan (Anthropic)",
|
||||||
"qwen-portal": "Qwen (阿里云)",
|
"qwen-portal": "Qwen (阿里云)",
|
||||||
"qwen-intl": "Qwen International",
|
"qwen-intl": "Qwen International",
|
||||||
|
"qwen-us": "Qwen US",
|
||||||
moonshot: "Moonshot (月之暗面)",
|
moonshot: "Moonshot (月之暗面)",
|
||||||
groq: "Groq",
|
groq: "Groq",
|
||||||
openrouter: "OpenRouter",
|
openrouter: "OpenRouter",
|
||||||
|
|
@ -15,8 +22,11 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||||
shengsuanyun: "ShengsuanYun (神算云)",
|
shengsuanyun: "ShengsuanYun (神算云)",
|
||||||
antigravity: "Google Code Assist",
|
antigravity: "Google Code Assist",
|
||||||
"github-copilot": "GitHub Copilot",
|
"github-copilot": "GitHub Copilot",
|
||||||
|
"claude-cli": "Claude CLI (local)",
|
||||||
|
"codex-cli": "Codex CLI (local)",
|
||||||
ollama: "Ollama (local)",
|
ollama: "Ollama (local)",
|
||||||
lmstudio: "LM Studio (local)",
|
lmstudio: "LM Studio (local)",
|
||||||
|
litellm: "LiteLLM",
|
||||||
mistral: "Mistral AI",
|
mistral: "Mistral AI",
|
||||||
avian: "Avian",
|
avian: "Avian",
|
||||||
vllm: "VLLM (local)",
|
vllm: "VLLM (local)",
|
||||||
|
|
@ -28,6 +38,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||||
minimax: "MiniMax",
|
minimax: "MiniMax",
|
||||||
longcat: "LongCat",
|
longcat: "LongCat",
|
||||||
modelscope: "ModelScope (魔搭社区)",
|
modelscope: "ModelScope (魔搭社区)",
|
||||||
|
novita: "Novita AI",
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROVIDER_ALIASES: Record<string, string> = {
|
const PROVIDER_ALIASES: Record<string, string> = {
|
||||||
|
|
@ -40,6 +51,47 @@ const PROVIDER_ALIASES: Record<string, string> = {
|
||||||
"google-antigravity": "antigravity",
|
"google-antigravity": "antigravity",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PROVIDER_PRIORITY: Record<string, number> = {
|
||||||
|
volcengine: 0,
|
||||||
|
openai: 1,
|
||||||
|
gemini: 2,
|
||||||
|
anthropic: 3,
|
||||||
|
bedrock: 4,
|
||||||
|
"anthropic-messages": 5,
|
||||||
|
zhipu: 6,
|
||||||
|
deepseek: 7,
|
||||||
|
openrouter: 8,
|
||||||
|
"qwen-portal": 9,
|
||||||
|
"qwen-intl": 10,
|
||||||
|
"qwen-us": 11,
|
||||||
|
moonshot: 12,
|
||||||
|
groq: 13,
|
||||||
|
"coding-plan": 14,
|
||||||
|
"coding-plan-anthropic": 15,
|
||||||
|
"github-copilot": 16,
|
||||||
|
antigravity: 17,
|
||||||
|
nvidia: 18,
|
||||||
|
cerebras: 19,
|
||||||
|
shengsuanyun: 20,
|
||||||
|
venice: 21,
|
||||||
|
vivgrid: 22,
|
||||||
|
minimax: 23,
|
||||||
|
longcat: 24,
|
||||||
|
modelscope: 25,
|
||||||
|
mistral: 26,
|
||||||
|
avian: 27,
|
||||||
|
novita: 28,
|
||||||
|
azure: 29,
|
||||||
|
litellm: 30,
|
||||||
|
ollama: 31,
|
||||||
|
vllm: 32,
|
||||||
|
lmstudio: 33,
|
||||||
|
"claude-cli": 34,
|
||||||
|
"codex-cli": 35,
|
||||||
|
zai: 36,
|
||||||
|
mimo: 37,
|
||||||
|
}
|
||||||
|
|
||||||
export function getProviderKey(provider?: string): string {
|
export function getProviderKey(provider?: string): string {
|
||||||
const normalized = provider?.trim().toLowerCase()
|
const normalized = provider?.trim().toLowerCase()
|
||||||
if (!normalized) return "openai"
|
if (!normalized) return "openai"
|
||||||
|
|
@ -50,3 +102,45 @@ export function getProviderLabel(provider?: string): string {
|
||||||
const prefix = getProviderKey(provider)
|
const prefix = getProviderKey(provider)
|
||||||
return PROVIDER_LABELS[prefix] ?? prefix
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@
|
||||||
"modelNameHint": "A short name used to identify this model in conversations.",
|
"modelNameHint": "A short name used to identify this model in conversations.",
|
||||||
"modelId": "Model Identifier",
|
"modelId": "Model Identifier",
|
||||||
"modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
|
"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.",
|
"errorRequired": "This field is required.",
|
||||||
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
||||||
"saveError": "Failed to add model",
|
"saveError": "Failed to add model",
|
||||||
|
|
@ -272,8 +272,9 @@
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"providerPlaceholder": "e.g. openai",
|
"providerPlaceholder": "Select a provider",
|
||||||
"providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.",
|
"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",
|
"apiBase": "API Base URL",
|
||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
"apiKeyPlaceholder": "Enter your API key",
|
"apiKeyPlaceholder": "Enter your API key",
|
||||||
|
|
@ -282,6 +283,7 @@
|
||||||
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
|
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
|
||||||
"authMethod": "Auth Method",
|
"authMethod": "Auth Method",
|
||||||
"authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.",
|
"authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.",
|
||||||
|
"authMethodManagedHint": "This Provider manages its authentication mode automatically.",
|
||||||
"connectMode": "Connect Mode",
|
"connectMode": "Connect Mode",
|
||||||
"connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.",
|
"connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.",
|
||||||
"workspace": "Workspace Path",
|
"workspace": "Workspace Path",
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@
|
||||||
"modelNameHint": "用于在对话中识别此模型的简短名称。",
|
"modelNameHint": "用于在对话中识别此模型的简短名称。",
|
||||||
"modelId": "模型标识符",
|
"modelId": "模型标识符",
|
||||||
"modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o",
|
"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": "此字段为必填项。",
|
"errorRequired": "此字段为必填项。",
|
||||||
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
|
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
|
||||||
"saveError": "添加模型失败",
|
"saveError": "添加模型失败",
|
||||||
|
|
@ -272,8 +272,9 @@
|
||||||
},
|
},
|
||||||
"field": {
|
"field": {
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"providerPlaceholder": "例如 openai",
|
"providerPlaceholder": "请选择 Provider",
|
||||||
"providerHint": "可选。指定后,将以该值作为最终 provider,并将“模型标识符”字段解释为规范模型 ID。",
|
"providerHint": "请选择一个由后端 catalog 提供的 Provider;“模型标识符”字段会按该 Provider 的规范模型 ID 解释。",
|
||||||
|
"providerInvalid": "当前 Provider 无效,请重新选择一个受支持的 Provider。",
|
||||||
"apiBase": "API Base URL",
|
"apiBase": "API Base URL",
|
||||||
"apiKey": "API Key",
|
"apiKey": "API Key",
|
||||||
"apiKeyPlaceholder": "请输入 API Key",
|
"apiKeyPlaceholder": "请输入 API Key",
|
||||||
|
|
@ -282,6 +283,7 @@
|
||||||
"proxyHint": "可选。例如 http://127.0.0.1:7890",
|
"proxyHint": "可选。例如 http://127.0.0.1:7890",
|
||||||
"authMethod": "认证方式",
|
"authMethod": "认证方式",
|
||||||
"authMethodHint": "认证方式:oauth、token。留空表示使用 API Key 认证。",
|
"authMethodHint": "认证方式:oauth、token。留空表示使用 API Key 认证。",
|
||||||
|
"authMethodManagedHint": "该 Provider 的认证方式由系统自动管理。",
|
||||||
"connectMode": "连接模式",
|
"connectMode": "连接模式",
|
||||||
"connectModeHint": "CLI 型服务商的连接模式:stdio 或 grpc。",
|
"connectModeHint": "CLI 型服务商的连接模式:stdio 或 grpc。",
|
||||||
"workspace": "工作目录",
|
"workspace": "工作目录",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue