Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- MiniMax model registry entries and configurable OpenAI-compatible and
Anthropic-compatible regional endpoints.
- **Native TypeScript/JavaScript/Vue code graph with durable symbol anchors.**
Added a dependency-free tree-sitter index for project-aware symbols, calls,
references, type relations, re-exports, aliases, callbacks, receiver flows,
Expand Down
24 changes: 18 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ import (
// api_key: sk-ant-...
// deepseek:
// api_key: sk-...
// minimax:
// api_key: ...
// api_type: openai
// openai_base_url: https://api.minimax.io/v1
// anthropic_base_url: https://api.minimax.io/anthropic
//
// MiniMax's China endpoints are https://api.minimaxi.com/v1 and
// https://api.minimaxi.com/anthropic. Anthropic-compatible base URLs end at
// /anthropic; the SDK appends /v1/messages.
type Config struct {
Model string `yaml:"model" json:"model"` // default model ID
Providers map[string]ProviderAuth `yaml:"providers" json:"providers"` // provider ID → auth
Expand All @@ -55,12 +64,15 @@ type EmbeddingConfig struct {

// ProviderAuth stores credentials for one provider.
type ProviderAuth struct {
AuthType string `yaml:"auth_type,omitempty" json:"auth_type,omitempty"` // "api_key", "codex_oauth"
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
AccessToken string `yaml:"access_token,omitempty" json:"access_token,omitempty"`
RefreshToken string `yaml:"refresh_token,omitempty" json:"refresh_token,omitempty"`
ExpiresAt int64 `yaml:"expires_at,omitempty" json:"expires_at,omitempty"`
AccountID string `yaml:"account_id,omitempty" json:"account_id,omitempty"`
AuthType string `yaml:"auth_type,omitempty" json:"auth_type,omitempty"` // "api_key", "codex_oauth"
APIType string `yaml:"api_type,omitempty" json:"api_type,omitempty"` // "openai", "anthropic"
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
OpenAIBaseURL string `yaml:"openai_base_url,omitempty" json:"openai_base_url,omitempty"`
AnthropicBaseURL string `yaml:"anthropic_base_url,omitempty" json:"anthropic_base_url,omitempty"`
AccessToken string `yaml:"access_token,omitempty" json:"access_token,omitempty"`
RefreshToken string `yaml:"refresh_token,omitempty" json:"refresh_token,omitempty"`
ExpiresAt int64 `yaml:"expires_at,omitempty" json:"expires_at,omitempty"`
AccountID string `yaml:"account_id,omitempty" json:"account_id,omitempty"`
}

// ---------------------------------------------------------------------------
Expand Down
19 changes: 16 additions & 3 deletions internal/provider/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,29 @@ var _ LLMProvider = (*AnthropicProvider)(nil)
// NewAnthropic creates an Anthropic provider.
// Resolves API key from: explicit param → ANTHROPIC_API_KEY env.
func NewAnthropic(model, apiKey string) (*AnthropicProvider, error) {
return NewAnthropicWithBaseURL(model, apiKey, "")
}

// NewAnthropicWithBaseURL creates an Anthropic-compatible provider with an
// optional base URL. The SDK appends /v1/messages to the configured base URL.
func NewAnthropicWithBaseURL(model, apiKey, baseURL string) (*AnthropicProvider, error) {
return newAnthropicProvider(model, apiKey, baseURL)
}

func newAnthropicProvider(model, apiKey, baseURL string, extra ...option.RequestOption) (*AnthropicProvider, error) {
if apiKey == "" {
apiKey = os.Getenv("ANTHROPIC_API_KEY")
}
if apiKey == "" {
return nil, fmt.Errorf("no Anthropic API key: set ANTHROPIC_API_KEY or run 'haft setup'")
}

client := anthropic.NewClient(
option.WithAPIKey(apiKey),
)
opts := []option.RequestOption{option.WithAPIKey(apiKey)}
if baseURL != "" {
opts = append(opts, option.WithBaseURL(baseURL))
}
opts = append(opts, extra...)
client := anthropic.NewClient(opts...)

return &AnthropicProvider{
client: client,
Expand Down
41 changes: 37 additions & 4 deletions internal/provider/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,56 @@ package provider

import "fmt"

// ProviderOptions configures a provider adapter without changing the legacy
// NewProvider call shape.
type ProviderOptions struct {
APIType string
Region string
OpenAIBaseURL string
AnthropicBaseURL string
}

// NewProvider creates an LLM provider based on provider ID.
// Routes to the appropriate implementation:
// - "openai": OpenAI Responses API (also handles Codex/ChatGPT auth)
// - "anthropic": Anthropic Messages API
// - Others: treated as OpenAI-compatible (DeepSeek, Groq, Mistral, etc.)
// - "minimax": OpenAI-compatible by default, or Anthropic-compatible with options
//
// For OpenAI, apiKey can be empty — it resolves from env/config/codex.
// For Anthropic, apiKey is required (from env or config).
func NewProvider(providerID, model, apiKey string) (LLMProvider, error) {
return NewProviderWithOptions(providerID, model, apiKey, ProviderOptions{})
}

// NewProviderWithOptions creates a provider with protocol and endpoint
// selection for compatible providers.
func NewProviderWithOptions(providerID, model, apiKey string, options ProviderOptions) (LLMProvider, error) {
switch providerID {
case "openai":
return NewOpenAI(model)
case "anthropic":
return NewAnthropic(model, apiKey)
case "minimax":
endpoint, ok := MiniMaxEndpoint(options.Region)
if !ok {
return nil, fmt.Errorf("unknown MiniMax region %q", options.Region)
}
if options.APIType == "anthropic" {
baseURL := options.AnthropicBaseURL
if baseURL == "" {
baseURL = endpoint.AnthropicBaseURL
}
return NewAnthropicWithBaseURL(model, apiKey, baseURL)
}
if options.APIType != "" && options.APIType != "openai" {
return nil, fmt.Errorf("unsupported MiniMax API type %q", options.APIType)
}
baseURL := options.OpenAIBaseURL
if baseURL == "" {
baseURL = endpoint.OpenAIBaseURL
}
return NewOpenAICompatible(model, apiKey, baseURL)
default:
// OpenAI-compatible providers (DeepSeek, Groq, etc.)
// For now, route through OpenAI — they use the same API format.
// TODO: support custom base URLs for non-OpenAI providers.
return nil, fmt.Errorf("provider %q not yet supported — use openai or anthropic", providerID)
}
}
Expand Down Expand Up @@ -54,6 +86,7 @@ func guessProviderFromPrefix(model string) string {
"gemini-": "google",
"deepseek-": "deepseek",
"llama-": "groq",
"MiniMax-": "minimax",
}
for prefix, provider := range prefixes {
if len(model) >= len(prefix) && model[:len(prefix)] == prefix {
Expand Down
99 changes: 99 additions & 0 deletions internal/provider/factory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package provider

import (
"context"
"net/http"
"net/http/httptest"
"testing"

anthropic "github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
openaioption "github.com/openai/openai-go/v3/option"
)

func TestMiniMaxProviderFactory(t *testing.T) {
openAIProvider, err := NewProviderWithOptions(
"minimax",
"MiniMax-M3",
"test-key",
ProviderOptions{Region: "global_en"},
)
if err != nil {
t.Fatalf("create MiniMax OpenAI-compatible provider: %v", err)
}
if _, ok := openAIProvider.(*OpenAIProvider); !ok {
t.Fatalf("MiniMax default provider type: got %T, want *OpenAIProvider", openAIProvider)
}
if openAIProvider.ModelID() != "MiniMax-M3" {
t.Fatalf("MiniMax OpenAI-compatible model: got %q", openAIProvider.ModelID())
}

anthropicProvider, err := NewProviderWithOptions(
"minimax",
"MiniMax-M2.7",
"test-key",
ProviderOptions{APIType: "anthropic", Region: "cn_zh"},
)
if err != nil {
t.Fatalf("create MiniMax Anthropic-compatible provider: %v", err)
}
if _, ok := anthropicProvider.(*AnthropicProvider); !ok {
t.Fatalf("MiniMax Anthropic-compatible provider type: got %T, want *AnthropicProvider", anthropicProvider)
}
if anthropicProvider.ModelID() != "MiniMax-M2.7" {
t.Fatalf("MiniMax Anthropic-compatible model: got %q", anthropicProvider.ModelID())
}
}

func TestMiniMaxOpenAIEndpointPath(t *testing.T) {
var path string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"resp_1","object":"response","created_at":1,"status":"completed","model":"MiniMax-M3","output":[]}`))
}))
defer server.Close()

provider, err := newOpenAIProvider(
"MiniMax-M3",
"test-key",
"api_key",
"",
server.URL+"/v1",
openaioption.WithHTTPClient(server.Client()),
)
if err != nil {
t.Fatalf("create OpenAI-compatible provider: %v", err)
}
_, _ = provider.client.Responses.New(context.Background(), buildResponseParams("MiniMax-M3", "", "api_key", nil, nil))
if path != "/v1/responses" {
t.Fatalf("OpenAI-compatible request path: got %q, want /v1/responses", path)
}
}

func TestMiniMaxAnthropicEndpointPath(t *testing.T) {
var path string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()

provider, err := newAnthropicProvider(
"MiniMax-M2.7",
"test-key",
server.URL+"/anthropic",
option.WithHTTPClient(server.Client()),
)
if err != nil {
t.Fatalf("create Anthropic-compatible provider: %v", err)
}
_, _ = provider.client.Messages.New(context.Background(), anthropic.MessageNewParams{
Model: "MiniMax-M2.7",
MaxTokens: 1,
})
if path != "/anthropic/v1/messages" {
t.Fatalf("Anthropic-compatible request path: got %q, want /anthropic/v1/messages", path)
}
}
31 changes: 25 additions & 6 deletions internal/provider/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,46 @@ func NewOpenAI(model string) (*OpenAIProvider, error) {
if resolved.key == "" {
return nil, fmt.Errorf("no OpenAI auth found: run 'haft login' or set OPENAI_API_KEY")
}
return newOpenAIProvider(model, resolved.key, resolved.authType, resolved.accountID, "")
}

opts := []option.RequestOption{option.WithAPIKey(resolved.key)}
// NewOpenAICompatible creates an OpenAI-compatible provider with an explicit
// API key and base URL.
func NewOpenAICompatible(model, apiKey, baseURL string) (*OpenAIProvider, error) {
if apiKey == "" {
return nil, fmt.Errorf("no API key configured for OpenAI-compatible provider")
}
if baseURL == "" {
return nil, fmt.Errorf("no base URL configured for OpenAI-compatible provider")
}
return newOpenAIProvider(model, apiKey, "api_key", "", baseURL)
}

func newOpenAIProvider(model, apiKey, authType, accountID, baseURL string, extra ...option.RequestOption) (*OpenAIProvider, error) {
opts := []option.RequestOption{option.WithAPIKey(apiKey)}
if baseURL != "" {
opts = append(opts, option.WithBaseURL(baseURL))
}

// ChatGPT/Codex auth uses the ChatGPT backend and requires workspace scoping.
if resolved.authType == "codex" || resolved.authType == "codex_cli" {
if authType == "codex" || authType == "codex_cli" {
opts = append(opts,
option.WithBaseURL(codexAPIEndpoint),
option.WithHeader("originator", codexOriginator),
)
if resolved.accountID != "" {
opts = append(opts, option.WithHeader("chatgpt-account-id", resolved.accountID))
if accountID != "" {
opts = append(opts, option.WithHeader("chatgpt-account-id", accountID))
}
}
opts = append(opts, extra...)

client := openai.NewClient(opts...)

return &OpenAIProvider{
client: client,
model: model,
accountID: resolved.accountID,
authType: resolved.authType,
accountID: accountID,
authType: authType,
}, nil
}

Expand Down
Loading