From b9d90ccba2af6002603f900eaffa978f01517415 Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 26 Feb 2025 12:50:28 +0300 Subject: [PATCH] Add OpenRouter insights integration --- internal/adapters/insights/gemini.go | 4 +- internal/adapters/insights/openrouter.go | 126 +++++++++++++++++++++ internal/api/swagger.yaml | 2 +- internal/config/config.go | 50 ++++---- main.go | 3 + web/src/features/insights-builder/model.ts | 2 +- 6 files changed, 162 insertions(+), 25 deletions(-) create mode 100644 internal/adapters/insights/openrouter.go diff --git a/internal/adapters/insights/gemini.go b/internal/adapters/insights/gemini.go index 848bb69..faa1771 100644 --- a/internal/adapters/insights/gemini.go +++ b/internal/adapters/insights/gemini.go @@ -12,7 +12,7 @@ import ( // TODO: Consider if this should be customizable const geminiModel = "gemini-1.5-flash" -const baseURI = "https://generativelanguage.googleapis.com/v1beta/models/" +const geminiURI = "https://generativelanguage.googleapis.com/v1beta/models/" const action = "generateContent" const requestMethod = http.MethodPost const contentTypeKey = "Content-Type" @@ -42,7 +42,7 @@ type GeminiCandidate struct { } func (g *GeminiInsightsAdapter) GenerateContent(ctx context.Context, prompt string, httpClient *http.Client) (string, error) { - req, err := g.buildRequest(ctx, baseURI, geminiModel, g.token, action, requestMethod, contentTypeKey, contentTypeValue, prompt) + req, err := g.buildRequest(ctx, geminiURI, geminiModel, g.token, action, requestMethod, contentTypeKey, contentTypeValue, prompt) if err != nil { return "", fmt.Errorf("preparing request: %w", err) } diff --git a/internal/adapters/insights/openrouter.go b/internal/adapters/insights/openrouter.go new file mode 100644 index 0000000..fe5733f --- /dev/null +++ b/internal/adapters/insights/openrouter.go @@ -0,0 +1,126 @@ +package insights + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "moonlogs/internal/lib/serialize" + "net/http" + "net/url" + "strings" +) + +const openRouterUri = "https://openrouter.ai/api/v1/chat/completions" +const acceptKey = "Accept" +const authKey = "Authorization" +const bearerPrefix = "Bearer " + +type OpenRouterInsightsAdapter struct { + token string + model string +} + +func NewOpenRouterInsightsAdapter(token string, model string) *OpenRouterInsightsAdapter { + return &OpenRouterInsightsAdapter{token: token, model: model} +} + +type OpenRouterResponse struct { + Choices []*OpenRouterChoice `json:"choices"` +} + +type OpenRouterChoice struct { + Message *OpenRouterContentMessage `json:"message"` +} + +type OpenRouterContentMessage struct { + Content string `json:"content"` +} + +func (a *OpenRouterInsightsAdapter) GenerateContent(ctx context.Context, prompt string, httpClient *http.Client) (string, error) { + req, err := a.buildRequest(ctx, openRouterUri, a.model, a.token, requestMethod, contentTypeKey, contentTypeValue, prompt) + if err != nil { + return "", fmt.Errorf("preparing request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading response body: %w", err) + } + + // OpenRouter returns strange responses with a lot of new lines at the beginning of response body. + // Trim leading spaces. + respStr := string(b) + respStr = strings.TrimSpace(respStr) + + var openRouterResp OpenRouterResponse + err = serialize.NewJSONDecoder(bytes.NewBufferString(respStr)).Decode(&openRouterResp) + if err != nil { + return "", fmt.Errorf("decoding open router response: %w", err) + } + + if len(openRouterResp.Choices) == 0 { + return "", errors.New("empty choices") + } + + choice := openRouterResp.Choices[0] + if choice.Message == nil { + return "", errors.New("empty choice message") + } + + content := choice.Message.Content + if len(content) == 0 { + return "", errors.New("empty choice message content") + } + + return content, nil +} + +type OpenRouterRequest struct { + Model string `json:"model"` + Messages []OpenRouterMessage `json:"messages"` +} + +type OpenRouterMessage struct { + Role string `json:"role"` + Content []OpenRouterContent `json:"content"` +} + +type OpenRouterContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +func (a *OpenRouterInsightsAdapter) buildRequest(ctx context.Context, baseURI, modelName, token, requestMethod, contentTypeKey, contentTypeValue, propmt string) (*http.Request, error) { + _, err := url.Parse(baseURI) + if err != nil { + return nil, fmt.Errorf("parsing base uri: %w", err) + } + + content := OpenRouterContent{Text: propmt, Type: "text"} + message := OpenRouterMessage{Role: "user", Content: []OpenRouterContent{content}} + payload := OpenRouterRequest{Model: modelName, Messages: []OpenRouterMessage{message}} + + jsonData, err := serialize.JSONMarshal(payload) + if err != nil { + return nil, fmt.Errorf("marshalling payload body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, requestMethod, baseURI, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("building request with timeout: %w", err) + } + + req.Header.Set(contentTypeKey, contentTypeValue) + req.Header.Set(acceptKey, contentTypeValue) + req.Header.Set(authKey, bearerPrefix+token) + + return req, nil +} diff --git a/internal/api/swagger.yaml b/internal/api/swagger.yaml index 12e5e6a..9706322 100644 --- a/internal/api/swagger.yaml +++ b/internal/api/swagger.yaml @@ -9,7 +9,7 @@ info: license: name: Apache 2.0 url: http://www.apache.org/licenses/LICENSE-2.0.html - version: 1.22.0 + version: 1.23.0 externalDocs: description: Find out more about spec url: https://moonlogs.pages.dev diff --git a/internal/config/config.go b/internal/config/config.go index 60e73ad..e4beefd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,8 @@ const ( DB_PATH = "/var/lib/moonlogs/database.sqlite" CONFIG_PATH = "/etc/moonlogs/config.yaml" GEMINI_TOKEN = "" + OPEN_ROUTER_TOKEN = "" + OPEN_ROUTER_MODEL = "" INSIGHTS_PROXY_USER = "" INSIGHTS_PROXY_PASS = "" INSIGHTS_PROXY_HOST = "" @@ -29,16 +31,18 @@ const ( var config *Config type Config struct { - Port int `yaml:"port"` - DBPath string `yaml:"db_path"` - DBAdapter string `yaml:"db_adapter"` - ReadTimeout time.Duration `yaml:"read_timeout"` - WriteTimeout time.Duration `yaml:"write_timeout"` - GeminiToken string `yaml:"gemini_token"` - ProxyUser string `yaml:"proxy_user"` - ProxyPass string `yaml:"proxy_pass"` - ProxyHost string `yaml:"proxy_host"` - ProxyPort string `yaml:"proxy_port"` + Port int `yaml:"port"` + DBPath string `yaml:"db_path"` + DBAdapter string `yaml:"db_adapter"` + ReadTimeout time.Duration `yaml:"read_timeout"` + WriteTimeout time.Duration `yaml:"write_timeout"` + GeminiToken string `yaml:"gemini_token"` + OpenRouterToken string `yaml:"open_router_token"` + OpenRouterModel string `yaml:"open_router_model"` + ProxyUser string `yaml:"insights_proxy_user"` + ProxyPass string `yaml:"insights_proxy_pass"` + ProxyHost string `yaml:"insights_proxy_host"` + ProxyPort string `yaml:"insights_proxy_port"` } func Load() (*Config, error) { @@ -106,17 +110,19 @@ write_timeout: %s } type args struct { - Config string - Port int - DBPath string - DBAdapter string - ReadTimeout time.Duration - WriteTimeout time.Duration - GeminiToken string - ProxyUser string - ProxyPass string - ProxyHost string - ProxyPort string + Config string + Port int + DBPath string + DBAdapter string + ReadTimeout time.Duration + WriteTimeout time.Duration + GeminiToken string + OpenRouterToken string + OpenRouterModel string + ProxyUser string + ProxyPass string + ProxyHost string + ProxyPort string } func processArgs() (args, error) { @@ -130,6 +136,8 @@ func processArgs() (args, error) { f.DurationVar(&a.WriteTimeout, "write-timeout", WRITE_TIMEOUT, "write timeout duration") f.DurationVar(&a.ReadTimeout, "read-timeout", READ_TIMEOUT, "read timeout duration") f.StringVar(&a.GeminiToken, "gemini-token", GEMINI_TOKEN, "token to access Gemini API") + f.StringVar(&a.OpenRouterToken, "open-router-token", OPEN_ROUTER_TOKEN, "token to access OpenRouter API") + f.StringVar(&a.OpenRouterModel, "open-router-model", OPEN_ROUTER_MODEL, "model of OpenRouter API") f.StringVar(&a.ProxyUser, "insights-proxy-user", INSIGHTS_PROXY_USER, "proxy user for insights API") f.StringVar(&a.ProxyPass, "insights-proxy-pass", INSIGHTS_PROXY_PASS, "proxy pass for insights API") f.StringVar(&a.ProxyHost, "insights-proxy-host", INSIGHTS_PROXY_HOST, "proxy host for insights API") diff --git a/main.go b/main.go index 12ca83c..dff263f 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,9 @@ func main() { if cfg.GeminiToken != "" { insightsAdapter = insights.NewGeminiInsightsAdapter(cfg.GeminiToken) } + if cfg.OpenRouterToken != "" && cfg.OpenRouterModel != "" { + insightsAdapter = insights.NewOpenRouterInsightsAdapter(cfg.OpenRouterToken, cfg.OpenRouterModel) + } proxyCfg := usecases.ProxyCfg{ ProxyUser: cfg.ProxyUser, diff --git a/web/src/features/insights-builder/model.ts b/web/src/features/insights-builder/model.ts index be39aec..b0ec0d4 100644 --- a/web/src/features/insights-builder/model.ts +++ b/web/src/features/insights-builder/model.ts @@ -130,7 +130,7 @@ const summarizeLogsAiFx = createEffect(({ lang, logs }: { lang: string; logs: Lo }) .join("\n"); - const prompt = `Write short summary of these logs in '${lang}' language, try to highlight problems if there are any error level logs, use only new lines as markup:\n\n${logsText}`; + const prompt = `Write really short summary of these logs in '${lang}' language, try to highlight problems if there are any error level logs, dont use markdown markup, only new lines:\n\n${logsText}`; return generateContent(prompt); });