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 @@ -4,6 +4,7 @@ import (
_ "github.com/Nithwin/WindMist/internal/providers/gemini"
_ "github.com/Nithwin/WindMist/internal/providers/groq"
_ "github.com/Nithwin/WindMist/internal/providers/ollama"
_ "github.com/Nithwin/WindMist/internal/providers/openai"

"github.com/Nithwin/WindMist/internal/chat"
"github.com/spf13/cobra"
Expand Down
2 changes: 2 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import (
const (
EnvGeminiAPIKey = "GEMINI_API_KEY"
EnvGroqAPIKey = "GROQ_API_KEY"
EnvOpenAIAPIKey = "OPENAI_API_KEY"
)

var envKeys = map[string]string{
"gemini": EnvGeminiAPIKey,
"groq": EnvGroqAPIKey,
"openai": EnvOpenAIAPIKey,
}

// ActiveProvider returns the active provider configuration.
Expand Down
3 changes: 3 additions & 0 deletions internal/config/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ func DefaultConfig() *Config {
Model: "qwen3:8b",
BaseURL: "http://localhost:11434",
},
"openai": {
Model: "gpt-4o",
},
},

UI: UIConfig{
Expand Down
82 changes: 82 additions & 0 deletions internal/providers/openai/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package openai

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

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

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

// GenerateContent sends a non-streaming completion request to the OpenAI 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")
if c.apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
}

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("openai 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/openai/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package openai

// ChatRequest represents a request to OpenAI'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 single conversation message in OpenAI 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 (`type: "function"`).
type Tool struct {
Type string `json:"type"`
Function Function `json:"function"`
}

// Function holds the schema 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 arguments.
type Schema struct {
Type string `json:"type"`
Properties map[string]*Property `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
}

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

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

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

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

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

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

// StreamResponse represents an SSE chunk from OpenAI.
type StreamResponse struct {
Choices []StreamChoice `json:"choices"`
}

// StreamChoice represents a single candidate inside a StreamResponse chunk.
type StreamChoice struct {
Delta StreamDelta `json:"delta"`
FinishReason string `json:"finish_reason"`
}

// StreamDelta holds 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/openai/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package openai

import (
"context"
"strings"

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

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

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

// New creates a new OpenAI provider instance.
func New(cfg config.ProviderConfig) ai.Provider {
baseURL := strings.TrimRight(cfg.BaseURL, "/")
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
if !strings.HasSuffix(baseURL, "/v1") {
baseURL = baseURL + "/v1"
}

model := cfg.Model
if model == "" {
model = "gpt-4o"
}

return &Provider{
client: NewClient(cfg.APIKey, 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)
}
})
}
Loading
Loading