Files
Jakub Zych cd3f0f6c1c feat: golem15.golem plugin with the OpenAI-compatible adapter and AIService.Send
- Prompt, Payload and AIResponse value objects ported from Golem15.Golem
- OpenAIAdapter: chat completions mapping with PHP's response_format,
  max_completion_tokens and reasoning_effort rules
- AIService.Send through fetchguard (TrustedMode for operator models,
  PublicOnlyMode otherwise) with PHP's failure messages
- config defaults for the SSRF allowlist and the AI timeout, lang, README
2026-10-03 22:41:49 +02:00

108 lines
3.6 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"
)
// OpenAIAdapter is the OpenAI-compatible chat completions mapping (PHP
// OpenAIAdapter). It also serves compatible providers (Groq, Together,
// local model servers) through their base URL.
type OpenAIAdapter struct{}
var _ Adapter = OpenAIAdapter{}
// reasoningModel matches the o-series and gpt-5 model ids, which spend
// completion tokens on hidden reasoning.
var reasoningModel = regexp.MustCompile(`(?i)^(o\d|gpt-5)`)
// Headers is Authorization: Bearer <key>.
func (OpenAIAdapter) Headers(apiKey string) http.Header {
return http.Header{"Authorization": {fetchguard.Bearer(apiKey)}}
}
// ChatEndpoint is <base>/chat/completions.
func (OpenAIAdapter) ChatEndpoint(baseURL string) string {
return endpoint(baseURL, OpenAIBaseURL, "/chat/completions")
}
// FilesEndpoint is <base>/files.
func (OpenAIAdapter) FilesEndpoint(baseURL string) string {
return endpoint(baseURL, OpenAIBaseURL, "/files")
}
// ImageEndpoint is <base>/images/generations.
func (OpenAIAdapter) ImageEndpoint(baseURL string) string {
return endpoint(baseURL, OpenAIBaseURL, "/images/generations")
}
// BuildChatPayload is the prompt's chat completions body with three
// adjustments PHP makes: response_format is dropped (recognition asks for
// JSON in the prompt instead), a max_tokens option becomes
// max_completion_tokens (an explicit max_completion_tokens wins), and an
// o-series or gpt-5 model gets reasoning_effort low unless the prompt set
// one.
func (OpenAIAdapter) BuildChatPayload(prompt valueobjects.Prompt, modelID string) *valueobjects.Payload {
payload := prompt.ToAPIPayload(modelID)
payload.Delete("response_format")
if payload.IsSet("max_tokens") || payload.IsSet("max_completion_tokens") {
v, _ := payload.Get("max_completion_tokens")
if v == nil {
v, _ = payload.Get("max_tokens")
}
payload.Set("max_completion_tokens", v)
payload.Delete("max_tokens")
}
if reasoningModel.MatchString(modelID) && !payload.IsSet("reasoning_effort") {
payload.Set("reasoning_effort", "low")
}
return payload
}
// FileUploadFields is the purpose field.
func (OpenAIAdapter) FileUploadFields(purpose string) []fetchguard.FormField {
return []fetchguard.FormField{{Name: "purpose", Value: purpose}}
}
// FileUploadHeaders is Authorization: Bearer <key>.
func (OpenAIAdapter) FileUploadHeaders(apiKey string) http.Header {
return http.Header{"Authorization": {fetchguard.Bearer(apiKey)}}
}
// ParseChatResponse reads choices[0].message.content; an error object is a
// failure carrying the provider's message and the whole body.
func (OpenAIAdapter) ParseChatResponse(data map[string]any) valueobjects.AIResponse {
if msg, ok := errorMessage(data); ok {
return valueobjects.Failure(msg, data)
}
content := dig(data, "choices", 0, "message", "content")
if content == nil {
return valueobjects.Failure("No content in AI response.", data)
}
usage, _ := data["usage"].(map[string]any)
return valueobjects.Success(phpString(content), usage, data)
}
// ParseStreamChunk reads a `data: {...}` line's choices[0].delta.content;
// `data: [DONE]` ends the stream.
func (OpenAIAdapter) ParseStreamChunk(line string) (string, bool) {
payload, ok := strings.CutPrefix(line, "data: ")
if !ok {
return "", false
}
if payload == "[DONE]" {
return "", true
}
var v map[string]any
if json.Unmarshal([]byte(payload), &v) != nil || len(v) == 0 {
return "", false
}
delta, _ := dig(v, "choices", 0, "delta", "content").(string)
return delta, false
}