feat: Implement dynamic model selection for agents using a new backend API and a reusable combobox component.

This commit is contained in:
viettranx committed 2026-02-23 19:17:34 +07:00
1 parent 3d63e419a5
commit abaaf8fc83
8 files changed
+339 -21

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
package http
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/google/uuid"
)
// ModelInfo is a normalized model entry returned by the list-models endpoint.
type ModelInfo struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
}
// handleListProviderModels proxies to the upstream provider API to list
// available models for the given provider.
//
// GET /v1/providers/{id}/models
func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provider ID"})
return
}
p, err := h.store.GetProvider(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider not found"})
return
}
if p.APIKey == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "provider has no API key configured"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
var models []ModelInfo
switch p.ProviderType {
case "anthropic_native":
models, err = fetchAnthropicModels(ctx, p.APIKey)
case "openai_compat":
apiBase := strings.TrimRight(p.APIBase, "/")
if apiBase == "" {
apiBase = "https://api.openai.com/v1"
}
models, err = fetchOpenAIModels(ctx, apiBase, p.APIKey)
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("unsupported provider type: %s", p.ProviderType)})
return
}
if err != nil {
slog.Warn("providers.models", "provider", p.Name, "error", err)
// Return empty list instead of error — provider may not support /models
writeJSON(w, http.StatusOK, map[string]interface{}{"models": []ModelInfo{}})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"models": models})
}
// fetchAnthropicModels calls the Anthropic models API.
func fetchAnthropicModels(ctx context.Context, apiKey string) ([]ModelInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/v1/models", nil)
if err != nil {
return nil, err
}
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("anthropic API returned %d: %s", resp.StatusCode, string(body))
}
var result struct {
Data []struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode anthropic response: %w", err)
}
models := make([]ModelInfo, 0, len(result.Data))
for _, m := range result.Data {
models = append(models, ModelInfo{ID: m.ID, Name: m.DisplayName})
}
return models, nil
}
// fetchOpenAIModels calls an OpenAI-compatible /models endpoint.
func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", apiBase+"/models", nil)
if err != nil {
return nil, err
}
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 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("provider API returned %d: %s", resp.StatusCode, string(body))
}
var result struct {
Data []struct {
ID string `json:"id"`
OwnedBy string `json:"owned_by"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode provider response: %w", err)
}
models := make([]ModelInfo, 0, len(result.Data))
for _, m := range result.Data {
models = append(models, ModelInfo{ID: m.ID, Name: m.OwnedBy})
}
return models, nil
}
+2
View File
@@ -30,6 +30,8 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("PUT /v1/providers/{id}", h.auth(h.handleUpdateProvider))
mux.HandleFunc("DELETE /v1/providers/{id}", h.auth(h.handleDeleteProvider))
// Model listing (proxied to upstream provider API)
mux.HandleFunc("GET /v1/providers/{id}/models", h.auth(h.handleListProviderModels))
}
func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc {