Skip to content
Open

Ollama #1466

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
634 changes: 634 additions & 0 deletions OLLAMA_INTEGRATION_CHANGES.md

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions api/handler_ai_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"fmt"
"net/http"
"strings"
"time"

"nofx/config"
"nofx/crypto"
"nofx/logger"
"nofx/mcp"
"nofx/security"
"nofx/wallet"

Expand Down Expand Up @@ -68,6 +70,7 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false},
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false},
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false},
{ID: "ollama", Name: "Ollama AI", Provider: "ollama", Enabled: false},
}
c.JSON(http.StatusOK, defaultModels)
return
Expand Down Expand Up @@ -218,8 +221,115 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
{"id": "ollama", "name": "Ollama (Local)", "provider": "ollama", "defaultModel": "llama3.1"},
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "glm-5"},
}

c.JSON(http.StatusOK, supportedModels)
}

// TestModelRequest request body for testing an AI model connection
type TestModelRequest struct {
Provider string `json:"provider"`
APIKey string `json:"api_key"`
CustomAPIURL string `json:"custom_api_url"`
CustomModelName string `json:"custom_model_name"`
}

// TestModelResponse response for test model endpoint
type TestModelResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
LatencyMs int64 `json:"latency_ms"`
}

// handleTestModel Test AI model connection with provided credentials
func (s *Server) handleTestModel(c *gin.Context) {
cfg := config.Get()

bodyBytes, err := c.GetRawData()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
return
}

var req TestModelRequest

if !cfg.TransportEncryption {
if err := json.Unmarshal(bodyBytes, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}
} else {
var encryptedPayload crypto.EncryptedPayload
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil || encryptedPayload.WrappedKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Encrypted transmission required"})
return
}
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
return
}
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
return
}
}

if req.Provider == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Provider is required"})
return
}
if req.APIKey == "" && req.Provider != mcp.ProviderOllama {
c.JSON(http.StatusBadRequest, gin.H{"error": "API key is required"})
return
}

if req.CustomAPIURL != "" {
cleanURL := strings.TrimSuffix(req.CustomAPIURL, "#")
if err := security.ValidateURL(cleanURL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom API URL: %s", err.Error())})
return
}
}

client := mcp.NewAIClientByProvider(
req.Provider,
mcp.WithTimeout(15*time.Second),
mcp.WithMaxRetries(1),
mcp.WithMaxTokens(10),
)
if client == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Unsupported provider: %s", req.Provider)})
return
}

client.SetAPIKey(req.APIKey, req.CustomAPIURL, req.CustomModelName)

start := time.Now()
_, err = client.CallWithMessages("", "Say hi")
latencyMs := time.Since(start).Milliseconds()

if err != nil {
errMsg := err.Error()
userMsg := "Connection failed"
switch {
case strings.Contains(errMsg, "401") || strings.Contains(errMsg, "403") || strings.Contains(errMsg, "Unauthorized") || strings.Contains(errMsg, "authentication"):
userMsg = "Invalid API key"
case strings.Contains(errMsg, "404"):
userMsg = "Model not found"
case strings.Contains(errMsg, "429"):
userMsg = "Rate limited"
case strings.Contains(errMsg, "timeout") || strings.Contains(errMsg, "Timeout"):
userMsg = "Request timed out"
case strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "no such host"):
userMsg = "Cannot reach API endpoint"
}
logger.Infof("❌ Model test failed for %s: %v", req.Provider, err)
c.JSON(http.StatusOK, TestModelResponse{Success: false, Message: userMsg, LatencyMs: latencyMs})
return
}

c.JSON(http.StatusOK, TestModelResponse{Success: true, Message: "Connection successful", LatencyMs: latencyMs})
}
4 changes: 4 additions & 0 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ CRITICAL: The "id" field (e.g. "abc123_deepseek") is what you must use for ai_mo
model_id values: "openai","deepseek","qwen","kimi","grok","gemini","claude"
Defaults when custom fields empty: openai→api.openai.com/v1, deepseek→api.deepseek.com, qwen→dashscope.aliyuncs.com/compatible-mode/v1, kimi→api.moonshot.ai/v1, grok→api.x.ai/v1, gemini→generativelanguage.googleapis.com/v1beta/openai, claude→api.anthropic.com/v1`,
s.handleUpdateModelConfigs)
s.routeWithSchema(protected, "POST", "/models/test", "Test AI model connection with provided credentials",
`Body: {"provider":"<string>","api_key":"<string>","custom_api_url":"<string, optional>","custom_model_name":"<string, optional>"}
Returns: {"success":<bool>,"message":"<string>","latency_ms":<int>}`,
s.handleTestModel)

// Exchange configuration
s.routeWithSchema(protected, "GET", "/exchanges", "List exchange accounts",
Expand Down
8 changes: 4 additions & 4 deletions mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ func (client *Client) SetTimeout(timeout time.Duration) {

// CallWithMessages template method - fixed retry flow (cannot be overridden)
func (client *Client) CallWithMessages(systemPrompt, userPrompt string) (string, error) {
if client.APIKey == "" {
if client.APIKey == "" && client.Provider != ProviderOllama {
return "", fmt.Errorf("AI API key not set, please call SetAPIKey first")
}

Expand Down Expand Up @@ -413,7 +413,7 @@ func (client *Client) IsRetryableError(err error) bool {

// CallWithRequest calls AI API using Request object (supports advanced features)
func (client *Client) CallWithRequest(req *Request) (string, error) {
if client.APIKey == "" {
if client.APIKey == "" && client.Provider != ProviderOllama {
return "", fmt.Errorf("AI API key not set, please call SetAPIKey first")
}

Expand Down Expand Up @@ -459,7 +459,7 @@ func (client *Client) CallWithRequest(req *Request) (string, error) {

// CallWithRequestFull calls the AI API and returns both text content and tool calls.
func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
if client.APIKey == "" {
if client.APIKey == "" && client.Provider != ProviderOllama {
return nil, fmt.Errorf("AI API key not set, please call SetAPIKey first")
}
if req.Model == "" {
Expand Down Expand Up @@ -664,7 +664,7 @@ func (client *Client) BuildRequestBodyFromRequest(req *Request) map[string]any {
// Idle timeout: if no chunk arrives for 30 seconds the stream is cancelled automatically.
// This prevents the scanner from blocking indefinitely on a hung or stalled connection.
func (client *Client) CallWithRequestStream(req *Request, onChunk func(string)) (string, error) {
if client.APIKey == "" {
if client.APIKey == "" && client.Provider != ProviderOllama {
return "", fmt.Errorf("AI API key not set")
}
if req.Model == "" {
Expand Down
76 changes: 76 additions & 0 deletions mcp/provider/ollama.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package provider

import (
"fmt"
"net/http"
"strings"

"nofx/mcp"
)

func init() {
mcp.RegisterProvider(mcp.ProviderOllama, func(opts ...mcp.ClientOption) mcp.AIClient {
return NewOllamaClientWithOptions(opts...)
})
}

type OllamaClient struct {
*mcp.Client
}

func (c *OllamaClient) BaseClient() *mcp.Client { return c.Client }

// NewOllamaClient creates Ollama client (backward compatible)
func NewOllamaClient() mcp.AIClient {
return NewOllamaClientWithOptions()
}

// NewOllamaClientWithOptions creates Ollama client (supports options pattern)
func NewOllamaClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
ollamaOpts := []mcp.ClientOption{
mcp.WithProvider(mcp.ProviderOllama),
mcp.WithModel(mcp.DefaultOllamaModel),
}

allOpts := append(ollamaOpts, opts...)
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)

ollamaClient := &OllamaClient{
Client: baseClient,
}

baseClient.Hooks = ollamaClient
return ollamaClient
}

func (c *OllamaClient) SetAPIKey(apiKey string, customURL string, customModel string) {
c.APIKey = apiKey // May be empty — Ollama typically needs no auth

if customURL != "" {
c.BaseURL = customURL
c.Log.Infof("🔧 [MCP] Ollama using BaseURL: %s", customURL)
} else if c.BaseURL == "" {
c.Log.Warnf("⚠️ [MCP] Ollama requires a Base URL to be set")
}
if customModel != "" {
c.Model = customModel
c.Log.Infof("🔧 [MCP] Ollama using custom Model: %s", customModel)
} else {
c.Log.Infof("🔧 [MCP] Ollama using default Model: %s", c.Model)
}
}

// SetAuthHeader skips Authorization header when no API key is set
func (c *OllamaClient) SetAuthHeader(reqHeaders http.Header) {
if c.APIKey != "" {
c.Client.SetAuthHeader(reqHeaders)
}
}

// BuildUrl constructs the Ollama OpenAI-compatible endpoint URL
func (c *OllamaClient) BuildUrl() string {
if c.UseFullURL {
return c.BaseURL
}
return fmt.Sprintf("%s/v1/chat/completions", strings.TrimRight(c.BaseURL, "/"))
}
4 changes: 4 additions & 0 deletions mcp/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const (
ProviderKimi = "kimi"
ProviderMiniMax = "minimax"

ProviderOllama = "ollama"
ProviderClaw402 = "claw402"

// Default DeepSeek configuration (used as fallback in NewClient)
Expand All @@ -26,4 +27,7 @@ const (
// Default MiniMax configuration (used by WithMiniMaxConfig convenience option)
DefaultMiniMaxBaseURL = "https://api.minimax.io/v1"
DefaultMiniMaxModel = "MiniMax-M2.7"

// Default Ollama configuration (no default base URL — user must provide)
DefaultOllamaModel = "llama3.1"
)
14 changes: 14 additions & 0 deletions web/public/icons/ollama.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions web/src/components/common/ModelIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const MODEL_COLORS: Record<string, string> = {
grok: '#000000',
openai: '#10A37F',
minimax: '#E45735',
ollama: '#FFFFFF',
claw402: '#7C3AED',
}

Expand Down Expand Up @@ -49,6 +50,9 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
case 'minimax':
iconPath = '/icons/minimax.svg'
break
case 'ollama':
iconPath = '/icons/ollama.svg'
break
case 'claw402':
iconPath = '/icons/claw402.png'
break
Expand Down
Loading