picoclaw/web/backend/api/models.go
Guoguo 30938df40b
fix(web): use stored API key when fetching models for saved providers (#2910)
When editing an existing model, the edit form initializes apiKey as
empty for security. This caused "Fetch Available Models" to reject with
"please enter API Key first" even though the key is saved server-side.

Add model_index support: the frontend passes the model's index to the
backend, which looks up the stored key from config. The key never leaves
the backend. Provider and API base are validated to prevent a stored key
from being sent to an unrelated endpoint.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:51:45 +08:00

1049 lines
32 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/audio/asr"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
// 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/fetch", h.handleFetchModels)
mux.HandleFunc("GET /api/models/catalog", h.handleListCatalogs)
mux.HandleFunc("DELETE /api/models/catalog/{id}", h.handleDeleteCatalog)
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)
mux.HandleFunc("POST /api/models/{index}/test", h.handleTestModel)
mux.HandleFunc("POST /api/models/test-inline", h.handleTestInlineModel)
}
// 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"`
Provider string `json:"provider,omitempty"`
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"`
ToolSchemaTransform string `json:"tool_schema_transform,omitempty"`
Streaming config.ModelStreamingConfig `json:"streaming,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"`
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
// Meta
Enabled bool `json:"enabled"`
Available bool `json:"available"`
Status string `json:"status"`
IsDefault bool `json:"is_default"`
IsVirtual bool `json:"is_virtual"`
DefaultModelAllowed bool `json:"default_model_allowed"`
}
func normalizeStoredModelConfig(mc *config.ModelConfig) bool {
if mc == nil {
return false
}
changed := false
model := strings.TrimSpace(mc.Model)
if model != mc.Model {
mc.Model = model
changed = true
}
provider := strings.TrimSpace(mc.Provider)
if provider != mc.Provider {
mc.Provider = provider
changed = true
}
authMethod := strings.ToLower(strings.TrimSpace(mc.AuthMethod))
if authMethod != mc.AuthMethod {
mc.AuthMethod = authMethod
changed = true
}
if provider != "" {
normalizedProvider := providers.NormalizeProvider(provider)
if providers.IsSupportedModelProvider(normalizedProvider) && normalizedProvider != provider {
mc.Provider = normalizedProvider
changed = true
}
if mc.Provider == "elevenlabs" {
if _, strippedModel, found := strings.Cut(
model,
"/",
); found &&
providers.NormalizeProvider(strings.TrimSpace(provider)) == "elevenlabs" {
strippedModel = strings.TrimSpace(strippedModel)
if strippedModel != "" && strippedModel != mc.Model {
mc.Model = strippedModel
changed = true
}
}
if strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() {
mc.Model = asr.ElevenLabsSupportedModelID()
changed = true
}
}
return changed
}
effectiveProvider, modelID := providers.SplitModelProviderAndID(model, "openai")
if effectiveProvider == "" {
return changed
}
if mc.Provider != effectiveProvider {
mc.Provider = effectiveProvider
changed = true
}
if mc.Model != modelID {
mc.Model = modelID
changed = true
}
return changed
}
func normalizeIncomingModelConfig(mc *config.ModelConfig) {
if mc == nil {
return
}
mc.Model = strings.TrimSpace(mc.Model)
mc.Provider = strings.TrimSpace(mc.Provider)
mc.AuthMethod = strings.ToLower(strings.TrimSpace(mc.AuthMethod))
if mc.Provider == "" {
mc.Provider, mc.Model = providers.SplitModelProviderAndID(mc.Model, "openai")
} else {
mc.Provider = providers.NormalizeProvider(mc.Provider)
if mc.Provider == "elevenlabs" {
if _, strippedModel, found := strings.Cut(mc.Model, "/"); found {
strippedModel = strings.TrimSpace(strippedModel)
if strippedModel != "" {
mc.Model = strippedModel
}
}
}
}
if mc.Provider == "antigravity" && mc.AuthMethod == "" {
mc.AuthMethod = "oauth"
}
}
func createAllowedForProvider(provider string) bool {
normalized := providers.NormalizeProvider(provider)
switch normalized {
case "bedrock":
// Bedrock currently authenticates through the AWS SDK credential chain
// (env vars, shared profiles, IAM roles, etc.), and this Web layer does
// not yet have a reliable preflight check for those credential sources.
// Keep it creatable in the catalog and let provider construction/runtime
// return the concrete AWS error when the environment is incomplete.
return true
case "claude-cli", "codex-cli":
return cliProviderCreateAllowedFromCurrentStatus(normalized)
default:
return providers.IsCreatableModelProvider(normalized)
}
}
// cliProviderCreateAllowedFromCurrentStatus intentionally reuses the existing
// local model status pipeline so provider catalog gating follows the same CLI
// executable probe used by launcher readiness.
func cliProviderCreateAllowedFromCurrentStatus(provider string) bool {
status := modelConfigurationStatus(&config.ModelConfig{
Provider: provider,
Model: provider,
})
return status.Available
}
func modelProviderOptionsForResponse() []providers.ModelProviderOption {
options := providers.ModelProviderOptions()
for i := range options {
options[i].CreateAllowed = createAllowedForProvider(options[i].ID)
}
return options
}
func defaultModelAllowedForModelConfig(mc *config.ModelConfig) bool {
provider, _ := providers.ExtractProtocol(mc)
return providers.IsDefaultModelProvider(provider)
}
func validateIncomingModelConfig(mc *config.ModelConfig, existing *config.ModelConfig) error {
if mc == nil {
return fmt.Errorf("model config is required")
}
if err := mc.Validate(); err != nil {
return err
}
if strings.TrimSpace(mc.Provider) == "" {
return fmt.Errorf("provider is required")
}
if !providers.IsSupportedModelProvider(mc.Provider) {
return fmt.Errorf("provider %q is not supported", mc.Provider)
}
if mc.Provider == "elevenlabs" && strings.TrimSpace(mc.Model) != asr.ElevenLabsSupportedModelID() {
return fmt.Errorf("provider %q only supports model %q", mc.Provider, asr.ElevenLabsSupportedModelID())
}
if !createAllowedForProvider(mc.Provider) {
if existing == nil {
return fmt.Errorf("provider %q is not available for new models", mc.Provider)
}
existingProvider, _ := providers.ExtractProtocol(existing)
if providers.NormalizeProvider(existingProvider) != mc.Provider {
return fmt.Errorf("provider %q is not available for selection", mc.Provider)
}
}
return nil
}
func normalizeStoredModelProviders(cfg *config.Config) bool {
if cfg == nil {
return false
}
changed := false
for _, model := range cfg.ModelList {
if normalizeStoredModelConfig(model) {
changed = true
}
}
return changed
}
// handleListModels returns all model_list entries with masked API keys.
//
// 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
}
// Normalize legacy provider/model storage in memory so GET can round-trip
// through the current API shape without mutating the on-disk config.
normalizeStoredModelProviders(cfg)
defaultModel := cfg.Agents.Defaults.GetModelName()
modelStatuses := make([]modelConfigurationSummary, len(cfg.ModelList))
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()
modelStatuses[i] = modelConfigurationStatus(m)
}(i, m)
}
wg.Wait()
models := make([]modelResponse, 0, len(cfg.ModelList))
for i, m := range cfg.ModelList {
provider, modelID := providers.ExtractProtocol(m)
models = append(models, modelResponse{
Index: i,
ModelName: m.ModelName,
Provider: provider,
Model: modelID,
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,
ToolSchemaTransform: m.ToolSchemaTransform,
Streaming: m.Streaming,
ExtraBody: m.ExtraBody,
CustomHeaders: m.CustomHeaders,
Enabled: m.Enabled,
Available: modelStatuses[i].Available,
Status: modelStatuses[i].Status,
IsDefault: m.ModelName == defaultModel,
IsVirtual: m.IsVirtual(),
DefaultModelAllowed: defaultModelAllowedForModelConfig(m),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"models": models,
"total": len(models),
"default_model": defaultModel,
"provider_options": modelProviderOptionsForResponse(),
})
}
// 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
}
normalizeIncomingModelConfig(&mc.ModelConfig)
if err = validateIncomingModelConfig(&mc.ModelConfig, nil); 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)
normalizeStoredModelProviders(cfg)
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()
var rawFields map[string]json.RawMessage
if err = json.Unmarshal(body, &rawFields); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
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
}
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
}
// Preserve existing CustomHeaders when omitted (nil), but clear it when
// the frontend sends an empty object {} to indicate the field should
// be removed.
if mc.CustomHeaders == nil {
mc.CustomHeaders = cfg.ModelList[idx].CustomHeaders
} else if len(mc.CustomHeaders) == 0 {
mc.CustomHeaders = nil
}
if _, ok := rawFields["tool_schema_transform"]; !ok {
mc.ToolSchemaTransform = cfg.ModelList[idx].ToolSchemaTransform
}
if _, ok := rawFields["streaming"]; !ok {
mc.Streaming = cfg.ModelList[idx].Streaming
}
// Preserve the existing Provider when the caller omits it. This keeps the
// update API backward-compatible for clients that haven't started sending
// the new field yet, while still allowing explicit clearing via "".
if _, ok := rawFields["provider"]; !ok {
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) == "" {
existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
incomingModel := strings.TrimSpace(mc.Model)
existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
if incomingModel == existingModelID {
mc.Model = existingRawModel
} 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
}
}
}
}
normalizeIncomingModelConfig(&mc.ModelConfig)
if err = validateIncomingModelConfig(&mc.ModelConfig, cfg.ModelList[idx]); err != nil {
http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest)
return
}
if cfg.Agents.Defaults.ModelName == cfg.ModelList[idx].ModelName &&
!defaultModelAllowedForModelConfig(&mc.ModelConfig) {
// Allow users to recover from legacy/invalid defaults by saving the model
// and clearing the default chat model reference in the same write.
cfg.Agents.Defaults.ModelName = ""
}
cfg.ModelList[idx] = &mc.ModelConfig
normalizeStoredModelProviders(cfg)
logger.Debugf("update model config: %#v", mc.ModelConfig)
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
}
for _, m := range cfg.ModelList {
if m.ModelName == req.ModelName {
if !defaultModelAllowedForModelConfig(m) {
http.Error(
w,
fmt.Sprintf("Model %q cannot be used as the default chat model", req.ModelName),
http.StatusBadRequest,
)
return
}
break
}
}
cfg.Agents.Defaults.ModelName = req.ModelName
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:]
}
// handleFetchModels fetches available models from an upstream provider.
//
// POST /api/models/fetch
func (h *Handler) handleFetchModels(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 {
Provider string `json:"provider"`
APIKey string `json:"api_key"`
APIBase string `json:"api_base"`
ModelIndex *int `json:"model_index,omitempty"`
}
if err = json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if req.Provider == "" {
http.Error(w, "provider is required", http.StatusBadRequest)
return
}
if !providers.IsModelProviderFetchable(req.Provider) {
http.Error(w, fmt.Sprintf("provider %q does not support model listing", req.Provider), http.StatusBadRequest)
return
}
apiKey := strings.TrimSpace(req.APIKey)
apiBase := strings.TrimSpace(req.APIBase)
if apiKey == "" && req.ModelIndex != nil {
if stored := h.lookupStoredAPIKey(*req.ModelIndex, req.Provider, apiBase); stored != "" {
apiKey = stored
}
}
if apiBase == "" {
apiBase = providers.DefaultAPIBaseForProtocol(req.Provider)
}
if apiBase == "" {
http.Error(w, fmt.Sprintf("No default API base for provider %q", req.Provider), http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
models, err := fetchUpstreamModels(ctx, req.Provider, apiBase, apiKey)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to fetch models: %v", err), http.StatusBadGateway)
return
}
// Auto-save fetched models to catalog
catalogModels := make([]CatalogModel, len(models))
for i, m := range models {
catalogModels[i] = CatalogModel{ID: m.ID, OwnedBy: m.OwnedBy}
}
if saveErr := SaveCatalog(req.Provider, apiBase, apiKey, catalogModels); saveErr != nil {
// Log but don't fail the request — saving catalog is non-critical
logger.Warnf("Failed to save model catalog: %v", saveErr)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"models": models,
"total": len(models),
})
}
func (h *Handler) lookupStoredAPIKey(index int, reqProvider, reqAPIBase string) string {
cfg, err := config.LoadConfig(h.configPath)
if err != nil || index < 0 || index >= len(cfg.ModelList) {
return ""
}
stored := cfg.ModelList[index]
storedProvider, _ := providers.ExtractProtocol(stored)
if providers.NormalizeProvider(reqProvider) != providers.NormalizeProvider(storedProvider) {
return ""
}
effectiveReqBase := strings.TrimSpace(reqAPIBase)
if effectiveReqBase == "" {
effectiveReqBase = providers.DefaultAPIBaseForProtocol(reqProvider)
}
effectiveStoredBase := strings.TrimSpace(stored.APIBase)
if effectiveStoredBase == "" {
effectiveStoredBase = providers.DefaultAPIBaseForProtocol(storedProvider)
}
if normalizeAPIBaseForCompare(effectiveReqBase) != normalizeAPIBaseForCompare(effectiveStoredBase) {
return ""
}
return stored.APIKey()
}
type upstreamModel struct {
ID string `json:"id"`
OwnedBy string `json:"owned_by,omitempty"`
}
func fetchUpstreamModels(ctx context.Context, provider, apiBase, apiKey string) ([]upstreamModel, error) {
apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/")
var fetchURL string
switch strings.ToLower(provider) {
case "ollama":
// Strip /v1 suffix if present to get the Ollama root
root := apiBase
if strings.HasSuffix(root, "/v1") {
root = root[:len(root)-3]
}
root = strings.TrimRight(root, "/")
fetchURL = root + "/api/tags"
return fetchOllamaModels(ctx, fetchURL)
default:
// OpenAI-compatible: /v1/models
fetchURL = apiBase + "/models"
return fetchOpenAICompatibleModels(ctx, fetchURL, apiKey)
}
}
func fetchOpenAICompatibleModels(ctx context.Context, fetchURL, apiKey string) ([]upstreamModel, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil)
if err != nil {
return nil, err
}
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("upstream returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
type modelItem struct {
ID string `json:"id"`
OwnedBy string `json:"owned_by"`
}
// {"data": [...]} envelope. Distinguish "envelope shape with empty list"
// from "object without a data key" via Data being non-nil after unmarshal:
// json.Unmarshal sets Data to []modelItem{} for `{"data":[]}` but leaves
// it as nil when "data" is absent or null.
var envelope struct {
Data []modelItem `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil {
models := make([]upstreamModel, 0, len(envelope.Data))
for _, m := range envelope.Data {
if m.ID != "" {
models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy})
}
}
return models, nil
}
// Bare-array shape, including `[]`.
var arr []modelItem
if err := json.Unmarshal(body, &arr); err == nil {
models := make([]upstreamModel, 0, len(arr))
for _, m := range arr {
if m.ID != "" {
models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy})
}
}
return models, nil
}
preview := body
if len(preview) > 256 {
preview = preview[:256]
}
return nil, fmt.Errorf("decode response: unrecognized shape: %s", strings.TrimSpace(string(preview)))
}
func fetchOllamaModels(ctx context.Context, fetchURL string) ([]upstreamModel, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ollama returned status %d", resp.StatusCode)
}
var parsed struct {
Models []struct {
Name string `json:"name"`
Model string `json:"model"`
} `json:"models"`
}
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return nil, err
}
models := make([]upstreamModel, 0, len(parsed.Models))
for _, m := range parsed.Models {
id := m.Name
if id == "" {
id = m.Model
}
if id != "" {
models = append(models, upstreamModel{ID: id})
}
}
return models, nil
}
// normalizeAPIBaseForCompare normalizes an API base URL for equality comparison
// by trimming trailing slashes and lowering the scheme/host.
func normalizeAPIBaseForCompare(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
raw = strings.TrimRight(raw, "/")
u, err := url.Parse(raw)
if err != nil {
return strings.ToLower(raw)
}
if u.Host == "" {
u, err = url.Parse("//" + raw)
if err != nil {
return strings.ToLower(raw)
}
}
return strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + strings.TrimRight(u.Path, "/")
}
// handleTestModel tests connectivity to a model endpoint.
//
// POST /api/models/{index}/test
func (h *Handler) handleTestModel(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
}
m := cfg.ModelList[idx]
start := time.Now()
summary := modelConfigurationStatus(m)
latency := time.Since(start).Milliseconds()
result := map[string]any{
"success": summary.Available,
"latency_ms": latency,
"status": summary.Status,
}
if !summary.Available {
if summary.Status == modelStatusUnconfigured {
result["error"] = "API key not configured"
} else {
result["error"] = "Endpoint unreachable"
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// handleTestInlineModel tests connectivity using inline (unsaved) parameters.
// Unlike handleTestModel which only checks saved config, this endpoint performs
// a real network probe (e.g. GET /models) to verify the endpoint is reachable.
//
// POST /api/models/test-inline
func (h *Handler) handleTestInlineModel(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
}
var req struct {
Provider string `json:"provider"`
Model string `json:"model"`
APIBase string `json:"api_base"`
APIKey string `json:"api_key"`
AuthMethod string `json:"auth_method"`
ModelIndex *int `json:"model_index"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
m := &config.ModelConfig{
Provider: strings.TrimSpace(req.Provider),
Model: strings.TrimSpace(req.Model),
APIBase: strings.TrimSpace(req.APIBase),
AuthMethod: strings.TrimSpace(req.AuthMethod),
}
if req.APIKey != "" {
m.SetAPIKey(req.APIKey)
}
// When api_key is empty and model_index is provided, fall back to stored credentials.
// This lets the edit form test unsaved field changes while using the saved key.
// Only reuse the stored key when the provider and effective API base match
// the saved model, to prevent attaching a credential to a different endpoint.
if req.APIKey == "" && req.ModelIndex != nil {
cfg, err := config.LoadConfig(h.configPath)
if err == nil && *req.ModelIndex >= 0 && *req.ModelIndex < len(cfg.ModelList) {
stored := cfg.ModelList[*req.ModelIndex]
storedProvider, _ := providers.ExtractProtocol(stored)
reqProvider := providers.NormalizeProvider(m.Provider)
providerMatch := reqProvider == "" || reqProvider == providers.NormalizeProvider(storedProvider)
effectiveReqBase := strings.TrimSpace(m.APIBase)
if effectiveReqBase == "" {
effectiveReqBase = providers.DefaultAPIBaseForProtocol(reqProvider)
}
effectiveStoredBase := strings.TrimSpace(stored.APIBase)
if effectiveStoredBase == "" {
effectiveStoredBase = providers.DefaultAPIBaseForProtocol(storedProvider)
}
baseMatch := normalizeAPIBaseForCompare(effectiveReqBase) == normalizeAPIBaseForCompare(effectiveStoredBase)
if providerMatch && baseMatch {
if stored.APIKey() != "" {
m.SetAPIKey(stored.APIKey())
}
if m.APIBase == "" && stored.APIBase != "" {
m.APIBase = stored.APIBase
}
}
}
}
// Check if configuration exists
if !hasModelConfiguration(m) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"success": false,
"latency_ms": 0,
"status": modelStatusUnconfigured,
"error": "API key not configured",
})
return
}
// Perform a real network probe
start := time.Now()
available := probeModelConnectivity(m)
latency := time.Since(start).Milliseconds()
result := map[string]any{
"success": available,
"latency_ms": latency,
}
if available {
result["status"] = modelStatusAvailable
} else {
result["status"] = modelStatusUnreachable
result["error"] = "Endpoint unreachable"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// probeModelConnectivity performs a real network probe to verify model endpoint reachability.
func probeModelConnectivity(m *config.ModelConfig) bool {
apiBase := modelProbeAPIBase(m)
protocol, modelID := splitModel(m)
switch protocol {
case "ollama":
return probeOllamaModel(apiBase, modelID)
case "vllm", "lmstudio":
return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey())
case "github-copilot":
return probeTCPService(apiBase)
case "claude-cli":
return probeCommandAvailable("claude")
case "codex-cli":
return probeCommandAvailable("codex")
default:
// For remote providers (OpenAI, Anthropic, Gemini, DeepSeek, etc.),
// make a real GET /models request to verify connectivity and credentials.
if apiBase != "" {
return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey())
}
return false
}
}