Add vendor assessment agent

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-04-22 22:36:14 +02:00
parent 25c590ffe6
commit 509d0c88b1
108 changed files with 9445 additions and 645 deletions

View File

@@ -152,6 +152,28 @@ func buildParams(req *llm.ChatCompletionRequest) (anthropic.MessageNewParams, er
if req.ToolChoice != nil {
params.ToolChoice = buildToolChoice(req.ToolChoice)
}
if req.Thinking != nil && req.Thinking.Enabled {
params.Thinking = anthropic.ThinkingConfigParamOfEnabled(int64(req.Thinking.BudgetTokens))
}
if req.ResponseFormat != nil {
switch req.ResponseFormat.Type {
case llm.ResponseFormatJSONSchema:
if req.ResponseFormat.JSONSchema == nil {
return anthropic.MessageNewParams{}, fmt.Errorf("cannot apply JSON schema output format: schema is nil")
}
var schema map[string]any
if err := json.Unmarshal(req.ResponseFormat.JSONSchema.Schema, &schema); err != nil {
return anthropic.MessageNewParams{}, fmt.Errorf("cannot unmarshal JSON schema for output format: %w", err)
}
params.OutputConfig = anthropic.OutputConfigParam{
Format: anthropic.JSONOutputFormatParam{Schema: schema},
}
case llm.ResponseFormatJSONObject:
return anthropic.MessageNewParams{}, fmt.Errorf("anthropic does not support json_object response format without a schema; use json_schema instead")
case llm.ResponseFormatText:
// default behaviour, nothing to set
}
}
return params, nil
}
@@ -194,12 +216,21 @@ func buildMessages(messages []llm.Message) []anthropic.MessageParam {
out = append(out, anthropic.NewUserMessage(blocks...))
case llm.RoleAssistant:
var blocks []anthropic.ContentBlockParamUnion
if text := msg.Text(); text != "" {
blocks = append(blocks, anthropic.NewTextBlock(text))
for _, p := range msg.Parts {
switch part := p.(type) {
case llm.ThinkingPart:
blocks = append(blocks, anthropic.NewThinkingBlock(part.Signature, part.Text))
case llm.TextPart:
if part.Text != "" {
blocks = append(blocks, anthropic.NewTextBlock(part.Text))
}
}
}
for _, tc := range msg.ToolCalls {
var input any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil || input == nil {
input = map[string]any{}
}
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, input, tc.Function.Name))
}
out = append(out, anthropic.NewAssistantMessage(blocks...))
@@ -295,6 +326,12 @@ func mapResponse(msg *anthropic.Message) *llm.ChatCompletionResponse {
for _, block := range msg.Content {
switch block.Type {
case "thinking":
tb := block.AsThinking()
resp.Message.Parts = append(resp.Message.Parts, llm.ThinkingPart{
Text: tb.Thinking,
Signature: tb.Signature,
})
case "text":
resp.Message.Parts = append(resp.Message.Parts, llm.TextPart{Text: block.Text})
case "tool_use":
@@ -326,6 +363,15 @@ func mapStopReason(reason anthropic.StopReason) llm.FinishReason {
}
func mapError(err error) error {
// The Anthropic SDK refuses non-streaming requests client-side when
// the expected response time exceeds 10 minutes (large max_tokens or
// model-specific non-streaming token limits). It returns a plain
// fmt.Errorf, not an *anthropic.Error, so we must match on the
// message before attempting the type assertion.
if err != nil && strings.Contains(err.Error(), "streaming is required") {
return &llm.ErrStreamingRequired{Err: err}
}
var apiErr *anthropic.Error
if !errors.As(err, &apiErr) {
return err
@@ -361,7 +407,9 @@ type anthropicStream struct {
stream *ssestream.Stream[anthropic.MessageStreamEventUnion]
current llm.ChatCompletionStreamEvent
// Track tool call indices for mapping content_block_start events.
toolCallIndex int
toolCallIndex int
inToolUse bool
thinkingSignature string
}
func (s *anthropicStream) Next() bool {
@@ -396,7 +444,9 @@ func (s *anthropicStream) mapStreamEvent(event *anthropic.MessageStreamEventUnio
switch event.Type {
case "content_block_start":
cb := event.ContentBlock
if cb.Type == "tool_use" {
switch cb.Type {
case "tool_use":
s.inToolUse = true
tu := cb.AsToolUse()
return llm.ChatCompletionStreamEvent{
Delta: llm.MessageDelta{
@@ -407,6 +457,8 @@ func (s *anthropicStream) mapStreamEvent(event *anthropic.MessageStreamEventUnio
}},
},
}, true
case "thinking":
return llm.ChatCompletionStreamEvent{}, false
}
return llm.ChatCompletionStreamEvent{}, false
@@ -417,6 +469,15 @@ func (s *anthropicStream) mapStreamEvent(event *anthropic.MessageStreamEventUnio
return llm.ChatCompletionStreamEvent{
Delta: llm.MessageDelta{Content: delta.Text},
}, true
case "thinking_delta":
return llm.ChatCompletionStreamEvent{
Delta: llm.MessageDelta{Thinking: delta.Thinking},
}, true
case "signature_delta":
s.thinkingSignature = delta.Signature
return llm.ChatCompletionStreamEvent{
Delta: llm.MessageDelta{ThinkingSignature: delta.Signature},
}, true
case "input_json_delta":
return llm.ChatCompletionStreamEvent{
Delta: llm.MessageDelta{
@@ -430,8 +491,9 @@ func (s *anthropicStream) mapStreamEvent(event *anthropic.MessageStreamEventUnio
return llm.ChatCompletionStreamEvent{}, false
case "content_block_stop":
if event.ContentBlock.Type == "tool_use" {
if s.inToolUse {
s.toolCallIndex++
s.inToolUse = false
}
return llm.ChatCompletionStreamEvent{}, false

View File

@@ -33,6 +33,12 @@ type (
ToolChoice *ToolChoice
ParallelToolCalls *bool
ResponseFormat *ResponseFormat
Thinking *ThinkingConfig
}
ThinkingConfig struct {
Enabled bool
BudgetTokens int
}
ToolChoiceType string
@@ -97,8 +103,10 @@ type (
}
MessageDelta struct {
Content string
ToolCalls []ToolCallDelta
Content string
Thinking string
ThinkingSignature string
ToolCalls []ToolCallDelta
}
ToolCallDelta struct {
@@ -144,13 +152,15 @@ func (u Usage) Add(other Usage) Usage {
// After the stream is exhausted (Next returns false), call Response
// to get the fully assembled ChatCompletionResponse.
type StreamAccumulator struct {
stream ChatCompletionStream
current ChatCompletionStreamEvent
content strings.Builder
toolCalls map[int]*ToolCall
usage Usage
finishReason FinishReason
model string
stream ChatCompletionStream
current ChatCompletionStreamEvent
content strings.Builder
thinking strings.Builder
thinkingSignature string
toolCalls map[int]*ToolCall
usage Usage
finishReason FinishReason
model string
}
func NewStreamAccumulator(stream ChatCompletionStream) *StreamAccumulator {
@@ -194,11 +204,20 @@ func (a *StreamAccumulator) Response() *ChatCompletionResponse {
}
}
var parts []Part
if thinking := a.thinking.String(); thinking != "" {
parts = append(parts, ThinkingPart{
Text: thinking,
Signature: a.thinkingSignature,
})
}
parts = append(parts, TextPart{Text: a.content.String()})
return &ChatCompletionResponse{
Model: a.model,
Message: Message{
Role: RoleAssistant,
Parts: []Part{TextPart{Text: a.content.String()}},
Parts: parts,
ToolCalls: toolCalls,
},
Usage: a.usage,
@@ -212,6 +231,10 @@ func (a *StreamAccumulator) accumulate(event ChatCompletionStreamEvent) {
}
a.content.WriteString(event.Delta.Content)
a.thinking.WriteString(event.Delta.Thinking)
if event.Delta.ThinkingSignature != "" {
a.thinkingSignature = event.Delta.ThinkingSignature
}
for _, tcd := range event.Delta.ToolCalls {
tc, ok := a.toolCalls[tcd.Index]

View File

@@ -37,6 +37,13 @@ type (
ErrAuthentication struct {
Err error
}
// ErrStreamingRequired is returned by a provider when a non-streaming
// request must be retried with the streaming endpoint (e.g. Anthropic
// requires streaming for responses that may take longer than 10 minutes).
ErrStreamingRequired struct {
Err error
}
)
func (e *ErrRateLimit) Error() string {
@@ -68,3 +75,9 @@ func (e *ErrAuthentication) Error() string {
}
func (e *ErrAuthentication) Unwrap() error { return e.Err }
func (e *ErrStreamingRequired) Error() string {
return fmt.Sprintf("streaming is required: %v", e.Err)
}
func (e *ErrStreamingRequired) Unwrap() error { return e.Err }

View File

@@ -52,3 +52,13 @@ func (m Message) Text() string {
}
return s.String()
}
func (m Message) Thinking() string {
var s strings.Builder
for _, p := range m.Parts {
if tp, ok := p.(ThinkingPart); ok {
s.WriteString(tp.Text)
}
}
return s.String()
}

View File

@@ -166,6 +166,16 @@ func buildParams(req *llm.ChatCompletionRequest) openai.ChatCompletionNewParams
if req.ResponseFormat != nil {
params.ResponseFormat = buildResponseFormat(req.ResponseFormat)
}
if req.Thinking != nil && req.Thinking.Enabled && isReasoningModel(req.Model) {
switch {
case req.Thinking.BudgetTokens <= 1024:
params.ReasoningEffort = shared.ReasoningEffortLow
case req.Thinking.BudgetTokens <= 8192:
params.ReasoningEffort = shared.ReasoningEffortMedium
default:
params.ReasoningEffort = shared.ReasoningEffortHigh
}
}
return params
}
@@ -456,6 +466,17 @@ func mapChunkToEvent(chunk *openai.ChatCompletionChunk) llm.ChatCompletionStream
return event
}
// isReasoningModel returns true for OpenAI models that support
// reasoning_effort (o1, o3-mini, o3, and their dated variants).
func isReasoningModel(model string) bool {
for _, prefix := range []string{"o1", "o3"} {
if model == prefix || strings.HasPrefix(model, prefix+"-") {
return true
}
}
return false
}
func buildFilePart(p llm.FilePart) openai.ChatCompletionContentPartUnionParam {
switch {
case strings.HasPrefix(p.MimeType, "image/"):

View File

@@ -32,8 +32,14 @@ type (
MimeType string // e.g. "application/pdf", "text/csv", "image/png"
Filename string
}
ThinkingPart struct {
Text string
Signature string // Anthropic thinking signature for multi-turn continuity
}
)
func (TextPart) part() {}
func (ImagePart) part() {}
func (FilePart) part() {}
func (TextPart) part() {}
func (ImagePart) part() {}
func (FilePart) part() {}
func (ThinkingPart) part() {}