diff --git a/openai_client.go b/openai_client.go index cce9251..7b178aa 100644 --- a/openai_client.go +++ b/openai_client.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "strings" + "time" "github.com/sirupsen/logrus" ) @@ -90,20 +91,56 @@ func (llm *OpenAIClient) openAICompletion(ctx context.Context, prompt string, fo if apiURL == "" { apiURL = "https://api.openai.com/v1/chat/completions" } + + isOpenAIStyle := strings.Contains(apiURL, "openrouter.ai") || strings.Contains(apiURL, "/v1/") + // Helper to stringify the expected JSON schema for instructions schemaDesc := func() string { b, _ := json.MarshalIndent(format, "", " ") return string(b) } - body := map[string]interface{}{ - "model": llm.Model, - "messages": []map[string]string{ - {"role": "system", "content": "You are a strict JSON generator. ONLY output valid JSON matching this schema: " + schemaDesc() + " Do not add explanations."}, - {"role": "user", "content": prompt}, - }, - "response_format": map[string]interface{}{"type": "json_object"}, + + truncate := func(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." } + + buildBody := func() map[string]interface{} { + if isOpenAIStyle { + return map[string]interface{}{ + "model": llm.Model, + "messages": []map[string]string{ + {"role": "system", "content": "You are a strict JSON generator. ONLY output valid JSON matching this schema: " + schemaDesc() + " Do not add explanations."}, + {"role": "user", "content": prompt}, + }, + "response_format": map[string]interface{}{"type": "json_object"}, + } + } + // This should never be reached in OpenAI client but keeping for safety + return map[string]interface{}{ + "model": llm.Model, + "messages": []map[string]string{{"role": "user", "content": prompt}}, + "stream": false, + "format": format, + } + } + + body := buildBody() + + // Enhanced logging similar to the unified client jsonBody, _ := json.Marshal(body) + bodySize := len(jsonBody) + logrus.WithFields(logrus.Fields{ + "event": "llm_request", + "api_url": apiURL, + "model": llm.Model, + "is_openai_style": isOpenAIStyle, + "prompt_len": len(prompt), + "body_size": bodySize, + }).Info("[LLM] sending request") + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonBody)) if llm.APIKey != "" { req.Header.Set("Authorization", "Bearer "+llm.APIKey) @@ -114,16 +151,39 @@ func (llm *OpenAIClient) openAICompletion(ctx context.Context, prompt string, fo req.Header.Set("Referer", "https://github.com/") req.Header.Set("X-Title", "vetrag-app") } + + start := time.Now() client := &http.Client{} resp, err := client.Do(req) + dur := time.Since(start) + if err != nil { + logrus.WithFields(logrus.Fields{ + "event": "llm_response", + "status": 0, + "latency_ms": dur.Milliseconds(), + "error": err, + }).Error("[LLM] request failed") return "", err } + defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { return "", err } + + logrus.WithFields(logrus.Fields{ + "event": "llm_raw_response", + "status": resp.StatusCode, + "latency_ms": dur.Milliseconds(), + "raw_trunc": truncate(string(raw), 600), + "raw_len": len(raw), + }).Debug("[LLM] raw response body") + + parseVariant := "unknown" + + // Attempt OpenAI/OpenRouter style parse first var openAI struct { Choices []struct { Message struct { @@ -137,18 +197,66 @@ func (llm *OpenAIClient) openAICompletion(ctx context.Context, prompt string, fo } if err := json.Unmarshal(raw, &openAI); err == nil { if openAI.Error != nil || resp.StatusCode >= 400 { + parseVariant = "openai" var msg string if openAI.Error != nil { msg = openAI.Error.Message } else { msg = string(raw) } + logrus.WithFields(logrus.Fields{ + "event": "llm_response", + "status": resp.StatusCode, + "latency_ms": dur.Milliseconds(), + "parse_variant": parseVariant, + "error": msg, + }).Error("[LLM] provider error") return "", fmt.Errorf("provider error: %s", msg) } if len(openAI.Choices) > 0 && openAI.Choices[0].Message.Content != "" { - return openAI.Choices[0].Message.Content, nil + parseVariant = "openai" + content := openAI.Choices[0].Message.Content + logrus.WithFields(logrus.Fields{ + "event": "llm_response", + "status": resp.StatusCode, + "latency_ms": dur.Milliseconds(), + "parse_variant": parseVariant, + "content_len": len(content), + "content_snip": truncate(content, 300), + }).Info("[LLM] parsed response") + return content, nil } } + + // As a fallback, attempt Ollama format parse + var ollama struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + Error string `json:"error"` + } + if err := json.Unmarshal(raw, &ollama); err == nil && ollama.Message.Content != "" { + parseVariant = "ollama" + content := ollama.Message.Content + logrus.WithFields(logrus.Fields{ + "event": "llm_response", + "status": resp.StatusCode, + "latency_ms": dur.Milliseconds(), + "parse_variant": parseVariant, + "content_len": len(content), + "content_snip": truncate(content, 300), + }).Info("[LLM] parsed response") + return content, nil + } + + logrus.WithFields(logrus.Fields{ + "event": "llm_response", + "status": resp.StatusCode, + "latency_ms": dur.Milliseconds(), + "parse_variant": parseVariant, + "raw_snip": truncate(string(raw), 300), + }).Error("[LLM] unrecognized response format") + return "", fmt.Errorf("unrecognized LLM response format: %.200s", string(raw)) }