package llm import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" ) const openAIEndpoint = "https://api.openai.com/v1/chat/completions" // Client wraps OpenAI API calls. type Client struct { httpClient *http.Client apiKey string model string } // NewClient creates a new OpenAI client. Returns nil if no API key is configured. func NewClient() (*Client, error) { apiKey := os.Getenv("OPENAI_API_KEY") if apiKey == "" { return nil, fmt.Errorf("OPENAI_API_KEY not set") } model := os.Getenv("OPENAI_MODEL") if model == "" { model = "gpt-4o-mini" } return &Client{ httpClient: &http.Client{Timeout: 5 * time.Minute}, apiKey: apiKey, model: model, }, nil } type chatRequest struct { Model string `json:"model"` Messages []chatMsg `json:"messages"` MaxTokens int `json:"max_tokens,omitempty"` MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` Temperature float32 `json:"temperature,omitempty"` ResponseFormat *respFormat `json:"response_format,omitempty"` } type chatMsg struct { Role string `json:"role"` Content string `json:"content"` } type respFormat struct { Type string `json:"type"` JsonSchema *jsonSchemaDef `json:"json_schema,omitempty"` } type jsonSchemaDef struct { Name string `json:"name"` Schema json.RawMessage `json:"schema"` Strict bool `json:"strict"` } type chatResponse struct { Choices []struct { Message chatMsg `json:"message"` } `json:"choices"` Error *struct { Message string `json:"message"` Type string `json:"type"` } `json:"error,omitempty"` } // FormatMarkdown sends the markdown to the LLM for cleanup and returns the cleaned version. func (c *Client) FormatMarkdown(ctx context.Context, systemPrompt, markdown string, userInstructions string) (string, error) { userMsg := BuildFormatMessage(markdown, userInstructions) req := c.newReq(16000, 0.1, false) req.Messages = []chatMsg{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userMsg}, } return c.doChat(ctx, req) } // newReq creates a chatRequest with the correct token parameter for the model. func (c *Client) newReq(maxTokens int, temperature float32, wantJSON bool) chatRequest { req := chatRequest{ Model: c.model, Temperature: temperature, } if strings.HasPrefix(c.model, "gpt-5") { req.MaxCompletionTokens = maxTokens } else { req.MaxTokens = maxTokens } if wantJSON { req.ResponseFormat = &respFormat{Type: "json_object"} } return req } func (c *Client) doChat(ctx context.Context, req chatRequest) (string, error) { resp, err := c.doChatRaw(ctx, req) if err != nil { return "", err } if len(resp.Choices) == 0 { return "", fmt.Errorf("empty LLM response") } return resp.Choices[0].Message.Content, nil } func (c *Client) doChatRaw(ctx context.Context, req chatRequest) (*chatResponse, error) { body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("marshaling request: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, openAIEndpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) httpReq.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("HTTP request: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reading response body: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("OpenAI API error (status %d): %s", resp.StatusCode, string(respBody)) } var chatResp chatResponse if err := json.Unmarshal(respBody, &chatResp); err != nil { return nil, fmt.Errorf("parsing response: %w (raw: %s)", err, string(respBody)) } if chatResp.Error != nil { return nil, fmt.Errorf("OpenAI error (%s): %s", chatResp.Error.Type, chatResp.Error.Message) } return &chatResp, nil }