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>
62 lines
1.8 KiB
Go
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
|
|
}
|