mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
3b3062abe8
* Add extraBody field to model configuration forms
This adds a new field allowing users to specify additional JSON fields
to inject into the request body when configuring models.
* Handle ExtraBody clearing when frontend sends empty object
The backend now interprets an empty object sent from the frontend as a
signal to clear the ExtraBody field, while nil/undefined preserves the
existing value. Frontend changed to send {} instead of undefined when
the field is empty.
354 lines
10 KiB
Go
354 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
)
|
|
|
|
// registerModelRoutes binds model list management endpoints to the ServeMux.
|
|
func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /api/models", h.handleListModels)
|
|
mux.HandleFunc("POST /api/models", h.handleAddModel)
|
|
mux.HandleFunc("POST /api/models/default", h.handleSetDefaultModel)
|
|
mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel)
|
|
mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel)
|
|
}
|
|
|
|
// modelResponse is the JSON structure returned for each model in the list.
|
|
// All ModelConfig fields are included so the frontend can display and edit them.
|
|
type modelResponse struct {
|
|
Index int `json:"index"`
|
|
ModelName string `json:"model_name"`
|
|
Model string `json:"model"`
|
|
APIBase string `json:"api_base,omitempty"`
|
|
APIKey string `json:"api_key"`
|
|
Proxy string `json:"proxy,omitempty"`
|
|
AuthMethod string `json:"auth_method,omitempty"`
|
|
// Advanced fields
|
|
ConnectMode string `json:"connect_mode,omitempty"`
|
|
Workspace string `json:"workspace,omitempty"`
|
|
RPM int `json:"rpm,omitempty"`
|
|
MaxTokensField string `json:"max_tokens_field,omitempty"`
|
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
|
ThinkingLevel string `json:"thinking_level,omitempty"`
|
|
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
|
// Meta
|
|
Configured bool `json:"configured"`
|
|
IsDefault bool `json:"is_default"`
|
|
IsVirtual bool `json:"is_virtual"`
|
|
}
|
|
|
|
// handleListModels returns all model_list entries with masked API keys.
|
|
//
|
|
// GET /api/models
|
|
func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
defaultModel := cfg.Agents.Defaults.GetModelName()
|
|
configured := make([]bool, len(cfg.ModelList))
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(len(cfg.ModelList))
|
|
for i, m := range cfg.ModelList {
|
|
go func(i int, m *config.ModelConfig) {
|
|
defer wg.Done()
|
|
configured[i] = isModelConfigured(m)
|
|
}(i, m)
|
|
}
|
|
wg.Wait()
|
|
|
|
models := make([]modelResponse, 0, len(cfg.ModelList))
|
|
for i, m := range cfg.ModelList {
|
|
models = append(models, modelResponse{
|
|
Index: i,
|
|
ModelName: m.ModelName,
|
|
Model: m.Model,
|
|
APIBase: m.APIBase,
|
|
APIKey: maskAPIKey(m.APIKey()),
|
|
Proxy: m.Proxy,
|
|
AuthMethod: m.AuthMethod,
|
|
ConnectMode: m.ConnectMode,
|
|
Workspace: m.Workspace,
|
|
RPM: m.RPM,
|
|
MaxTokensField: m.MaxTokensField,
|
|
RequestTimeout: m.RequestTimeout,
|
|
ThinkingLevel: m.ThinkingLevel,
|
|
ExtraBody: m.ExtraBody,
|
|
Configured: configured[i],
|
|
IsDefault: m.ModelName == defaultModel,
|
|
IsVirtual: m.IsVirtual(),
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"models": models,
|
|
"total": len(models),
|
|
"default_model": defaultModel,
|
|
})
|
|
}
|
|
|
|
// handleAddModel appends a new model configuration entry.
|
|
//
|
|
// POST /api/models
|
|
func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
|
|
type custom struct {
|
|
config.ModelConfig
|
|
APIKey string `json:"api_key"`
|
|
}
|
|
|
|
var mc custom
|
|
if err = json.Unmarshal(body, &mc); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err = mc.Validate(); err != nil {
|
|
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if mc.APIKey != "" {
|
|
mc.ModelConfig.SetAPIKey(mc.APIKey)
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
cfg.ModelList = append(cfg.ModelList, &mc.ModelConfig)
|
|
|
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "ok",
|
|
"index": len(cfg.ModelList) - 1,
|
|
})
|
|
}
|
|
|
|
// handleUpdateModel replaces a model configuration entry at the given index.
|
|
// If the request body omits api_key (or sends an empty string), the existing
|
|
// stored key is preserved so callers can update only api_base / proxy without
|
|
// exposing or clearing the secret.
|
|
//
|
|
// PUT /api/models/{index}
|
|
func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
|
|
idx, err := strconv.Atoi(r.PathValue("index"))
|
|
if err != nil {
|
|
http.Error(w, "Invalid index", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
|
|
type custom struct {
|
|
config.ModelConfig
|
|
APIKey string `json:"api_key"`
|
|
}
|
|
|
|
var mc custom
|
|
if err = json.Unmarshal(body, &mc); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err = mc.Validate(); err != nil {
|
|
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if idx < 0 || idx >= len(cfg.ModelList) {
|
|
http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Preserve the existing API key when the caller omits it (empty string).
|
|
// This lets the UI update api_base / proxy without clearing the stored secret.
|
|
if mc.APIKey == "" {
|
|
mc.ModelConfig.SetAPIKey(cfg.ModelList[idx].APIKey())
|
|
} else {
|
|
mc.ModelConfig.SetAPIKey(mc.APIKey)
|
|
}
|
|
// Preserve existing ExtraBody when omitted (nil), but clear it when
|
|
// the frontend sends an empty object {} to indicate the field should
|
|
// be removed.
|
|
if mc.ExtraBody == nil {
|
|
mc.ExtraBody = cfg.ModelList[idx].ExtraBody
|
|
} else if len(mc.ExtraBody) == 0 {
|
|
mc.ExtraBody = nil
|
|
}
|
|
|
|
cfg.ModelList[idx] = &mc.ModelConfig
|
|
|
|
logger.Debugf("update model config: %#v", mc.ModelConfig)
|
|
|
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// handleDeleteModel removes a model configuration entry at the given index.
|
|
//
|
|
// DELETE /api/models/{index}
|
|
func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) {
|
|
idx, err := strconv.Atoi(r.PathValue("index"))
|
|
if err != nil {
|
|
http.Error(w, "Invalid index", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if idx < 0 || idx >= len(cfg.ModelList) {
|
|
http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
deletedModelName := cfg.ModelList[idx].ModelName
|
|
|
|
cfg.ModelList = append(cfg.ModelList[:idx], cfg.ModelList[idx+1:]...)
|
|
|
|
// If the deleted model was the default, clear it.
|
|
if cfg.Agents.Defaults.ModelName == deletedModelName {
|
|
cfg.Agents.Defaults.ModelName = ""
|
|
}
|
|
|
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// handleSetDefaultModel sets the default model for all agents.
|
|
//
|
|
// POST /api/models/default
|
|
func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "Failed to read request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer r.Body.Close()
|
|
|
|
var req struct {
|
|
ModelName string `json:"model_name"`
|
|
}
|
|
if err = json.Unmarshal(body, &req); err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.ModelName == "" {
|
|
http.Error(w, "model_name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Verify the model_name exists in model_list and is not a virtual model
|
|
found := false
|
|
isVirtual := false
|
|
for _, m := range cfg.ModelList {
|
|
if m.ModelName == req.ModelName {
|
|
found = true
|
|
isVirtual = m.IsVirtual()
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound)
|
|
return
|
|
}
|
|
if isVirtual {
|
|
http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
cfg.Agents.Defaults.ModelName = req.ModelName
|
|
|
|
if err := config.SaveConfig(h.configPath, cfg); err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"status": "ok",
|
|
"default_model": req.ModelName,
|
|
})
|
|
}
|
|
|
|
// maskAPIKey returns a masked version of an API key for safe display.
|
|
// Keys longer than 12 chars show prefix + last 4 chars: "sk-****abcd".
|
|
// Keys 9-12 chars show prefix + last 2 chars: "sk-****cd".
|
|
// Shorter keys are fully masked as "****".
|
|
// Empty keys return empty string.
|
|
// Ensure at least 40% of the key will not be displayed.
|
|
func maskAPIKey(key string) string {
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
|
|
if len(key) <= 8 {
|
|
return "****"
|
|
}
|
|
|
|
// Show first 3 chars and last 2 chars
|
|
if len(key) <= 12 {
|
|
return key[:3] + "****" + key[len(key)-2:]
|
|
}
|
|
|
|
// Show first 3 chars and last 4 chars
|
|
return key[:3] + "****" + key[len(key)-4:]
|
|
}
|