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 /messages. func (AnthropicAdapter) ChatEndpoint(baseURL string) string { return endpoint(baseURL, AnthropicBaseURL, "/messages") } // FilesEndpoint is /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 }