Files
sm-golem-plugin/classes/providers/anthropic.go
Jakub Zych c93e2d9142 feat: AI models admin, settings import, Anthropic adapter and the rest of AIService
- golem15_golem_models (encrypted write-only api_key) with its cabana list
  and form under golem15.golem.access_settings, and the Settings lookups
  (Models, DefaultModel with PHP's position-0 quirk, Vision, Image, File,
  ByName), every one Trusted
- golem:import-settings copies the WinterCMS golem_settings repeater once
- AnthropicAdapter; SendStream, SendFile/SendFilePath/SendFileReader,
  SendWithModel, SendToImageModel, SendToVisionModel, GenerateImage, Ask
- PromptFactory; README documents the config keys, the environment
  variable, the command and the admin screen
2026-10-03 23:09:02 +02:00

196 lines
5.8 KiB
Go

package providers
import (
"encoding/json"
"net/http"
"regexp"
"strings"
"git.golem15.com/golem15/sm-golem-plugin/classes/valueobjects"
"git.golem15.com/golem15/summercms/modules/fetchguard"
)
// Anthropic API headers PHP sends on every request.
const (
AnthropicVersion = "2023-06-01"
AnthropicBeta = "files-api-2025-04-14"
)
// AnthropicAdapter is the Anthropic Messages API mapping (PHP
// AnthropicAdapter).
type AnthropicAdapter struct{}
var _ Adapter = AnthropicAdapter{}
var dataURL = regexp.MustCompile(`^data:([^;]+);base64,(.+)$`)
func anthropicHeaders(apiKey string) http.Header {
return http.Header{
"X-Api-Key": {apiKey},
"Anthropic-Version": {AnthropicVersion},
"Anthropic-Beta": {AnthropicBeta},
}
}
// Headers are x-api-key, anthropic-version and anthropic-beta.
func (AnthropicAdapter) Headers(apiKey string) http.Header { return anthropicHeaders(apiKey) }
// ChatEndpoint is <base>/messages.
func (AnthropicAdapter) ChatEndpoint(baseURL string) string {
return endpoint(baseURL, AnthropicBaseURL, "/messages")
}
// FilesEndpoint is <base>/files.
func (AnthropicAdapter) FilesEndpoint(baseURL string) string {
return endpoint(baseURL, AnthropicBaseURL, "/files")
}
// ImageEndpoint is "": Anthropic generates no images.
func (AnthropicAdapter) ImageEndpoint(string) string { return "" }
// BuildChatPayload is the Messages body: model (the prompt's override or
// modelID), the messages with image_url blocks rewritten to image blocks
// (base64 for a data: URL, url otherwise) and file blocks to document
// blocks, the system prompt when set, max_tokens (the max_tokens option,
// else max_completion_tokens, else 4096) and every other option except
// response_format and max_completion_tokens.
func (AnthropicAdapter) BuildChatPayload(prompt valueobjects.Prompt, modelID string) *valueobjects.Payload {
msgs := prompt.Messages()
out := make([]map[string]any, 0, len(msgs))
for _, m := range msgs {
out = append(out, map[string]any{"role": m.Role, "content": anthropicContent(m)})
}
payload := valueobjects.NewPayload()
model := modelID
if p := prompt.Model(); p != nil {
model = *p
}
payload.Set("model", model)
payload.Set("messages", out)
if s := prompt.SystemPrompt(); s != "" {
payload.Set("system", s)
}
opts := prompt.Options()
var maxTokens any = 4096
if opts.IsSet("max_tokens") {
maxTokens, _ = opts.Get("max_tokens")
} else if opts.IsSet("max_completion_tokens") {
maxTokens, _ = opts.Get("max_completion_tokens")
}
payload.Set("max_tokens", maxTokens)
for _, k := range opts.Keys() {
if k == "response_format" || k == "max_completion_tokens" {
continue
}
v, _ := opts.Get(k)
payload.Set(k, v)
}
return payload
}
func anthropicContent(m valueobjects.Message) any {
if m.Parts == nil {
return m.Text
}
blocks := make([]any, 0, len(m.Parts))
for _, part := range m.Parts {
switch part.Type {
case "image_url":
u := ""
if part.ImageURL != nil {
u = part.ImageURL.URL
}
if strings.HasPrefix(u, "data:") {
media, data := "image/jpeg", ""
if mm := dataURL.FindStringSubmatch(u); mm != nil {
media, data = mm[1], mm[2]
}
blocks = append(blocks, map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": media, "data": data}})
} else {
blocks = append(blocks, map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": u}})
}
case "file":
id := ""
if part.File != nil {
id = part.File.FileID
}
blocks = append(blocks, map[string]any{"type": "document", "source": map[string]any{"type": "file", "file_id": id}})
default:
blocks = append(blocks, part)
}
}
return blocks
}
// FileUploadFields is empty: the Anthropic files API takes the file only.
func (AnthropicAdapter) FileUploadFields(string) []fetchguard.FormField { return nil }
// FileUploadHeaders are the Messages headers.
func (AnthropicAdapter) FileUploadHeaders(apiKey string) http.Header {
return anthropicHeaders(apiKey)
}
// ParseChatResponse reads content[0].text; usage gains PHP's
// prompt_tokens, completion_tokens and total_tokens next to Anthropic's
// input_tokens and output_tokens; Raw keeps the whole answer, stop_reason
// included.
func (AnthropicAdapter) ParseChatResponse(data map[string]any) valueobjects.AIResponse {
if msg, ok := errorMessage(data); ok {
return valueobjects.Failure(msg, data)
}
content := dig(data, "content", 0, "text")
if content == nil {
return valueobjects.Failure("No content in AI response.", data)
}
usage := map[string]any{}
if u, ok := data["usage"].(map[string]any); ok {
for k, v := range u {
usage[k] = v
}
}
if in, ok := usage["input_tokens"]; ok && in != nil {
prompt := number(in)
completion := number(usage["output_tokens"])
usage["prompt_tokens"] = in
if usage["output_tokens"] == nil {
usage["completion_tokens"] = 0
} else {
usage["completion_tokens"] = usage["output_tokens"]
}
usage["total_tokens"] = prompt + completion
}
return valueobjects.Success(phpString(content), usage, data)
}
func number(v any) float64 {
if f, ok := v.(float64); ok {
return f
}
return 0
}
// ParseStreamChunk reads Anthropic's event stream: `event: message_stop`
// ends it, a `data:` line of type content_block_delta carries the text
// delta, anything else is skipped.
func (AnthropicAdapter) ParseStreamChunk(line string) (string, bool) {
if line == "" {
return "", false
}
if event, ok := strings.CutPrefix(line, "event: "); ok {
return "", event == "message_stop"
}
payload, ok := strings.CutPrefix(line, "data: ")
if !ok {
return "", false
}
var v map[string]any
if json.Unmarshal([]byte(payload), &v) != nil || len(v) == 0 {
return "", false
}
if v["type"] != "content_block_delta" {
return "", false
}
text, _ := dig(v, "delta", "text").(string)
return text, false
}