Skip to content
Merged
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
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
_ "github.com/Nithwin/WindMist/internal/providers/gemini"
_ "github.com/Nithwin/WindMist/internal/providers/ollama"

"github.com/Nithwin/WindMist/internal/chat"
"github.com/spf13/cobra"
Expand Down
77 changes: 77 additions & 0 deletions internal/providers/ollama/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package ollama

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)

// Client handles low-level HTTP communication with the Ollama API server.
type Client struct {
baseURL string
model string
client *http.Client
}

// NewClient creates a new Ollama HTTP client.
func NewClient(baseURL, model string) *Client {
return &Client{
baseURL: baseURL,
model: model,
client: &http.Client{
Timeout: 120 * time.Second,
},
}
}

// GenerateContent sends a non-streaming completion request to the Ollama server.
func (c *Client) GenerateContent(
ctx context.Context,
req *ChatRequest,
) (*ChatResponse, error) {

body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}

endpoint := fmt.Sprintf("%s/chat/completions", c.baseURL)

httpReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
endpoint,
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}

httpReq.Header.Set("Content-Type", "application/json")

resp, err := c.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()

data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ollama server returned status %d: %s", resp.StatusCode, string(data))
}

var result ChatResponse
if err := json.Unmarshal(data, &result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}

return &result, nil
}
98 changes: 98 additions & 0 deletions internal/providers/ollama/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package ollama

// ChatRequest represents a request to Ollama's /v1/chat/completions endpoint.
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
Temperature float32 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Stream bool `json:"stream"`
}

// Message represents a message in the /v1/chat/completions format.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
Name string `json:"name,omitempty"`
}

// Tool represents a tool definition in OpenAI-compatible format.
type Tool struct {
Type string `json:"type"`
Function Function `json:"function"`
}

// Function represents the function details inside a Tool.
type Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters *Schema `json:"parameters,omitempty"`
}

// Schema represents JSON Schema properties for tool parameters.
type Schema struct {
Type string `json:"type"`
Properties map[string]*Property `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
}

// Property represents a single parameter property in JSON Schema.
type Property struct {
Type string `json:"type"`
Description string `json:"description"`
Enum []string `json:"enum,omitempty"`
}

// ToolCall represents a tool call requested by the assistant.
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function FunctionCall `json:"function"`
}

// FunctionCall holds the name and JSON string arguments of the called tool.
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}

// ChatResponse represents the non-streaming JSON response from /v1/chat/completions.
type ChatResponse struct {
ID string `json:"id"`
Choices []Choice `json:"choices"`
Usage Usage `json:"usage"`
}

// Choice represents a single candidate in ChatResponse.
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}

// Usage holds token count metrics returned by Ollama.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}

// StreamResponse represents a streaming chunk from /v1/chat/completions.
type StreamResponse struct {
Choices []StreamChoice `json:"choices"`
}

// StreamChoice represents a single choice in a streaming chunk.
type StreamChoice struct {
Delta StreamDelta `json:"delta"`
FinishReason string `json:"finish_reason"`
}

// StreamDelta represents the incremental update in a stream chunk.
type StreamDelta struct {
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
106 changes: 106 additions & 0 deletions internal/providers/ollama/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package ollama

import (
"context"
"strings"

"github.com/Nithwin/WindMist/internal/ai"
"github.com/Nithwin/WindMist/internal/config"
)

func init() {
ai.Register("ollama", New)
}

// Provider implements the ai.Provider interface for Ollama.
type Provider struct {
client *Client
model string
}

// New creates a new Ollama provider instance.
func New(cfg config.ProviderConfig) ai.Provider {
baseURL := strings.TrimRight(cfg.BaseURL, "/")
if baseURL == "" {
baseURL = "http://localhost:11434"
}
if !strings.HasSuffix(baseURL, "/v1") {
baseURL = baseURL + "/v1"
}

model := cfg.Model
if model == "" {
model = "qwen2.5:8b"
}

return &Provider{
client: NewClient(baseURL, model),
model: model,
}
}

// Generate sends a non-streaming completion request via Client.
func (p *Provider) Generate(
ctx context.Context,
req *ai.GenerateRequest,
) (*ai.GenerateResponse, error) {

messages := translateMessages(req.Messages)
if req.System != "" {
messages = append([]Message{{
Role: "system",
Content: req.System,
}}, messages...)
}

chatReq := &ChatRequest{
Model: p.model,
Messages: messages,
Tools: translateTools(req.Tools),
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: false,
}

chatResp, err := p.client.GenerateContent(ctx, chatReq)
if err != nil {
return nil, err
}

return translateResponse(p.model, chatResp)
}

// Stream streams a completion response chunk by chunk via Client.
func (p *Provider) Stream(
ctx context.Context,
req *ai.GenerateRequest,
onChunk func(string),
) error {

messages := translateMessages(req.Messages)
if req.System != "" {
messages = append([]Message{{
Role: "system",
Content: req.System,
}}, messages...)
}

chatReq := &ChatRequest{
Model: p.model,
Messages: messages,
Tools: translateTools(req.Tools),
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
Stream: true,
}

return p.client.StreamContent(ctx, chatReq, func(resp *StreamResponse) {
if len(resp.Choices) == 0 {
return
}
delta := resp.Choices[0].Delta
if delta.Content != "" {
onChunk(delta.Content)
}
})
}
81 changes: 81 additions & 0 deletions internal/providers/ollama/stream.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package ollama

import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)

// StreamContent streams Server-Sent Events (SSE) chunks from the Ollama server.
func (c *Client) StreamContent(
ctx context.Context,
req *ChatRequest,
onChunk func(*StreamResponse),
) error {

body, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}

endpoint := fmt.Sprintf("%s/chat/completions", c.baseURL)

httpReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
endpoint,
bytes.NewReader(body),
)
if err != nil {
return fmt.Errorf("create request: %w", err)
}

httpReq.Header.Set("Content-Type", "application/json")

resp, err := c.client.Do(httpReq)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("ollama server returned status %d: %s", resp.StatusCode, string(data))
}

scanner := bufio.NewScanner(resp.Body)

for scanner.Scan() {
if ctx.Err() != nil {
return ctx.Err()
}

line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data: ") {
continue
}

payload := strings.TrimPrefix(line, "data: ")
if payload == "[DONE]" {
break
}

var chunk StreamResponse
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}

onChunk(&chunk)
}

if err := scanner.Err(); err != nil {
return fmt.Errorf("read stream: %w", err)
}

return nil
}
Loading
Loading