iop/apps/edge/internal/openai/chat_decode.go
toki d1e32b6e06 feat(openai): 반복 출력 복구를 구현한다
출력 반복을 요청 단위로 감지하고 안전한 continuation lifecycle을 보장하기 위해 Chat/Responses codec과 stream gate evidence를 함께 정렬한다.
2026-07-29 18:40:36 +09:00

251 lines
8.4 KiB
Go

package openai
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"iop/packages/go/streamgate"
)
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
}
// decodeOpenAIChatRepeatHistory builds the Chat-specific, raw-free repeat
// history view. Only plain role text, the documented reasoning aliases, and
// completed tool call/result pairs participate. Signed, encrypted, malformed,
// and unknown fields remain in the canonical ingress snapshot but are ignored
// by this semantic decoder.
func decodeOpenAIChatRepeatHistory(rawBody []byte) (openAIRepeatHistorySnapshot, error) {
var root struct {
Messages []json.RawMessage `json:"messages"`
}
if err := json.Unmarshal(rawBody, &root); err != nil {
return openAIRepeatHistorySnapshot{}, fmt.Errorf("decode Chat repeat history: %w", err)
}
builder := newOpenAIRepeatHistoryBuilder()
pendingActions := make(map[string]streamgate.FixedFingerprint)
for _, rawMessage := range root.Messages {
var message map[string]json.RawMessage
if err := json.Unmarshal(rawMessage, &message); err != nil {
continue
}
var role string
if err := json.Unmarshal(message["role"], &role); err != nil {
continue
}
role = strings.ToLower(strings.TrimSpace(role))
for _, text := range openAIRepeatPlainTextParts(message["content"]) {
builder.recordText(role, openAIRepeatChannelContent, text)
}
for _, alias := range []string{"reasoning_content", "reasoning", "reasoning_text"} {
var reasoning string
if err := json.Unmarshal(message[alias], &reasoning); err == nil {
builder.recordText(role, openAIRepeatChannelReasoning, reasoning)
}
}
if role == "assistant" {
var calls []json.RawMessage
if json.Unmarshal(message["tool_calls"], &calls) == nil {
for _, rawCall := range calls {
var call struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
}
if json.Unmarshal(rawCall, &call) != nil {
continue
}
if fingerprint, ok := openAIRepeatActionFingerprint(
call.Function.Name,
openAIRepeatArgumentsValue(call.Function.Arguments),
); ok && strings.TrimSpace(call.ID) != "" {
pendingActions[call.ID] = fingerprint
}
}
}
}
if role == "tool" {
var callID string
if json.Unmarshal(message["tool_call_id"], &callID) != nil {
continue
}
action, ok := pendingActions[callID]
if !ok {
continue
}
result, ok := openAIRepeatTextFingerprint(strings.Join(openAIRepeatPlainTextParts(message["content"]), "\n"))
if ok {
builder.recordCompletedAction(action, result)
}
}
}
return builder.snapshot(), nil
}
func openAIRepeatArgumentsValue(raw json.RawMessage) json.RawMessage {
var value string
if json.Unmarshal(raw, &value) == nil {
return json.RawMessage(value)
}
return raw
}
func openAIRepeatPlainTextParts(raw json.RawMessage) []string {
var plain string
if json.Unmarshal(raw, &plain) == nil {
return []string{plain}
}
var parts []json.RawMessage
if json.Unmarshal(raw, &parts) != nil {
return nil
}
values := make([]string, 0, len(parts))
for _, rawPart := range parts {
var part map[string]json.RawMessage
if json.Unmarshal(rawPart, &part) != nil {
continue
}
var partType string
if json.Unmarshal(part["type"], &partType) != nil {
continue
}
switch partType {
case "text", "input_text", "output_text":
default:
continue
}
var text string
if json.Unmarshal(part["text"], &text) == nil {
values = append(values, text)
}
}
return values
}
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()
}