diff --git a/internal/ai/providers/openai.go b/internal/ai/providers/openai.go index 29e673c70..0c74ad6e7 100644 --- a/internal/ai/providers/openai.go +++ b/internal/ai/providers/openai.go @@ -307,6 +307,17 @@ func (c *OpenAIClient) isOpenRouter() bool { return c.Name() == "openrouter" || strings.Contains(c.baseURL, "openrouter.ai") } +func (c *OpenAIClient) usesOfficialOpenAIEndpoint() bool { + if c.Name() != "openai" { + return false + } + u, err := url.Parse(c.baseURL) + if err != nil || u.Host == "" { + return true + } + return strings.EqualFold(u.Hostname(), "api.openai.com") +} + // isDeepSeekReasoner returns true if using DeepSeek's reasoning model func (c *OpenAIClient) isDeepSeekReasoner() bool { return c.isDeepSeek() && strings.Contains(c.model, "reasoner") @@ -1240,6 +1251,25 @@ func (c *OpenAIClient) ListModels(ctx context.Context) ([]ModelInfo, error) { continue } + if !c.usesOfficialOpenAIEndpoint() { + modelName := strings.TrimSpace(m.Name) + if modelName == "" { + modelName = m.ID + } + description := strings.TrimSpace(m.Description) + if description == "" && strings.TrimSpace(m.OwnedBy) != "" { + description = strings.TrimSpace(m.OwnedBy) + } + models = append(models, ModelInfo{ + ID: m.ID, + Name: modelName, + Description: description, + CreatedAt: m.Created, + Notable: cache.IsNotable(c.Name(), m.ID, m.Created), + }) + continue + } + // Filter to only chat-capable models if strings.Contains(m.ID, "gpt") || strings.Contains(m.ID, "o1") || strings.Contains(m.ID, "o3") || strings.Contains(m.ID, "o4") || diff --git a/internal/ai/providers/openai_test.go b/internal/ai/providers/openai_test.go index e93019b36..1e6425c33 100644 --- a/internal/ai/providers/openai_test.go +++ b/internal/ai/providers/openai_test.go @@ -945,21 +945,24 @@ func TestNewOpenAIClient_StripsOpenRouterPrefix(t *testing.T) { } func TestOpenAIClient_ListModels(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - - json.NewEncoder(w).Encode(map[string]interface{}{ - "data": []map[string]interface{}{ - {"id": "gpt-4", "object": "model", "created": 1234567890, "owned_by": "openai"}, - {"id": "gpt-3.5-turbo", "object": "model", "created": 1234567890, "owned_by": "openai"}, - {"id": "claude-3", "object": "model", "created": 1234567890, "owned_by": "anthropic"}, - }, - }) - })) - defer server.Close() - - client := NewOpenAIClient("sk-test", "gpt-4", server.URL, 0) + client := NewOpenAIClient("sk-test", "gpt-4", "https://api.openai.com/v1", 0) + client.client = &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + assert.Equal(t, "https://api.openai.com/v1/models", r.URL.String()) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "data": [ + {"id": "gpt-4", "object": "model", "created": 1234567890, "owned_by": "openai"}, + {"id": "gpt-3.5-turbo", "object": "model", "created": 1234567890, "owned_by": "openai"}, + {"id": "claude-3", "object": "model", "created": 1234567890, "owned_by": "anthropic"} + ] + }`)), + Request: r, + }, nil + }), + } models, err := client.ListModels(context.Background()) require.NoError(t, err) @@ -969,6 +972,37 @@ func TestOpenAIClient_ListModels(t *testing.T) { assert.Equal(t, "gpt-3.5-turbo", models[1].ID) } +func TestOpenAIClient_ListModels_CustomBaseURLReturnsCatalog(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/models", r.URL.Path) + assert.Equal(t, "Bearer sk-test", r.Header.Get("Authorization")) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{ + {"id": "qwen2.5-coder:32b", "name": "Qwen Coder 32B", "description": "local coding model", "created": 1234567890}, + {"id": "llama3.3:70b", "object": "model", "created": 1234567891, "owned_by": "custom-lab"}, + {"id": "gpt-4-compatible", "object": "model", "created": 1234567892}, + }, + }) + })) + defer server.Close() + + client := NewOpenAIClient("sk-test", "qwen2.5-coder:32b", server.URL, 0) + + models, err := client.ListModels(context.Background()) + require.NoError(t, err) + + require.Len(t, models, 3) + assert.Equal(t, "qwen2.5-coder:32b", models[0].ID) + assert.Equal(t, "Qwen Coder 32B", models[0].Name) + assert.Equal(t, "local coding model", models[0].Description) + assert.Equal(t, "llama3.3:70b", models[1].ID) + assert.Equal(t, "custom-lab", models[1].Description) + assert.Equal(t, "gpt-4-compatible", models[2].ID) +} + func TestOpenAIClient_ListModels_OpenRouterReturnsCatalog(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/api/v1/models", r.URL.Path)