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:
viettranx committed 2026-04-03 15:18:54 +07:00
1 parent e8579f5e1c
commit 78a2152053
3 files changed
+296 -286

No files matched your search

+2 -286
View File
@@ -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 {
+103
View File
@@ -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"},
})
}
+191
View File
@@ -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
}