mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 06:16:10 +00:00
refactor(http): split provider_models.go into focused modules
- provider_models.go (154L): types, handler, reasoning helpers - provider_models_fetch.go (191L): remote API fetchers (Anthropic, Gemini, OpenAI, Ollama) - provider_models_catalog.go (103L): hardcoded model lists All files under 200-line guideline. No logic changes.
This commit is contained in:
1 parent
e8579f5e1c
commit
78a2152053
3 files changed
+296
-286
No files matched your search
@@ -2,16 +2,12 @@ package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
@@ -19,8 +15,8 @@ import (
|
||||
|
||||
// ModelInfo is a normalized model entry returned by the list-models endpoint.
|
||||
type ModelInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Reasoning *providers.ReasoningCapability `json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
@@ -147,286 +143,6 @@ func reasoningDefaultsForModels(
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchAnthropicModels calls the Anthropic models API.
|
||||
func fetchAnthropicModels(ctx context.Context, apiKey, apiBase string) ([]ModelInfo, error) {
|
||||
base := strings.TrimRight(apiBase, "/")
|
||||
if base == "" {
|
||||
base = "https://api.anthropic.com/v1"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", base+"/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
|
||||
}
|
||||
|
||||
// fetchGeminiModels calls the Google Gemini models API.
|
||||
// Gemini uses a different format: GET /v1beta/models?key=API_KEY
|
||||
func fetchGeminiModels(ctx context.Context, apiKey string) ([]ModelInfo, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://generativelanguage.googleapis.com/v1beta/models?key="+apiKey, 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 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("gemini API returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"` // e.g. "models/gemini-2.0-flash"
|
||||
DisplayName string `json:"displayName"` // e.g. "Gemini 2.0 Flash"
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode gemini response: %w", err)
|
||||
}
|
||||
|
||||
models := make([]ModelInfo, 0, len(result.Models))
|
||||
for _, m := range result.Models {
|
||||
// Strip "models/" prefix to get the usable model ID
|
||||
id := strings.TrimPrefix(m.Name, "models/")
|
||||
models = append(models, ModelInfo{ID: id, Name: m.DisplayName})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// bailianModels returns a hardcoded list of models available on the
|
||||
// Bailian Coding platform (coding-intl.dashscope.aliyuncs.com).
|
||||
// The platform does not expose a /v1/models endpoint.
|
||||
func bailianModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
{ID: "qwen3.5-plus", Name: "Qwen 3.5 Plus"},
|
||||
{ID: "kimi-k2.5", Name: "Kimi K2.5"},
|
||||
{ID: "GLM-5", Name: "GLM-5"},
|
||||
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
|
||||
{ID: "qwen3-max-2026-01-23", Name: "Qwen 3 Max (2026-01-23)"},
|
||||
{ID: "qwen3-coder-next", Name: "Qwen 3 Coder Next"},
|
||||
{ID: "qwen3-coder-plus", Name: "Qwen 3 Coder Plus"},
|
||||
{ID: "glm-4.7", Name: "GLM 4.7"},
|
||||
}
|
||||
}
|
||||
|
||||
// minimaxModels returns a hardcoded list of MiniMax models.
|
||||
// MiniMax does not expose a /v1/models endpoint.
|
||||
func minimaxModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
// Chat / text
|
||||
{ID: "MiniMax-Text-01", Name: "MiniMax Text 01"},
|
||||
{ID: "MiniMax-M1", Name: "MiniMax M1"},
|
||||
{ID: "MiniMax-M2.7", Name: "MiniMax M2.7"},
|
||||
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
|
||||
// Image generation
|
||||
{ID: "image-01", Name: "Image 01"},
|
||||
// Video generation
|
||||
{ID: "MiniMax-Hailuo-2.3", Name: "Hailuo Video 2.3"},
|
||||
{ID: "MiniMax-Hailuo-2", Name: "Hailuo Video 2"},
|
||||
{ID: "T2V-01-Director", Name: "T2V-01 Director"},
|
||||
// Music generation
|
||||
{ID: "music-2.5+", Name: "Music 2.5+"},
|
||||
{ID: "music-2.5", Name: "Music 2.5"},
|
||||
// TTS
|
||||
{ID: "speech-02-hd", Name: "Speech 02 HD"},
|
||||
{ID: "speech-02-turbo", Name: "Speech 02 Turbo"},
|
||||
}
|
||||
}
|
||||
|
||||
// dashScopeModels returns a hardcoded list of DashScope (Qwen) models.
|
||||
// DashScope does not expose a standard /v1/models endpoint.
|
||||
func dashScopeModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
// Qwen3.5 series — Text Generation + Deep Thinking + Visual Understanding
|
||||
{ID: "qwen3.5-plus", Name: "Qwen 3.5 Plus"},
|
||||
{ID: "qwen3.5-turbo", Name: "Qwen 3.5 Turbo"},
|
||||
// Qwen3 hosted series — Text + Thinking
|
||||
{ID: "qwen3-max", Name: "Qwen 3 Max"},
|
||||
{ID: "qwen3-plus", Name: "Qwen 3 Plus"},
|
||||
{ID: "qwen3-turbo", Name: "Qwen 3 Turbo"},
|
||||
// Image generation
|
||||
{ID: "wan2.6-image", Name: "Wan 2.6 Image"},
|
||||
{ID: "wan2.1-image", Name: "Wan 2.1 Image"},
|
||||
// Video generation
|
||||
{ID: "wan2.6-video", Name: "Wan 2.6 Video"},
|
||||
}
|
||||
}
|
||||
|
||||
// sunoModels returns a hardcoded list of Suno music generation models.
|
||||
func sunoModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
{ID: "v4.5", Name: "Suno V4.5"},
|
||||
{ID: "v4", Name: "Suno V4"},
|
||||
{ID: "v3.5", Name: "Suno V3.5"},
|
||||
}
|
||||
}
|
||||
|
||||
// claudeCLIModels returns the model aliases accepted by the Claude CLI.
|
||||
func claudeCLIModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
{ID: "sonnet", Name: "Sonnet"},
|
||||
{ID: "opus", Name: "Opus"},
|
||||
{ID: "haiku", Name: "Haiku"},
|
||||
}
|
||||
}
|
||||
|
||||
// acpModels returns the model aliases for ACP-compatible coding agents.
|
||||
func acpModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
{ID: "claude", Name: "Claude"},
|
||||
{ID: "codex", Name: "Codex"},
|
||||
{ID: "gemini", Name: "Gemini"},
|
||||
}
|
||||
}
|
||||
|
||||
// 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.ID})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func chatGPTOAuthModels() []ModelInfo {
|
||||
return withReasoningCapabilities([]ModelInfo{
|
||||
{ID: "gpt-5.4", Name: "GPT-5.4"},
|
||||
{ID: "gpt-5.4-mini", Name: "GPT-5.4 Mini"},
|
||||
{ID: "gpt-5.3-codex", Name: "GPT-5.3 Codex"},
|
||||
{ID: "gpt-5.3-codex-spark", Name: "GPT-5.3 Codex Spark"},
|
||||
{ID: "gpt-5.2-codex", Name: "GPT-5.2 Codex"},
|
||||
{ID: "gpt-5.2", Name: "GPT-5.2"},
|
||||
{ID: "gpt-5.1-codex", Name: "GPT-5.1 Codex"},
|
||||
{ID: "gpt-5.1-codex-max", Name: "GPT-5.1 Codex Max"},
|
||||
{ID: "gpt-5.1-codex-mini", Name: "GPT-5.1 Codex Mini"},
|
||||
{ID: "gpt-5.1", Name: "GPT-5.1"},
|
||||
})
|
||||
}
|
||||
|
||||
// fetchOllamaModels calls Ollama's native /api/tags endpoint to get model metadata
|
||||
// including parameter size, quantization level, and model family.
|
||||
// The api_base may include a /v1 suffix (from issue #654 normalization) — strip it
|
||||
// before appending /api/tags since /api/tags lives at the root, not under /v1.
|
||||
func (h *ProvidersHandler) fetchOllamaModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) {
|
||||
// Strip /v1 suffix if present, then trim trailing slash.
|
||||
base := strings.TrimRight(strings.TrimSuffix(strings.TrimRight(apiBase, "/"), "/v1"), "/")
|
||||
url := config.DockerLocalhost(base + "/api/tags")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if 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 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("ollama /api/tags returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
Details struct {
|
||||
Family string `json:"family"`
|
||||
ParameterSize string `json:"parameter_size"`
|
||||
QuantizationLevel string `json:"quantization_level"`
|
||||
} `json:"details"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode ollama response: %w", err)
|
||||
}
|
||||
|
||||
models := make([]ModelInfo, 0, len(result.Models))
|
||||
for _, m := range result.Models {
|
||||
// Build a human-readable display name: "family paramSize quantLevel" e.g. "gemma4 8.0B Q4_K_M"
|
||||
var parts []string
|
||||
if m.Details.Family != "" {
|
||||
parts = append(parts, m.Details.Family)
|
||||
}
|
||||
if m.Details.ParameterSize != "" {
|
||||
parts = append(parts, m.Details.ParameterSize)
|
||||
}
|
||||
if m.Details.QuantizationLevel != "" {
|
||||
parts = append(parts, m.Details.QuantizationLevel)
|
||||
}
|
||||
name := m.Name
|
||||
if len(parts) > 0 {
|
||||
name = strings.Join(parts, " ")
|
||||
}
|
||||
models = append(models, ModelInfo{ID: m.Name, Name: name})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func withReasoningCapabilities(models []ModelInfo) []ModelInfo {
|
||||
result := make([]ModelInfo, 0, len(models))
|
||||
for _, model := range models {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package http
|
||||
|
||||
// bailianModels returns a hardcoded list of models available on the
|
||||
// Bailian Coding platform (coding-intl.dashscope.aliyuncs.com).
|
||||
// The platform does not expose a /v1/models endpoint.
|
||||
func bailianModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
{ID: "qwen3.5-plus", Name: "Qwen 3.5 Plus"},
|
||||
{ID: "kimi-k2.5", Name: "Kimi K2.5"},
|
||||
{ID: "GLM-5", Name: "GLM-5"},
|
||||
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
|
||||
{ID: "qwen3-max-2026-01-23", Name: "Qwen 3 Max (2026-01-23)"},
|
||||
{ID: "qwen3-coder-next", Name: "Qwen 3 Coder Next"},
|
||||
{ID: "qwen3-coder-plus", Name: "Qwen 3 Coder Plus"},
|
||||
{ID: "glm-4.7", Name: "GLM 4.7"},
|
||||
}
|
||||
}
|
||||
|
||||
// minimaxModels returns a hardcoded list of MiniMax models.
|
||||
// MiniMax does not expose a /v1/models endpoint.
|
||||
func minimaxModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
// Chat / text
|
||||
{ID: "MiniMax-Text-01", Name: "MiniMax Text 01"},
|
||||
{ID: "MiniMax-M1", Name: "MiniMax M1"},
|
||||
{ID: "MiniMax-M2.7", Name: "MiniMax M2.7"},
|
||||
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
|
||||
// Image generation
|
||||
{ID: "image-01", Name: "Image 01"},
|
||||
// Video generation
|
||||
{ID: "MiniMax-Hailuo-2.3", Name: "Hailuo Video 2.3"},
|
||||
{ID: "MiniMax-Hailuo-2", Name: "Hailuo Video 2"},
|
||||
{ID: "T2V-01-Director", Name: "T2V-01 Director"},
|
||||
// Music generation
|
||||
{ID: "music-2.5+", Name: "Music 2.5+"},
|
||||
{ID: "music-2.5", Name: "Music 2.5"},
|
||||
// TTS
|
||||
{ID: "speech-02-hd", Name: "Speech 02 HD"},
|
||||
{ID: "speech-02-turbo", Name: "Speech 02 Turbo"},
|
||||
}
|
||||
}
|
||||
|
||||
// dashScopeModels returns a hardcoded list of DashScope (Qwen) models.
|
||||
// DashScope does not expose a standard /v1/models endpoint.
|
||||
func dashScopeModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
// Qwen3.5 series — Text Generation + Deep Thinking + Visual Understanding
|
||||
{ID: "qwen3.5-plus", Name: "Qwen 3.5 Plus"},
|
||||
{ID: "qwen3.5-turbo", Name: "Qwen 3.5 Turbo"},
|
||||
// Qwen3 hosted series — Text + Thinking
|
||||
{ID: "qwen3-max", Name: "Qwen 3 Max"},
|
||||
{ID: "qwen3-plus", Name: "Qwen 3 Plus"},
|
||||
{ID: "qwen3-turbo", Name: "Qwen 3 Turbo"},
|
||||
// Image generation
|
||||
{ID: "wan2.6-image", Name: "Wan 2.6 Image"},
|
||||
{ID: "wan2.1-image", Name: "Wan 2.1 Image"},
|
||||
// Video generation
|
||||
{ID: "wan2.6-video", Name: "Wan 2.6 Video"},
|
||||
}
|
||||
}
|
||||
|
||||
// sunoModels returns a hardcoded list of Suno music generation models.
|
||||
func sunoModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
{ID: "v4.5", Name: "Suno V4.5"},
|
||||
{ID: "v4", Name: "Suno V4"},
|
||||
{ID: "v3.5", Name: "Suno V3.5"},
|
||||
}
|
||||
}
|
||||
|
||||
// claudeCLIModels returns the model aliases accepted by the Claude CLI.
|
||||
func claudeCLIModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
{ID: "sonnet", Name: "Sonnet"},
|
||||
{ID: "opus", Name: "Opus"},
|
||||
{ID: "haiku", Name: "Haiku"},
|
||||
}
|
||||
}
|
||||
|
||||
// acpModels returns the model aliases for ACP-compatible coding agents.
|
||||
func acpModels() []ModelInfo {
|
||||
return []ModelInfo{
|
||||
{ID: "claude", Name: "Claude"},
|
||||
{ID: "codex", Name: "Codex"},
|
||||
{ID: "gemini", Name: "Gemini"},
|
||||
}
|
||||
}
|
||||
|
||||
// chatGPTOAuthModels returns models available via ChatGPT OAuth integration.
|
||||
func chatGPTOAuthModels() []ModelInfo {
|
||||
return withReasoningCapabilities([]ModelInfo{
|
||||
{ID: "gpt-5.4", Name: "GPT-5.4"},
|
||||
{ID: "gpt-5.4-mini", Name: "GPT-5.4 Mini"},
|
||||
{ID: "gpt-5.3-codex", Name: "GPT-5.3 Codex"},
|
||||
{ID: "gpt-5.3-codex-spark", Name: "GPT-5.3 Codex Spark"},
|
||||
{ID: "gpt-5.2-codex", Name: "GPT-5.2 Codex"},
|
||||
{ID: "gpt-5.2", Name: "GPT-5.2"},
|
||||
{ID: "gpt-5.1-codex", Name: "GPT-5.1 Codex"},
|
||||
{ID: "gpt-5.1-codex-max", Name: "GPT-5.1 Codex Max"},
|
||||
{ID: "gpt-5.1-codex-mini", Name: "GPT-5.1 Codex Mini"},
|
||||
{ID: "gpt-5.1", Name: "GPT-5.1"},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
)
|
||||
|
||||
// fetchAnthropicModels calls the Anthropic models API.
|
||||
func fetchAnthropicModels(ctx context.Context, apiKey, apiBase string) ([]ModelInfo, error) {
|
||||
base := strings.TrimRight(apiBase, "/")
|
||||
if base == "" {
|
||||
base = "https://api.anthropic.com/v1"
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", base+"/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
|
||||
}
|
||||
|
||||
// fetchGeminiModels calls the Google Gemini models API.
|
||||
// Gemini uses a different format: GET /v1beta/models?key=API_KEY
|
||||
func fetchGeminiModels(ctx context.Context, apiKey string) ([]ModelInfo, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://generativelanguage.googleapis.com/v1beta/models?key="+apiKey, 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 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("gemini API returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"` // e.g. "models/gemini-2.0-flash"
|
||||
DisplayName string `json:"displayName"` // e.g. "Gemini 2.0 Flash"
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode gemini response: %w", err)
|
||||
}
|
||||
|
||||
models := make([]ModelInfo, 0, len(result.Models))
|
||||
for _, m := range result.Models {
|
||||
// Strip "models/" prefix to get the usable model ID
|
||||
id := strings.TrimPrefix(m.Name, "models/")
|
||||
models = append(models, ModelInfo{ID: 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.ID})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
// fetchOllamaModels calls Ollama's native /api/tags endpoint to get model metadata
|
||||
// including parameter size, quantization level, and model family.
|
||||
// The api_base may include a /v1 suffix (from issue #654 normalization) — strip it
|
||||
// before appending /api/tags since /api/tags lives at the root, not under /v1.
|
||||
func (h *ProvidersHandler) fetchOllamaModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) {
|
||||
// Strip /v1 suffix if present, then trim trailing slash.
|
||||
base := strings.TrimRight(strings.TrimSuffix(strings.TrimRight(apiBase, "/"), "/v1"), "/")
|
||||
url := config.DockerLocalhost(base + "/api/tags")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if 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 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("ollama /api/tags returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
Details struct {
|
||||
Family string `json:"family"`
|
||||
ParameterSize string `json:"parameter_size"`
|
||||
QuantizationLevel string `json:"quantization_level"`
|
||||
} `json:"details"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode ollama response: %w", err)
|
||||
}
|
||||
|
||||
models := make([]ModelInfo, 0, len(result.Models))
|
||||
for _, m := range result.Models {
|
||||
// Build a human-readable display name: "family paramSize quantLevel" e.g. "gemma4 8.0B Q4_K_M"
|
||||
var parts []string
|
||||
if m.Details.Family != "" {
|
||||
parts = append(parts, m.Details.Family)
|
||||
}
|
||||
if m.Details.ParameterSize != "" {
|
||||
parts = append(parts, m.Details.ParameterSize)
|
||||
}
|
||||
if m.Details.QuantizationLevel != "" {
|
||||
parts = append(parts, m.Details.QuantizationLevel)
|
||||
}
|
||||
name := m.Name
|
||||
if len(parts) > 0 {
|
||||
name = strings.Join(parts, " ")
|
||||
}
|
||||
models = append(models, ModelInfo{ID: m.Name, Name: name})
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
Reference in new issue
Block a user