Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
308 lines
8.2 KiB
Go
308 lines
8.2 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
defaultOllamaHost = "http://localhost:11434"
|
|
defaultOllamaModel = "llama3.1"
|
|
openRouterBaseURL = "https://openrouter.ai/api/v1"
|
|
defaultOpenRouterModel = "openai/gpt-4o-mini"
|
|
// Moonshot / Kimi OpenAI-compatible API (used when the kimi CLI is absent).
|
|
moonshotBaseURL = "https://api.moonshot.ai/v1"
|
|
defaultMoonshotModel = "kimi-k2.5"
|
|
|
|
// A single repository bundle is bounded so it stays within a model's
|
|
// context window and a review call stays affordable.
|
|
maxFileBytes = 64 << 10
|
|
maxTotalBytes = 384 << 10
|
|
)
|
|
|
|
// getenv abstracts os.Getenv so detection is testable.
|
|
type getenv func(string) string
|
|
|
|
// detectHTTPProviders returns the configured OpenAI-compatible model endpoints.
|
|
// Ollama appears when its daemon is plausibly present (the CLI is installed or
|
|
// a host is configured); OpenRouter appears when an API key is set.
|
|
func detectHTTPProviders(env getenv) []Provider {
|
|
var providers []Provider
|
|
|
|
if ollamaConfigured(env) {
|
|
providers = append(providers, Provider{
|
|
Name: "ollama",
|
|
Kind: KindHTTP,
|
|
Description: "Ollama (local)",
|
|
BaseURL: strings.TrimRight(firstNonEmpty(env("OLLAMA_HOST"), defaultOllamaHost), "/") + "/v1",
|
|
Model: firstNonEmpty(env("OLLAMA_MODEL"), defaultOllamaModel),
|
|
})
|
|
}
|
|
|
|
if env("OPENROUTER_API_KEY") != "" {
|
|
providers = append(providers, Provider{
|
|
Name: "openrouter",
|
|
Kind: KindHTTP,
|
|
Description: "OpenRouter",
|
|
BaseURL: openRouterBaseURL,
|
|
Model: firstNonEmpty(env("OPENROUTER_MODEL"), defaultOpenRouterModel),
|
|
APIKeyEnv: "OPENROUTER_API_KEY",
|
|
})
|
|
}
|
|
|
|
// Prefer the kimi CLI when installed; fall back to the Moonshot HTTP API.
|
|
if env("MOONSHOT_API_KEY") != "" || env("KIMI_API_KEY") != "" {
|
|
apiKeyEnv := "MOONSHOT_API_KEY"
|
|
if env("MOONSHOT_API_KEY") == "" {
|
|
apiKeyEnv = "KIMI_API_KEY"
|
|
}
|
|
providers = append(providers, Provider{
|
|
Name: "kimi-http",
|
|
Kind: KindHTTP,
|
|
Description: "Kimi (Moonshot API)",
|
|
BaseURL: strings.TrimRight(firstNonEmpty(env("MOONSHOT_BASE_URL"), moonshotBaseURL), "/"),
|
|
Model: firstNonEmpty(env("MOONSHOT_MODEL"), env("KIMI_MODEL"), defaultMoonshotModel),
|
|
APIKeyEnv: apiKeyEnv,
|
|
})
|
|
}
|
|
|
|
return providers
|
|
}
|
|
|
|
func ollamaConfigured(env getenv) bool {
|
|
if env("OLLAMA_HOST") != "" || env("OLLAMA_MODEL") != "" {
|
|
return true
|
|
}
|
|
_, err := lookPath("ollama")
|
|
return err == nil
|
|
}
|
|
|
|
// chatMessage, chatRequest, and chatResponse cover the subset of the OpenAI
|
|
// chat-completions schema that Ollama and OpenRouter both implement.
|
|
type chatMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type chatRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []chatMessage `json:"messages"`
|
|
Stream bool `json:"stream"`
|
|
}
|
|
|
|
type chatResponse struct {
|
|
Choices []struct {
|
|
Message chatMessage `json:"message"`
|
|
} `json:"choices"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
func runHTTP(ctx context.Context, provider Provider, request Request) (string, error) {
|
|
if provider.Model == "" {
|
|
return "", fmt.Errorf("%s: no model configured", provider.Description)
|
|
}
|
|
|
|
apiKey := ""
|
|
if provider.APIKeyEnv != "" {
|
|
if apiKey = os.Getenv(provider.APIKeyEnv); apiKey == "" {
|
|
return "", fmt.Errorf("%s: set %s", provider.Description, provider.APIKeyEnv)
|
|
}
|
|
}
|
|
|
|
if request.Progress != nil {
|
|
// HTTP backends pack the tree into the prompt (no per-tool stream).
|
|
request.Progress("→ Packing repository context for " + provider.Description)
|
|
}
|
|
bundle, err := gatherRepositoryContext(request.Directory)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read repository for review: %w", err)
|
|
}
|
|
|
|
if request.Progress != nil {
|
|
request.Progress("→ Calling " + provider.Description + " (" + provider.Model + ")…")
|
|
request.Progress("… waiting for model (HTTP providers do not stream file reads)")
|
|
}
|
|
payload := chatRequest{
|
|
Model: provider.Model,
|
|
Stream: false,
|
|
Messages: []chatMessage{
|
|
{Role: "system", Content: reviewInstruction},
|
|
{Role: "user", Content: bundle + "\n\nUser request:\n" + request.Prompt},
|
|
},
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
endpoint := strings.TrimRight(provider.BaseURL, "/") + "/chat/completions"
|
|
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
httpRequest.Header.Set("Content-Type", "application/json")
|
|
if apiKey != "" {
|
|
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
|
|
// OpenRouter attributes traffic by these headers; harmless elsewhere.
|
|
httpRequest.Header.Set("X-Title", "Tarakan")
|
|
httpRequest.Header.Set("HTTP-Referer", "https://tarakan.lol")
|
|
}
|
|
|
|
response, err := http.DefaultClient.Do(httpRequest)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s request failed: %w", provider.Description, err)
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 8<<20))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var decoded chatResponse
|
|
_ = json.Unmarshal(responseBody, &decoded)
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return "", fmt.Errorf("%s returned %s: %s", provider.Description, response.Status, errorDetail(decoded, responseBody))
|
|
}
|
|
if decoded.Error != nil && decoded.Error.Message != "" {
|
|
return "", fmt.Errorf("%s error: %s", provider.Description, decoded.Error.Message)
|
|
}
|
|
if len(decoded.Choices) == 0 {
|
|
return "", fmt.Errorf("%s returned no content", provider.Description)
|
|
}
|
|
|
|
return strings.TrimSpace(decoded.Choices[0].Message.Content), nil
|
|
}
|
|
|
|
func errorDetail(decoded chatResponse, raw []byte) string {
|
|
if decoded.Error != nil && decoded.Error.Message != "" {
|
|
return decoded.Error.Message
|
|
}
|
|
detail := strings.TrimSpace(string(raw))
|
|
if len(detail) > 500 {
|
|
detail = detail[:500] + "…"
|
|
}
|
|
return detail
|
|
}
|
|
|
|
// gatherRepositoryContext packs the repository's source files into one text
|
|
// bundle for a model that cannot read the disk itself. Binary, oversized, and
|
|
// vendored files are skipped, and the total is bounded; when the budget runs
|
|
// out the bundle is truncated with a note rather than failing.
|
|
func gatherRepositoryContext(root string) (string, error) {
|
|
if root == "" {
|
|
return "", fmt.Errorf("no repository directory")
|
|
}
|
|
|
|
var files []string
|
|
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return nil
|
|
}
|
|
if entry.IsDir() {
|
|
if skipDir(entry.Name()) && path != root {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !entry.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
files = append(files, path)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
sort.Strings(files)
|
|
|
|
var builder strings.Builder
|
|
total := 0
|
|
included := 0
|
|
truncated := false
|
|
|
|
for _, path := range files {
|
|
if total >= maxTotalBytes {
|
|
truncated = true
|
|
break
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil || info.Size() == 0 || info.Size() > maxFileBytes {
|
|
continue
|
|
}
|
|
|
|
content, err := os.ReadFile(path)
|
|
if err != nil || isBinary(content) {
|
|
continue
|
|
}
|
|
|
|
if total+len(content) > maxTotalBytes {
|
|
truncated = true
|
|
break
|
|
}
|
|
|
|
relative, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
relative = path
|
|
}
|
|
|
|
fmt.Fprintf(&builder, "=== %s ===\n%s\n\n", filepath.ToSlash(relative), content)
|
|
total += len(content)
|
|
included++
|
|
}
|
|
|
|
if included == 0 {
|
|
return "Repository source (no readable text files found):", nil
|
|
}
|
|
|
|
header := fmt.Sprintf("Repository source (%d files", included)
|
|
if truncated {
|
|
header += ", truncated to fit the review budget"
|
|
}
|
|
header += "):\n\n"
|
|
|
|
return header + strings.TrimRight(builder.String(), "\n"), nil
|
|
}
|
|
|
|
func skipDir(name string) bool {
|
|
switch name {
|
|
case ".git", "node_modules", "vendor", "dist", "build", "target",
|
|
".venv", "venv", "__pycache__", ".next", ".turbo", ".cache",
|
|
"coverage", ".idea", ".vscode":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isBinary(content []byte) bool {
|
|
limit := len(content)
|
|
if limit > 8000 {
|
|
limit = 8000
|
|
}
|
|
return bytes.IndexByte(content[:limit], 0) >= 0
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|