- 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
196 lines
5.8 KiB
Go
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
|
|
}
|