Files
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

138 lines
5.0 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/anthropics/anthropic-sdk-go/packages/param"
"github.com/anthropics/anthropic-sdk-go/shared/constant"
)
// mcpServer is the name the Gitea MCP server has inside a request.
const mcpServer = "gitea"
// maxContinuations bounds how often a paused turn (the server-side tool loop reached its limit)
// is resumed.
const maxContinuations = 30
// fallbackModels serve a refused request with Anthropic's server-side fallback model.
var fallbackModels = []string{"claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5"}
// Anthropic runs the review on the Messages API. Anthropic connects to the Gitea MCP server
// itself, with a read-only token, and only the listed read tools are enabled.
type Anthropic struct {
client anthropic.Client
mcpURL string
mcpToken string
tools []string
effort string
}
func newAnthropic(cfg Config) *Anthropic {
opts := []option.RequestOption{option.WithAPIKey(cfg.AnthropicKey), option.WithMaxRetries(0)}
if cfg.AnthropicURL != "" {
opts = append(opts, option.WithBaseURL(cfg.AnthropicURL))
}
return &Anthropic{client: anthropic.NewClient(opts...), mcpURL: cfg.MCPURL, mcpToken: cfg.MCPToken,
tools: cfg.MCPTools, effort: cfg.AnthropicEffort}
}
// Prepare has nothing to set up: the prompt and tools travel with every request.
func (a *Anthropic) Prepare(context.Context, []string) error { return nil }
func (a *Anthropic) params(model, task string) anthropic.BetaMessageNewParams {
configs := map[string]anthropic.BetaMCPToolConfigParam{}
for _, t := range a.tools {
configs[t] = anthropic.BetaMCPToolConfigParam{Enabled: param.NewOpt(true)}
}
p := anthropic.BetaMessageNewParams{
Model: model,
MaxTokens: 64000,
System: []anthropic.BetaTextBlockParam{{Text: reviewPrompt, CacheControl: anthropic.NewBetaCacheControlEphemeralParam()}},
Messages: []anthropic.BetaMessageParam{anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock(task))},
MCPServers: []anthropic.BetaRequestMCPServerURLDefinitionParam{{
Name: mcpServer, URL: a.mcpURL, AuthorizationToken: param.NewOpt(a.mcpToken),
}},
Tools: []anthropic.BetaToolUnionParam{{OfMCPToolset: &anthropic.BetaMCPToolsetParam{
MCPServerName: mcpServer,
DefaultConfig: anthropic.BetaMCPToolDefaultConfigParam{Enabled: param.NewOpt(false)},
Configs: configs,
}}},
OutputConfig: anthropic.BetaOutputConfigParam{Effort: anthropic.BetaOutputConfigEffort(a.effort)},
Betas: []anthropic.AnthropicBeta{anthropic.AnthropicBetaMCPClient2025_11_20},
}
if slices.Contains(fallbackModels, model) {
p.Fallbacks = anthropic.BetaFallbacksParamUnion{OfDefault: constant.ValueOf[constant.Default]()}
p.Betas = append(p.Betas, anthropic.AnthropicBetaServerSideFallback2026_07_01)
}
return p
}
// Ask streams the review, resuming paused turns, and takes the text after the last tool call.
func (a *Anthropic) Ask(ctx context.Context, model, task string) (Answer, error) {
p := a.params(model, task)
var ans Answer
var texts []string
for turn := 0; ; turn++ {
stream := a.client.Beta.Messages.NewStreaming(ctx, p)
msg := anthropic.BetaMessage{}
for stream.Next() {
if err := msg.Accumulate(stream.Current()); err != nil {
_ = stream.Close()
return Answer{}, &streamError{Provider: "anthropic", Err: err}
}
}
if err := stream.Err(); err != nil {
return Answer{}, anthropicError(err)
}
for _, block := range msg.Content {
switch b := block.AsAny().(type) {
case anthropic.BetaMCPToolUseBlock:
ans.ToolCalls++
if !slices.Contains(a.tools, b.Name) {
ans.Violations = append(ans.Violations, b.Name)
}
texts = nil
case anthropic.BetaMCPToolResultBlock:
texts = nil
case anthropic.BetaTextBlock:
texts = append(texts, b.Text)
}
}
switch msg.StopReason {
case anthropic.BetaStopReasonPauseTurn:
if turn >= maxContinuations {
return Answer{}, fmt.Errorf("anthropic: the turn was still paused after %d continuations", maxContinuations)
}
p.Messages = append(p.Messages, msg.ToParam())
continue
case anthropic.BetaStopReasonRefusal:
return Answer{}, fmt.Errorf("anthropic refused the review (%s): %s", msg.StopDetails.Category, msg.StopDetails.Explanation)
case anthropic.BetaStopReasonMaxTokens:
return Answer{}, errors.New("anthropic: the answer hit max_tokens")
}
ans.Text = strings.Join(texts, "")
return ans, nil
}
}
// anthropicError makes an API error an apiError, so the retry policy sees its status, and any
// other failure of the stream a streamError.
func anthropicError(err error) error {
var api *anthropic.Error
if !errors.As(err, &api) {
return &streamError{Provider: "anthropic", Err: err}
}
resp := api.Response
if resp == nil {
resp = &http.Response{StatusCode: api.StatusCode, Header: http.Header{}}
}
return newAPIError("anthropic", http.MethodPost, "/v1/messages", resp, []byte(api.RawJSON()))
}