@@ -3,31 +3,109 @@ package main
33import (
44 "context"
55 "fmt"
6+ "os"
67 "strings"
78
89 tea "github.com/charmbracelet/bubbletea"
10+ anthropic "github.com/anthropics/anthropic-sdk-go"
11+ anthopt "github.com/anthropics/anthropic-sdk-go/option"
912 openai "github.com/openai/openai-go"
10- "github.com/openai/openai-go/option"
13+ openaiopt "github.com/openai/openai-go/option"
1114)
1215
1316// SummaryCommand represents a command's description and its captured output
1417// used for generating an LLM summary.
1518type SummaryCommand struct {
16- Description string
19+ Description * PlaybookCommand
1720 Output string
1821}
1922
2023// Summarizer encapsulates LLM client configuration used for summarization.
2124type Summarizer struct {
22- client openai.Client
23- model string
25+ provider string
26+ openaiClient openai.Client
27+ anthropicClient anthropic.Client
28+ model string
29+ disabled bool
2430}
2531
26- // NewSummarizer constructs a Summarizer with sensible defaults.
32+ // NewSummarizer constructs a Summarizer with provider selection based on env vars.
33+ // Priority:
34+ // - If ANTHROPIC_API_KEY is set, use Anthropic (claude-sonnet-4-0)
35+ // - Else if OPENROUTER_API_KEY is set, use OpenRouter base and that key
36+ // - Else if OPENAI_API_KEY starts with "sk-or-v1-", treat it as an OpenRouter key
37+ // - Else if OPENAI_API_KEY is set, use default OpenAI base and that key
38+ // Base URL can be overridden via OPENAI_BASE_URL for OpenAI/OpenRouter.
2739func NewSummarizer () * Summarizer {
40+ baseOverride := os .Getenv ("OPENAI_BASE_URL" )
41+ openRouterKey := os .Getenv ("OPENROUTER_API_KEY" )
42+ openAIKey := os .Getenv ("OPENAI_API_KEY" )
43+ anthropicKey := os .Getenv ("ANTHROPIC_API_KEY" )
44+
45+ // Heuristic: detect OpenRouter key provided via OPENAI_API_KEY
46+ if openRouterKey == "" && strings .HasPrefix (openAIKey , "sk-or-v1-" ) {
47+ openRouterKey = openAIKey
48+ }
49+
50+ // If no key is provided for any provider, mark summarizer as disabled.
51+ if strings .TrimSpace (anthropicKey ) == "" && strings .TrimSpace (openRouterKey ) == "" && strings .TrimSpace (openAIKey ) == "" {
52+ return & Summarizer {
53+ provider : "none" ,
54+ model : "" ,
55+ disabled : true ,
56+ }
57+ }
58+
59+ // Anthropic has highest priority if explicitly provided
60+ if strings .TrimSpace (anthropicKey ) != "" {
61+ cli := anthropic .NewClient (anthopt .WithAPIKey (anthropicKey ))
62+ return & Summarizer {
63+ provider : "anthropic" ,
64+ anthropicClient : cli ,
65+ model : "claude-sonnet-4-0" ,
66+ disabled : false ,
67+ }
68+ }
69+
70+ usingOpenRouter := openRouterKey != ""
71+
72+ // Determine base URL for OpenAI/OpenRouter
73+ baseURL := ""
74+ if baseOverride != "" {
75+ baseURL = baseOverride
76+ } else if usingOpenRouter {
77+ baseURL = "https://openrouter.ai/api/v1"
78+ }
79+
80+ // Build OpenAI client options
81+ var opts []openaiopt.RequestOption
82+ if baseURL != "" {
83+ opts = append (opts , openaiopt .WithBaseURL (baseURL ))
84+ }
85+ if usingOpenRouter {
86+ opts = append (opts , openaiopt .WithAPIKey (openRouterKey ))
87+ // OpenRouter attribution headers
88+ opts = append (opts ,
89+ openaiopt .WithHeader ("X-Title" , "gradient-engineer" ),
90+ openaiopt .WithHeader ("HTTP-Referer" , "https://github.com/QuesmaOrg/gradient-engineer" ),
91+ )
92+ } else if openAIKey != "" {
93+ opts = append (opts , openaiopt .WithAPIKey (openAIKey ))
94+ }
95+
96+ cli := openai .NewClient (opts ... )
97+
98+ // Choose a model slug compatible with provider
99+ model := "gpt-4.1"
100+ if usingOpenRouter {
101+ model = "openai/gpt-4.1"
102+ }
103+
28104 return & Summarizer {
29- client : openai .NewClient (option .WithBaseURL ("https://openrouter.ai/api/v1" )),
30- model : "openai/gpt-4.1" ,
105+ provider : "openai" ,
106+ openaiClient : cli ,
107+ model : model ,
108+ disabled : false ,
31109 }
32110}
33111
@@ -43,13 +121,43 @@ func (s *Summarizer) Summarize(systemPrompt string, commands []SummaryCommand) (
43121 if strings .TrimSpace (c .Output ) == "" {
44122 continue
45123 }
46- b .WriteString (fmt .Sprintf ("Command %d: %s\n " , i + 1 , c .Description ))
124+ desc := ""
125+ if c .Description != nil {
126+ desc = c .Description .Description
127+ }
128+ b .WriteString (fmt .Sprintf ("Command %d: %s\n " , i + 1 , desc ))
47129 b .WriteString (c .Output )
48130 b .WriteString ("\n \n " )
49131 }
50132 userContent := b .String ()
51133
52- resp , err := s .client .Chat .Completions .New (ctx , openai.ChatCompletionNewParams {
134+ if s .provider == "anthropic" {
135+ // Anthropic Messages API
136+ msg , err := s .anthropicClient .Messages .New (ctx , anthropic.MessageNewParams {
137+ Model : anthropic .Model (s .model ),
138+ MaxTokens : 4096 ,
139+ System : []anthropic.TextBlockParam {
140+ {Text : systemPrompt },
141+ },
142+ Messages : []anthropic.MessageParam {
143+ anthropic .NewUserMessage (anthropic .NewTextBlock (userContent )),
144+ },
145+ })
146+ if err != nil {
147+ return "" , err
148+ }
149+ // Concatenate text blocks
150+ var out strings.Builder
151+ for _ , c := range msg .Content {
152+ if c .Type == "text" {
153+ out .WriteString (c .Text )
154+ }
155+ }
156+ return out .String (), nil
157+ }
158+
159+ // OpenAI/OpenRouter path
160+ resp , err := s .openaiClient .Chat .Completions .New (ctx , openai.ChatCompletionNewParams {
53161 Model : s .model ,
54162 Messages : []openai.ChatCompletionMessageParamUnion {
55163 openai .SystemMessage (systemPrompt ),
0 commit comments