diff --git a/cmd/root.go b/cmd/root.go index 7595251..1acef17 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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" diff --git a/internal/config/config.go b/internal/config/config.go index 7287548..55fc04e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/config/default.go b/internal/config/default.go index 1084325..870c007 100644 --- a/internal/config/default.go +++ b/internal/config/default.go @@ -18,6 +18,9 @@ func DefaultConfig() *Config { Model: "qwen3:8b", BaseURL: "http://localhost:11434", }, + "openai": { + Model: "gpt-4o", + }, }, UI: UIConfig{ diff --git a/internal/providers/openai/client.go b/internal/providers/openai/client.go new file mode 100644 index 0000000..28a47dc --- /dev/null +++ b/internal/providers/openai/client.go @@ -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 +} diff --git a/internal/providers/openai/models.go b/internal/providers/openai/models.go new file mode 100644 index 0000000..af2d34e --- /dev/null +++ b/internal/providers/openai/models.go @@ -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"` +} diff --git a/internal/providers/openai/provider.go b/internal/providers/openai/provider.go new file mode 100644 index 0000000..42d64db --- /dev/null +++ b/internal/providers/openai/provider.go @@ -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) + } + }) +} diff --git a/internal/providers/openai/stream.go b/internal/providers/openai/stream.go new file mode 100644 index 0000000..7a94e63 --- /dev/null +++ b/internal/providers/openai/stream.go @@ -0,0 +1,84 @@ +package openai + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// StreamContent streams Server-Sent Events (SSE) chunks from the OpenAI API 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") + if c.apiKey != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + 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("openai 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 +} diff --git a/internal/providers/openai/translate.go b/internal/providers/openai/translate.go new file mode 100644 index 0000000..d832401 --- /dev/null +++ b/internal/providers/openai/translate.go @@ -0,0 +1,163 @@ +package openai + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/Nithwin/WindMist/internal/ai" +) + +// translateTools converts ai.ToolDefinition structs into OpenAI /v1/chat/completions Tool schemas. +func translateTools(tools []ai.ToolDefinition) []Tool { + if len(tools) == 0 { + return nil + } + + openaiTools := make([]Tool, 0, len(tools)) + for _, t := range tools { + properties := make(map[string]*Property) + required := make([]string, 0) + + for _, p := range t.Parameters { + schemaType := "string" + switch strings.ToLower(p.Type) { + case "string": + schemaType = "string" + case "int", "integer": + schemaType = "integer" + case "float", "number": + schemaType = "number" + case "bool", "boolean": + schemaType = "boolean" + case "array": + schemaType = "array" + case "object": + schemaType = "object" + } + + properties[p.Name] = &Property{ + Type: schemaType, + Description: p.Description, + Enum: p.Enum, + } + + if p.Required { + required = append(required, p.Name) + } + } + + openaiTools = append(openaiTools, Tool{ + Type: "function", + Function: Function{ + Name: t.Name, + Description: t.Description, + Parameters: &Schema{ + Type: "object", + Properties: properties, + Required: required, + }, + }, + }) + } + + return openaiTools +} + +// translateMessages converts []ai.Message into OpenAI []Message format. +func translateMessages(messages []ai.Message) []Message { + openaiMsgs := make([]Message, 0, len(messages)) + + for _, msg := range messages { + switch msg.Role { + case ai.RoleSystem: + openaiMsgs = append(openaiMsgs, Message{ + Role: "system", + Content: msg.Content, + }) + + case ai.RoleUser: + openaiMsgs = append(openaiMsgs, Message{ + Role: "user", + Content: msg.Content, + }) + + case ai.RoleAssistant: + var toolCalls []ToolCall + if len(msg.ToolCalls) > 0 { + toolCalls = make([]ToolCall, 0, len(msg.ToolCalls)) + for _, tc := range msg.ToolCalls { + argsJSON, _ := json.Marshal(tc.Args) + toolCalls = append(toolCalls, ToolCall{ + ID: tc.ID, + Type: "function", + Function: FunctionCall{ + Name: tc.Name, + Arguments: string(argsJSON), + }, + }) + } + } + + openaiMsgs = append(openaiMsgs, Message{ + Role: "assistant", + Content: msg.Content, + ToolCalls: toolCalls, + }) + + case ai.RoleTool: + for _, res := range msg.ToolResults { + openaiMsgs = append(openaiMsgs, Message{ + Role: "tool", + Content: res.Content, + ToolCallID: res.ID, + Name: res.Name, + }) + } + } + } + + return openaiMsgs +} + +// translateResponse converts an OpenAI ChatResponse into an ai.GenerateResponse. +func translateResponse(model string, resp *ChatResponse) (*ai.GenerateResponse, error) { + if len(resp.Choices) == 0 { + return nil, fmt.Errorf("openai returned no choices") + } + + choice := resp.Choices[0] + toolCalls := make([]ai.ToolCall, 0, len(choice.Message.ToolCalls)) + + for i, tc := range choice.Message.ToolCalls { + args := make(map[string]any) + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { + args["raw"] = tc.Function.Arguments + } + } + + id := tc.ID + if id == "" { + id = fmt.Sprintf("call_%s_%d", tc.Function.Name, i) + } + + toolCalls = append(toolCalls, ai.ToolCall{ + ID: id, + Name: tc.Function.Name, + Args: args, + }) + } + + return &ai.GenerateResponse{ + Text: choice.Message.Content, + ToolCalls: toolCalls, + Model: model, + Finish: choice.FinishReason, + Usage: ai.Usage{ + InputTokens: resp.Usage.PromptTokens, + OutputTokens: resp.Usage.CompletionTokens, + TotalTokens: resp.Usage.TotalTokens, + }, + }, nil +} diff --git a/internal/providers/openai/translate_test.go b/internal/providers/openai/translate_test.go new file mode 100644 index 0000000..54ecaab --- /dev/null +++ b/internal/providers/openai/translate_test.go @@ -0,0 +1,89 @@ +package openai + +import ( + "testing" + + "github.com/Nithwin/WindMist/internal/ai" +) + +func TestTranslateTools(t *testing.T) { + tools := []ai.ToolDefinition{ + { + Name: "create_file", + Description: "Creates a file", + Parameters: []ai.ToolParameter{ + { + Name: "path", + Type: "string", + Description: "Path to file", + Required: true, + }, + }, + }, + } + + translated := translateTools(tools) + if len(translated) != 1 { + t.Fatalf("expected 1 tool, got %d", len(translated)) + } + + fn := translated[0].Function + if fn.Name != "create_file" { + t.Errorf("expected name 'create_file', got '%s'", fn.Name) + } + + prop, ok := fn.Parameters.Properties["path"] + if !ok { + t.Fatalf("expected property 'path' in parameters") + } + if prop.Type != "string" { + t.Errorf("expected property type 'string', got '%s'", prop.Type) + } +} + +func TestTranslateResponse(t *testing.T) { + resp := &ChatResponse{ + ID: "test-id", + Choices: []Choice{ + { + Index: 0, + Message: Message{ + Role: "assistant", + ToolCalls: []ToolCall{ + { + ID: "call_1", + Type: "function", + Function: FunctionCall{ + Name: "read_file", + Arguments: `{"path": "test.txt"}`, + }, + }, + }, + }, + FinishReason: "tool_calls", + }, + }, + Usage: Usage{ + PromptTokens: 10, + CompletionTokens: 5, + TotalTokens: 15, + }, + } + + genResp, err := translateResponse("gpt-4o", resp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(genResp.ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(genResp.ToolCalls)) + } + + call := genResp.ToolCalls[0] + if call.Name != "read_file" { + t.Errorf("expected tool call name 'read_file', got '%s'", call.Name) + } + if path, ok := call.Args["path"].(string); !ok || path != "test.txt" { + t.Errorf("expected path 'test.txt', got '%v'", call.Args["path"]) + } +}