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>
138 lines
5.0 KiB
Go
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()))
|
|
}
|