Files
homelab/review-bot/models.go
claude-botandClaude Opus 5.5 16d957dff1
review-bot queued
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/review-bot-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
feat(review-bot): stream Mistral, add an Anthropic backend and a TypeSafe model selector
Mistral cut every POST /conversations off with HTTP 502 exactly ten minutes
after it started (about 30 times in the last 7 days), and every retry restarted
the whole review, so a review needing more than ten minutes could never finish.

- Mistral: the conversation now streams (stream=true, SSE). A stream that
  breaks off after the conversation started is looked up in the stored history
  (store=true, GET /conversations/{id}/history) before it is asked again. A
  broken stream is retryable like 429/502/503/504; a
  conversation.response.error event is not. One agent per model, named
  <AGENT_NAME>-<model>.
- Anthropic: new backend on the Messages API (Go SDK, streaming, adaptive
  thinking, effort high). Anthropic connects to the Gitea MCP server
  (gitea-mcp.renner.uno/mcp) with a read-only token; the toolset is in
  allowlist mode with only the read tools enabled. Paused turns are resumed,
  refusals use the server-side fallback on Opus/Sonnet 5.5.
- Models: MODELS lists backend:model = description, the first is the default.
  A backend whose keys are missing is switched off with a warning instead of
  stopping the bot.
- Selector: SELECTOR=typesafe asks TypeSafe (Jev, Choice) which model reviews
  each PR, from the title, description, size and changed files. A failure or
  an unusable choice falls back to the default model.
- Secrets: the Anthropic and TypeSafe keys reuse the wiki keys
  (apps/ai/wiki-anthropic, apps/ai/wiki-typesafe), like the Mistral key does.
  The MCP token is a second review-bot PAT scoped read:repository,read:issue,
  read:user, minted by a new rotate-mint-mcp step into
  infrastructure/review-bot#mcp_token (seeded with a placeholder). Each
  optional key has its own ExternalSecret and optional env ref.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:08:22 +02:00

62 lines
1.8 KiB
Go

package main
import (
"fmt"
"strings"
)
// Model is one model the bot can review with, on one backend.
type Model struct {
Backend string // "mistral" or "anthropic"
Name string // the provider's model id
Description string // what it is good for; the selector reads it
}
func (m Model) ID() string { return m.Backend + ":" + m.Name }
var backends = []string{"mistral", "anthropic"}
// parseModels reads one model per line, "backend:model = description", the description being
// optional. Blank lines and lines starting with # are skipped. The first model is the default.
func parseModels(text string) ([]Model, error) {
var out []Model
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
id, desc, _ := strings.Cut(line, "=")
backend, name, ok := strings.Cut(strings.TrimSpace(id), ":")
backend, name = strings.TrimSpace(backend), strings.TrimSpace(name)
if !ok || name == "" {
return nil, fmt.Errorf("MODELS line %q is not backend:model", line)
}
known := false
for _, b := range backends {
known = known || b == backend
}
if !known {
return nil, fmt.Errorf("MODELS line %q: unknown backend %q (want one of %s)", line, backend, strings.Join(backends, ", "))
}
m := Model{Backend: backend, Name: name, Description: strings.TrimSpace(desc)}
if seen[m.ID()] {
return nil, fmt.Errorf("MODELS lists %s twice", m.ID())
}
seen[m.ID()] = true
out = append(out, m)
}
return out, nil
}
// modelsOf lists the model names of one backend.
func modelsOf(models []Model, backend string) []string {
var out []string
for _, m := range models {
if m.Backend == backend {
out = append(out, m.Name)
}
}
return out
}