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 . func (OpenAIAdapter) Headers(apiKey string) http.Header { return http.Header{"Authorization": {fetchguard.Bearer(apiKey)}} } // ChatEndpoint is /chat/completions. func (OpenAIAdapter) ChatEndpoint(baseURL string) string { return endpoint(baseURL, OpenAIBaseURL, "/chat/completions") } // FilesEndpoint is /files. func (OpenAIAdapter) FilesEndpoint(baseURL string) string { return endpoint(baseURL, OpenAIBaseURL, "/files") } // ImageEndpoint is /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 . 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 }