Files
picoclaw/web/backend/api/models.go
T

1050 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", "gpt4free":
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
}
}