- 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
108 lines
3.6 KiB
Go
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
|
|
}
|