fix(web): preserve legacy provider prefixes on model updates

fix(models): normalize explicit provider-prefixed ids

fix(api): preserve legacy model updates across providers

fix(agent): preserve config identity for explicit provider refs
This commit is contained in:
lxowalle 2026-04-21 18:18:19 +08:00
parent 2cd1267f2a
commit f6c8605e90
7 changed files with 374 additions and 3 deletions

View file

@ -229,6 +229,43 @@ func TestNewAgentInstance_PreservesDistinctLimiterIdentityForSharedResolvedModel
} }
} }
func TestNewAgentInstance_PreservesConfigIdentityForExplicitProviderModelRef(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "nvidia/z-ai/glm-5.1",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "nvidia-glm",
Provider: "nvidia",
Model: "z-ai/glm-5.1",
RPM: 7,
},
},
}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
candidate := agent.Candidates[0]
if candidate.Provider != "nvidia" || candidate.Model != "z-ai/glm-5.1" {
t.Fatalf("candidate = %s/%s, want nvidia/z-ai/glm-5.1", candidate.Provider, candidate.Model)
}
if candidate.IdentityKey != "model_name:nvidia-glm" {
t.Fatalf("identity key = %q, want %q", candidate.IdentityKey, "model_name:nvidia-glm")
}
if candidate.RPM != 7 {
t.Fatalf("RPM = %d, want 7", candidate.RPM)
}
}
func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
mediaDir := media.TempDir() mediaDir := media.TempDir()

View file

@ -60,6 +60,12 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig
return mc return mc
} }
rawRef := providers.ParseModelRef(raw, "")
rawKey := ""
if rawRef != nil && strings.TrimSpace(rawRef.Provider) != "" && strings.TrimSpace(rawRef.Model) != "" {
rawKey = providers.ModelKey(rawRef.Provider, rawRef.Model)
}
for i := range cfg.ModelList { for i := range cfg.ModelList {
mc := cfg.ModelList[i] mc := cfg.ModelList[i]
if mc == nil { if mc == nil {
@ -72,10 +78,13 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig
if fullModel == raw { if fullModel == raw {
return mc return mc
} }
_, modelID := providers.ExtractProtocol(mc) protocol, modelID := providers.ExtractProtocol(mc)
if modelID == raw { if modelID == raw {
return mc return mc
} }
if rawKey != "" && providers.ModelKey(protocol, modelID) == rawKey {
return mc
}
} }
return nil return nil

View file

@ -107,7 +107,13 @@ func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) {
model := strings.TrimSpace(cfg.Model) model := strings.TrimSpace(cfg.Model)
if provider := strings.TrimSpace(cfg.Provider); provider != "" { if provider := strings.TrimSpace(cfg.Provider); provider != "" {
return NormalizeProvider(provider), model normalized := NormalizeProvider(provider)
if prefix, rest, found := strings.Cut(model, "/"); found {
if NormalizeProvider(prefix) == normalized && strings.TrimSpace(rest) != "" {
return normalized, strings.TrimSpace(rest)
}
}
return normalized, model
} }
if model == "" { if model == "" {
return "", "" return "", ""

View file

@ -83,6 +83,18 @@ func TestExtractProtocol(t *testing.T) {
wantProtocol: "nvidia", wantProtocol: "nvidia",
wantModelID: "z-ai/glm-5.1", wantModelID: "z-ai/glm-5.1",
}, },
{
name: "explicit provider strips redundant matching prefix",
config: &config.ModelConfig{Provider: "openai", Model: "openai/gpt-4o"},
wantProtocol: "openai",
wantModelID: "gpt-4o",
},
{
name: "explicit provider strips redundant aliased prefix",
config: &config.ModelConfig{Provider: "qwen", Model: "qwen/qwen-plus"},
wantProtocol: "qwen-portal",
wantModelID: "qwen-plus",
},
{ {
name: "empty provider segment", name: "empty provider segment",
config: &config.ModelConfig{Model: "/gpt-4o"}, config: &config.ModelConfig{Model: "/gpt-4o"},
@ -152,6 +164,27 @@ func TestCreateProviderFromConfig_UsesExplicitProvider(t *testing.T) {
} }
} }
func TestCreateProviderFromConfig_StripsRedundantExplicitProviderPrefix(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-openai",
Provider: "openai",
Model: "openai/gpt-4o",
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 != "gpt-4o" {
t.Fatalf("modelID = %q, want %q", modelID, "gpt-4o")
}
}
func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View file

@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"sync" "sync"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
@ -50,6 +51,27 @@ type modelResponse struct {
IsVirtual bool `json:"is_virtual"` IsVirtual bool `json:"is_virtual"`
} }
func normalizeExplicitProviderModel(mc *config.ModelConfig) {
if mc == nil {
return
}
provider := strings.TrimSpace(mc.Provider)
model := strings.TrimSpace(mc.Model)
if provider == "" || model == "" {
return
}
prefix, rest, found := strings.Cut(model, "/")
if !found || strings.TrimSpace(rest) == "" {
return
}
if providers.NormalizeProvider(prefix) == providers.NormalizeProvider(provider) {
mc.Model = strings.TrimSpace(rest)
}
}
// 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
@ -139,6 +161,7 @@ func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
if mc.APIKey != "" { if mc.APIKey != "" {
mc.ModelConfig.SetAPIKey(mc.APIKey) mc.ModelConfig.SetAPIKey(mc.APIKey)
} }
normalizeExplicitProviderModel(&mc.ModelConfig)
cfg, err := config.LoadConfig(h.configPath) cfg, err := config.LoadConfig(h.configPath)
if err != nil { if err != nil {
@ -241,7 +264,31 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
// the new field yet, while still allowing explicit clearing via "". // the new field yet, while still allowing explicit clearing via "".
if _, ok := rawFields["provider"]; !ok { if _, ok := rawFields["provider"]; !ok {
mc.Provider = cfg.ModelList[idx].Provider mc.Provider = cfg.ModelList[idx].Provider
// Older clients still round-trip the legacy model field only. When the
// stored config encodes provider/model in Model and has no explicit
// Provider field yet, continue preserving that hidden provider prefix.
// This keeps provider-omitted updates backward-compatible even when an
// older client edits the visible model ID.
if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
incomingModel := strings.TrimSpace(mc.Model)
if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
if incomingModel == existingModelID {
mc.Model = existingRawModel
} else if strings.Contains(incomingModel, "/") && !strings.Contains(existingModelID, "/") {
// Older clients never saw the hidden provider prefix for simple
// legacy entries such as "openai/gpt-4o". If they now send an
// explicit provider/model string, treat it as the caller's full
// intent instead of re-applying the old hidden prefix.
mc.Model = incomingModel
} else if !strings.HasPrefix(incomingModel, existingProtocol+"/") {
mc.Model = existingProtocol + "/" + incomingModel
} }
}
}
}
normalizeExplicitProviderModel(&mc.ModelConfig)
cfg.ModelList[idx] = &mc.ModelConfig cfg.ModelList[idx] = &mc.ModelConfig

View file

@ -508,6 +508,41 @@ func TestHandleAddModel_PersistsProvider(t *testing.T) {
} }
} }
func TestHandleAddModel_NormalizesRedundantExplicitProviderPrefix(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-gpt",
"provider":"openai",
"model":"openai/gpt-4o-mini",
"api_key":"sk-openai"
}`))
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 != "openai" {
t.Fatalf("provider = %q, want %q", got, "openai")
}
if got := added.Model; got != "gpt-4o-mini" {
t.Fatalf("model = %q, want %q", got, "gpt-4o-mini")
}
}
func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) { func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()
@ -699,6 +734,210 @@ func TestHandleUpdateModel_PreservesProviderWhenOmitted(t *testing.T) {
} }
} }
func TestHandleUpdateModel_NormalizesRedundantExplicitProviderPrefix(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: "editable",
Model: "gpt-4o",
Provider: "openai",
}}
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":"editable",
"provider":"openai",
"model":"openai/gpt-5.4"
}`))
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 != "openai" {
t.Fatalf("provider = %q, want %q", got, "openai")
}
if got := updated.ModelList[0].Model; got != "gpt-5.4" {
t.Fatalf("model = %q, want %q", got, "gpt-5.4")
}
}
func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(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)
// Simulate an older client: it reads GET /api/models, ignores the new
// provider field, then PUTs the visible model string back unchanged.
recList := httptest.NewRecorder()
reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(recList, reqList)
if recList.Code != http.StatusOK {
t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String())
}
var listResp struct {
Models []modelResponse `json:"models"`
}
if err := json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(listResp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(listResp.Models))
}
if got := listResp.Models[0].Provider; got != "openrouter" {
t.Fatalf("provider = %q, want %q", got, "openrouter")
}
if got := listResp.Models[0].Model; got != "openai/gpt-5.4" {
t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
}
recUpdate := httptest.NewRecorder()
reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
"model_name":"legacy-openrouter",
"model":"openai/gpt-5.4"
}`))
reqUpdate.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(recUpdate, reqUpdate)
if recUpdate.Code != http.StatusOK {
t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String())
}
updated, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if got := updated.ModelList[0].Provider; got != "" {
t.Fatalf("provider = %q, want empty", got)
}
if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" {
t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.4")
}
}
func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(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.MethodPut, "/api/models/0", bytes.NewBufferString(`{
"model_name":"legacy-openrouter",
"model":"openai/gpt-5.5"
}`))
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 != "" {
t.Fatalf("provider = %q, want empty", got)
}
if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.5" {
t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.5")
}
}
func TestHandleUpdateModel_OmittedProviderAllowsLegacyProviderChangeForSimpleVisibleModel(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-openai",
Model: "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.MethodPut, "/api/models/0", bytes.NewBufferString(`{
"model_name":"legacy-openai",
"model":"anthropic/claude-sonnet-4.6"
}`))
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 != "" {
t.Fatalf("provider = %q, want empty", got)
}
if got := updated.ModelList[0].Model; got != "anthropic/claude-sonnet-4.6" {
t.Fatalf("model = %q, want %q", got, "anthropic/claude-sonnet-4.6")
}
}
func TestHandleListModels_ReturnsProviderField(t *testing.T) { func TestHandleListModels_ReturnsProviderField(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t) configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup() defer cleanup()

View file

@ -118,7 +118,7 @@ export function EditModelSheet({
try { try {
await updateModel(model.index, { await updateModel(model.index, {
model_name: model.model_name, model_name: model.model_name,
provider: form.provider.trim() || undefined, provider: form.provider.trim(),
model: form.modelId.trim(), model: form.modelId.trim(),
api_base: form.apiBase || undefined, api_base: form.apiBase || undefined,
api_key: form.apiKey || undefined, api_key: form.apiKey || undefined,