diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index 1723050d..2ab10072 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -15,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"` } @@ -109,11 +109,8 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht models = minimaxModels() default: // All other types use OpenAI-compatible /models endpoint - apiBase := strings.TrimRight(h.resolveAPIBase(p), "/") - if apiBase == "" { - apiBase = "https://api.openai.com/v1" - } - models, err = fetchOpenAIModels(ctx, apiBase, p.APIKey) + apiBase := openAIModelsAPIBase(p.ProviderType, h.resolveAPIBase(p)) + models, err = fetchOpenAIModels(ctx, apiBase, p.APIKey, openAIModelsExtraHeaders(p.ProviderType)) } if err != nil { @@ -126,6 +123,28 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht respond(withReasoningCapabilities(models)) } +func openAIModelsAPIBase(providerType, apiBase string) string { + base := strings.TrimRight(apiBase, "/") + if base != "" { + return base + } + switch providerType { + case store.ProviderKimiCoding: + return store.KimiCodingDefaultAPIBase + default: + return "https://api.openai.com/v1" + } +} + +func openAIModelsExtraHeaders(providerType string) map[string]string { + if providerType != store.ProviderKimiCoding { + return nil + } + return map[string]string{ + "User-Agent": store.KimiCodingRequiredUserAgent, + } +} + func reasoningDefaultsForModels( settings []byte, models []ModelInfo, diff --git a/internal/http/provider_models_fetch.go b/internal/http/provider_models_fetch.go index 04cd85f3..678ffb16 100644 --- a/internal/http/provider_models_fetch.go +++ b/internal/http/provider_models_fetch.go @@ -91,12 +91,15 @@ func fetchGeminiModels(ctx context.Context, apiKey string) ([]ModelInfo, error) } // fetchOpenAIModels calls an OpenAI-compatible /models endpoint. -func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) { +func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string, extraHeaders map[string]string) ([]ModelInfo, error) { req, err := http.NewRequestWithContext(ctx, "GET", apiBase+"/models", nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+apiKey) + for k, v := range extraHeaders { + req.Header.Set(k, v) + } resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/internal/http/provider_models_test.go b/internal/http/provider_models_test.go index c6f6a202..2945d452 100644 --- a/internal/http/provider_models_test.go +++ b/internal/http/provider_models_test.go @@ -179,6 +179,73 @@ func TestProvidersHandlerListProviderModelsOpenAICompatAnnotatesKnownModels(t *t } } +func TestProvidersHandlerListProviderModelsKimiCodingSendsRequiredUserAgent(t *testing.T) { + token := setupProvidersAdminToken(t) + var capturedAuth, capturedUserAgent string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + capturedUserAgent = r.Header.Get("User-Agent") + if r.URL.Path != "/models" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]string{ + {"id": store.KimiCodingDefaultModel}, + }, + }) + })) + t.Cleanup(upstream.Close) + + providerStore := newMockProviderStore() + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: "kimi-coding", + ProviderType: store.ProviderKimiCoding, + APIBase: upstream.URL, + APIKey: "kimi-key", + Enabled: true, + } + if err := providerStore.CreateProvider(t.Context(), provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + handler := NewProvidersHandler(providerStore, newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + handler.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/providers/"+provider.ID.String()+"/models", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String()) + } + if capturedAuth != "Bearer kimi-key" { + t.Fatalf("Authorization = %q, want Bearer kimi-key", capturedAuth) + } + if capturedUserAgent != store.KimiCodingRequiredUserAgent { + t.Fatalf("User-Agent = %q, want %q", capturedUserAgent, store.KimiCodingRequiredUserAgent) + } + var result ProviderModelsResponse + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("Decode() error = %v", err) + } + if len(result.Models) != 1 || result.Models[0].ID != store.KimiCodingDefaultModel { + t.Fatalf("models = %#v, want %q", result.Models, store.KimiCodingDefaultModel) + } +} + +func TestOpenAIModelsAPIBaseDefaultsKimiCoding(t *testing.T) { + if got := openAIModelsAPIBase(store.ProviderKimiCoding, ""); got != store.KimiCodingDefaultAPIBase { + t.Fatalf("Kimi default api base = %q, want %q", got, store.KimiCodingDefaultAPIBase) + } + if got := openAIModelsAPIBase(store.ProviderOpenAICompat, ""); got != "https://api.openai.com/v1" { + t.Fatalf("OpenAI compat default api base = %q", got) + } +} + // TestProvidersHandlerListProviderModelsOllamaRichMetadata verifies that the // handler fetches /api/tags from Ollama and maps rich details (family, // parameter_size, quantization_level) into the display name.