iop/apps/edge/internal/openai/chat_decode.go

134 lines
5 KiB
Go

package openai
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
func decodeChatCompletionRequest(dec *json.Decoder, req *chatCompletionRequest) error {
var raw map[string]json.RawMessage
if err := dec.Decode(&raw); err != nil {
return fmt.Errorf("invalid JSON request")
}
for key := range raw {
switch key {
case "model", "messages", "stream", "metadata", "max_tokens", "max_completion_tokens", "temperature", "top_p", "presence_penalty", "frequency_penalty", "seed", "stop", "response_format", "tools", "tool_choice", "parallel_tool_calls", "stream_options", "store", "think", "reasoning_effort", "thinking_token_budget", "include_reasoning":
default:
return fmt.Errorf("%s is not supported for /v1/chat/completions", key)
}
}
normalized, err := json.Marshal(raw)
if err != nil {
return fmt.Errorf("invalid JSON request")
}
if err := json.Unmarshal(normalized, req); err != nil {
return fmt.Errorf("invalid /v1/chat/completions request format")
}
if req.MaxTokens != nil && *req.MaxTokens <= 0 {
return fmt.Errorf("max_tokens must be greater than zero")
}
if req.MaxCompletionTokens != nil && *req.MaxCompletionTokens <= 0 {
return fmt.Errorf("max_completion_tokens must be greater than zero")
}
if req.Temperature != nil && (*req.Temperature < 0 || *req.Temperature > 2) {
return fmt.Errorf("temperature must be between 0 and 2")
}
if req.TopP != nil && (*req.TopP < 0 || *req.TopP > 1) {
return fmt.Errorf("top_p must be between 0 and 1")
}
if err := validateThinkControl(req); err != nil {
return err
}
return nil
}
// chatCompletionEnvelope holds the routing-relevant fields decoded leniently
// from a /v1/chat/completions request body before strict normalization. Unknown
// fields are ignored so the provider tunnel passthrough can forward Codex/
// provider-specific payloads verbatim. Strict field validation stays on the
// normalized non-provider path.
type chatCompletionEnvelope struct {
Model string `json:"model"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Stream bool `json:"stream"`
}
// decodeChatCompletionEnvelope leniently extracts only the routing-relevant
// fields (model, metadata, stream) from a /v1/chat/completions request body.
// It uses json.Decoder to read only the first JSON value so caller-provided
// trailing payload (provider-specific extensions beyond the root object) does
// not invalidate the envelope; strict field validation stays on the normalized
// non-provider path.
func decodeChatCompletionEnvelope(rawBody []byte) (chatCompletionEnvelope, error) {
var env chatCompletionEnvelope
dec := json.NewDecoder(bytes.NewReader(rawBody))
if err := dec.Decode(&env); err != nil {
return chatCompletionEnvelope{}, fmt.Errorf("invalid JSON request")
}
return env, nil
}
// decodeChatCompletionRequestLenient decodes a /v1/chat/completions request
// body while preserving unknown top-level fields. Known-field syntax and
// validation are still enforced so malformed standard fields cannot reach the
// provider tunnel. This is the provider-pool ingress path.
func decodeChatCompletionRequestLenient(dec *json.Decoder, req *chatCompletionRequest) error {
var raw map[string]json.RawMessage
if err := dec.Decode(&raw); err != nil {
return fmt.Errorf("invalid JSON request")
}
for key := range raw {
switch key {
case "model", "messages", "stream", "metadata", "max_tokens", "max_completion_tokens", "temperature", "top_p", "presence_penalty", "frequency_penalty", "seed", "stop", "response_format", "tools", "tool_choice", "parallel_tool_calls", "stream_options", "store", "think", "reasoning_effort", "thinking_token_budget", "include_reasoning":
default:
// Unknown fields are tolerated for provider-pool passthrough.
}
}
normalized, err := json.Marshal(raw)
if err != nil {
return fmt.Errorf("invalid JSON request")
}
if err := json.Unmarshal(normalized, req); err != nil {
return fmt.Errorf("invalid /v1/chat/completions request format")
}
// Reuse the same validation rules on known fields for consistency.
if req.MaxTokens != nil && *req.MaxTokens <= 0 {
return fmt.Errorf("max_tokens must be greater than zero")
}
if req.MaxCompletionTokens != nil && *req.MaxCompletionTokens <= 0 {
return fmt.Errorf("max_completion_tokens must be greater than zero")
}
if req.Temperature != nil && (*req.Temperature < 0 || *req.Temperature > 2) {
return fmt.Errorf("temperature must be between 0 and 2")
}
if req.TopP != nil && (*req.TopP < 0 || *req.TopP > 1) {
return fmt.Errorf("top_p must be between 0 and 1")
}
if err := validateThinkControl(req); err != nil {
return err
}
return nil
}
func promptFromMessages(messages []chatMessage) string {
var b strings.Builder
for _, msg := range messages {
content := strings.TrimSpace(msg.Content)
if content == "" {
continue
}
role := strings.TrimSpace(msg.Role)
if role == "" {
role = "user"
}
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(role)
b.WriteString(": ")
b.WriteString(content)
}
return b.String()
}