Initial commit on Forgejo
Fresh repository history for elektrine/tarakan-client hosted at https://git.elektrine.com/elektrine/tarakan-client.
This commit is contained in:
commit
58b29a7f84
70 changed files with 12994 additions and 0 deletions
308
internal/agent/http.go
Normal file
308
internal/agent/http.go
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
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 ""
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue