AI Integration
Grit ships with multi-provider AI support for Claude (Anthropic), OpenAI, and Gemini (Google). Generate completions, run multi-turn conversations, and stream responses via SSE -- all through a unified Go service with raw net/http calls (no SDK dependencies).
Configuration
AI is configured via three environment variables. Switch between Claude, OpenAI, and Gemini by changing the provider and model -- no code changes required.
# AI ConfigurationAI_PROVIDER=claude # "claude", "openai", or "gemini"AI_API_KEY=sk-ant-xxxxxxxxxxxxx # API key for the selected providerAI_MODEL=claude-sonnet-4-5-20250929 # Model identifier
| Provider | AI_PROVIDER | Example Models |
|---|---|---|
| Anthropic Claude | claude | claude-sonnet-4-20250514, claude-opus-4-20250514 |
| OpenAI | openai | gpt-4o, gpt-4o-mini |
| Google Gemini | gemini | gemini-2.0-flash, gemini-1.5-pro |
AI Service
The AI service at internal/ai/ai.go provides a unified interface that works with Claude, OpenAI, and Gemini. It handles the differences in API formats, authentication headers, and response structures internally.
// Message represents a chat message.type Message struct {Role string `json:"role"` // "user" or "assistant"Content string `json:"content"`}// CompletionRequest holds the input for a completion.type CompletionRequest struct {Prompt string `json:"prompt"`Messages []Message `json:"messages,omitempty"`MaxTokens int `json:"max_tokens,omitempty"`Temperature float64 `json:"temperature,omitempty"`}// CompletionResponse holds the AI response.type CompletionResponse struct {Content string `json:"content"`Model string `json:"model"`Usage *Usage `json:"usage,omitempty"`}// Usage contains token usage information.type Usage struct {InputTokens int `json:"input_tokens"`OutputTokens int `json:"output_tokens"`}// StreamHandler is called for each chunk of a streamed response.type StreamHandler func(chunk string) error
// New creates a new AI service instance.func New(provider, apiKey, model string) *AI// Complete generates a response from a single prompt or message history.// Automatically routes to Claude, OpenAI, or Gemini based on provider config.func (a *AI) Complete(ctx context.Context, req CompletionRequest) (*CompletionResponse, error)// Stream generates a streaming response, calling handler for each text chunk.// Uses SSE (Server-Sent Events) from the upstream API.func (a *AI) Stream(ctx context.Context, req CompletionRequest, handler StreamHandler) error
Complete: Single Prompt
The simplest way to use the AI service. Send a prompt, get a response.
aiService := ai.New("claude", apiKey, "claude-sonnet-4-20250514")resp, err := aiService.Complete(ctx, ai.CompletionRequest{Prompt: "Explain the Go concurrency model in 3 sentences.",MaxTokens: 256,})if err != nil {return fmt.Errorf("AI completion failed: %w", err)}fmt.Println(resp.Content) // "Go uses goroutines..."fmt.Println(resp.Model) // "claude-sonnet-4-20250514"fmt.Println(resp.Usage.InputTokens) // 12fmt.Println(resp.Usage.OutputTokens) // 87
API Endpoint
Chat: Multi-Turn Conversations
For multi-turn conversations, send an array of messages with alternating user/assistant roles. The AI service passes the full conversation history to the provider.
resp, err := aiService.Complete(ctx, ai.CompletionRequest{Messages: []ai.Message{{Role: "user", Content: "I'm building a SaaS with Go and React."},{Role: "assistant", Content: "That's a great stack! Go handles the backend..."},{Role: "user", Content: "How should I structure my API?"},},MaxTokens: 512,Temperature: 0.7,})
API Endpoint
// Request body:{"messages": [{ "role": "user", "content": "What is Grit?" },{ "role": "assistant", "content": "Grit is a full-stack framework..." },{ "role": "user", "content": "How do I generate a resource?" }],"max_tokens": 512,"temperature": 0.7}// Response:{"data": {"content": "To generate a resource in Grit, use the CLI...","model": "claude-sonnet-4-20250514","usage": {"input_tokens": 45,"output_tokens": 120}}}
Stream: Server-Sent Events
The streaming endpoint sends response chunks as SSE events in real-time. This enables typewriter-style output in chat interfaces. The handler function receives each text chunk as it arrives from the AI provider.
// In a Go service:err := aiService.Stream(ctx, ai.CompletionRequest{Prompt: "Write a haiku about Go programming",MaxTokens: 100,}, func(chunk string) error {fmt.Print(chunk) // Prints each word/token as it arrivesreturn nil})
How Streaming Works via Gin
The AI handler at POST /api/ai/stream sets SSE headers and uses Gin's c.SSEvent() to send each chunk to the client. The connection stays open until the AI response is complete.
func (h *AIHandler) Stream(c *gin.Context) {var req chatRequestif err := c.ShouldBindJSON(&req); err != nil {c.JSON(http.StatusUnprocessableEntity, gin.H{...})return}// Set SSE headersc.Header("Content-Type", "text/event-stream")c.Header("Cache-Control", "no-cache")c.Header("Connection", "keep-alive")// Stream chunks to clienterr := h.AI.Stream(c.Request.Context(), ai.CompletionRequest{Messages: req.Messages,MaxTokens: req.MaxTokens,Temperature: req.Temperature,}, func(chunk string) error {c.SSEvent("message", chunk)c.Writer.Flush()return nil})if err != nil {c.SSEvent("error", fmt.Sprintf("Stream error: %v", err))c.Writer.Flush()}c.SSEvent("done", "[DONE]")c.Writer.Flush()}
Consuming the Stream (Frontend)
async function streamCompletion(messages: Message[]) {const response = await fetch("/api/ai/stream", {method: "POST",headers: {"Content-Type": "application/json",Authorization: `Bearer ${token}`,},body: JSON.stringify({ messages, max_tokens: 1024 }),});const reader = response.body?.getReader();const decoder = new TextDecoder();while (true) {const { done, value } = await reader!.read();if (done) break;const text = decoder.decode(value);const lines = text.split("\n");for (const line of lines) {if (line.startsWith("data: ")) {const data = JSON.parse(line.slice(6));if (data === "[DONE]") return;// Append chunk to the UIsetResponse((prev) => prev + data);}}}}
API Endpoints
| Endpoint | Method | Description |
|---|---|---|
| /api/ai/complete | POST | Single prompt completion |
| /api/ai/chat | POST | Multi-turn conversation |
| /api/ai/stream | POST | Streaming response via SSE |
Switching Providers
Switching between Claude, OpenAI, and Gemini requires only environment variable changes. The AI service abstracts away the differences in request/response formats, authentication headers, and streaming protocols.
AI_PROVIDER=claudeAI_API_KEY=sk-ant-api03-xxxxxxxxxxxxAI_MODEL=claude-sonnet-4-20250514
AI_PROVIDER=openaiAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxAI_MODEL=gpt-4o
AI_PROVIDER=geminiAI_API_KEY=AIzaSyxxxxxxxxxxxxxxxxxxxxxxxAI_MODEL=gemini-2.0-flash
| Difference | Claude | OpenAI | Gemini |
|---|---|---|---|
| API URL | api.anthropic.com/v1/messages | api.openai.com/v1/chat/completions | generativelanguage.googleapis.com |
| Auth | x-api-key header | Authorization: Bearer | ?key= query param |
| Response format | content[0].text | choices[0].message.content | candidates[0].content.parts[0].text |
| Stream event | content_block_delta | choices[0].delta.content | candidates[0].content.parts[0].text |
Initialization in main.go
The AI service is created in main.go and passed to the AI handler. If no API key is configured, the handler gracefully returns a 503 "AI service not configured" response.
// Initialize AI service (optional -- graceful if not configured)var aiService *ai.AIif cfg.AIProvider != "" && cfg.AIAPIKey != "" {aiService = ai.New(cfg.AIProvider, cfg.AIAPIKey, cfg.AIModel)log.Printf("AI service initialized: %s (%s)", cfg.AIProvider, cfg.AIModel)}// Register AI routesaiHandler := &handlers.AIHandler{AI: aiService}aiGroup := api.Group("/ai", authMiddleware){aiGroup.POST("/complete", aiHandler.Complete)aiGroup.POST("/chat", aiHandler.Chat)aiGroup.POST("/stream", aiHandler.Stream)}