출력 반복을 요청 단위로 감지하고 안전한 continuation lifecycle을 보장하기 위해 Chat/Responses codec과 stream gate evidence를 함께 정렬한다.
377 lines
12 KiB
Go
377 lines
12 KiB
Go
package openai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"iop/packages/go/streamgate"
|
|
)
|
|
|
|
func decodeResponsesRequest(dec *json.Decoder, req *responsesRequest) 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", "input", "instructions", "stream", "background", "metadata", "max_output_tokens", "temperature", "top_p":
|
|
default:
|
|
return fmt.Errorf("%s is not supported for /v1/responses", 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/responses request format")
|
|
}
|
|
if req.MaxOutputTokens != nil && *req.MaxOutputTokens <= 0 {
|
|
return fmt.Errorf("max_output_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")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decodeOpenAIResponsesRepeatHistory is intentionally independent from the
|
|
// Chat messages decoder. It reads only plain Responses input provenance and
|
|
// reduces it to bounded fingerprints; encrypted and unknown input items remain
|
|
// canonical-only data and never become mutation candidates.
|
|
func decodeOpenAIResponsesRepeatHistory(rawBody []byte) (openAIRepeatHistorySnapshot, error) {
|
|
var root struct {
|
|
Input json.RawMessage `json:"input"`
|
|
}
|
|
if err := json.Unmarshal(rawBody, &root); err != nil {
|
|
return openAIRepeatHistorySnapshot{}, fmt.Errorf("decode Responses repeat history: %w", err)
|
|
}
|
|
builder := newOpenAIRepeatHistoryBuilder()
|
|
var inputString string
|
|
if json.Unmarshal(root.Input, &inputString) == nil {
|
|
builder.recordText("user", openAIRepeatChannelContent, inputString)
|
|
return builder.snapshot(), nil
|
|
}
|
|
var items []json.RawMessage
|
|
if json.Unmarshal(root.Input, &items) != nil {
|
|
return builder.snapshot(), nil
|
|
}
|
|
pendingActions := make(map[string]streamgate.FixedFingerprint)
|
|
for _, rawItem := range items {
|
|
var item map[string]json.RawMessage
|
|
if json.Unmarshal(rawItem, &item) != nil {
|
|
continue
|
|
}
|
|
var itemType string
|
|
if json.Unmarshal(item["type"], &itemType) != nil {
|
|
continue
|
|
}
|
|
switch itemType {
|
|
case "message":
|
|
var role string
|
|
if json.Unmarshal(item["role"], &role) != nil {
|
|
continue
|
|
}
|
|
for _, text := range openAIRepeatResponsesTextParts(item["content"], "input_text", "output_text", "text") {
|
|
builder.recordText(role, openAIRepeatChannelContent, text)
|
|
}
|
|
case "reasoning":
|
|
for _, text := range openAIRepeatResponsesTextParts(item["content"], "reasoning_text") {
|
|
builder.recordText("assistant", openAIRepeatChannelReasoning, text)
|
|
}
|
|
case "function_call":
|
|
var callID, name string
|
|
var arguments json.RawMessage
|
|
_ = json.Unmarshal(item["call_id"], &callID)
|
|
if callID == "" {
|
|
_ = json.Unmarshal(item["id"], &callID)
|
|
}
|
|
_ = json.Unmarshal(item["name"], &name)
|
|
arguments = openAIRepeatArgumentsValue(item["arguments"])
|
|
if fingerprint, ok := openAIRepeatActionFingerprint(name, arguments); ok && strings.TrimSpace(callID) != "" {
|
|
pendingActions[callID] = fingerprint
|
|
}
|
|
case "function_call_output", "function_call_error":
|
|
var callID string
|
|
_ = json.Unmarshal(item["call_id"], &callID)
|
|
action, ok := pendingActions[callID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
var result string
|
|
if json.Unmarshal(item["output"], &result) != nil {
|
|
_ = json.Unmarshal(item["error"], &result)
|
|
}
|
|
if fingerprint, ok := openAIRepeatTextFingerprint(result); ok {
|
|
builder.recordCompletedAction(action, fingerprint)
|
|
}
|
|
}
|
|
}
|
|
return builder.snapshot(), nil
|
|
}
|
|
|
|
func openAIRepeatResponsesTextParts(raw json.RawMessage, allowedTypes ...string) []string {
|
|
allowed := make(map[string]struct{}, len(allowedTypes))
|
|
for _, value := range allowedTypes {
|
|
allowed[value] = struct{}{}
|
|
}
|
|
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, text string
|
|
if json.Unmarshal(part["type"], &partType) != nil {
|
|
continue
|
|
}
|
|
if _, ok := allowed[partType]; !ok {
|
|
continue
|
|
}
|
|
if json.Unmarshal(part["text"], &text) == nil {
|
|
values = append(values, text)
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
// decodeOpenAIResponsesResumeRequest accepts only the private recovery body
|
|
// emitted by buildOpenAIResponsesResumeBody. It must never be used for public
|
|
// /v1/responses ingress: public arrays remain rejected by parseResponsesInput.
|
|
//
|
|
// The decoder is intentionally exact. It admits only a non-streaming model,
|
|
// the fixed resume directive, and either an assistant output item or a
|
|
// reasoning item immediately followed by that assistant output item. It does
|
|
// not trim, normalize, or reinterpret recorded text.
|
|
func decodeOpenAIResponsesResumeRequest(body []byte) (responsesResumeRequest, error) {
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return responsesResumeRequest{}, fmt.Errorf("invalid OpenAI Responses resume request")
|
|
}
|
|
if len(raw) < 4 || len(raw) > 5 {
|
|
return responsesResumeRequest{}, fmt.Errorf("invalid OpenAI Responses resume request")
|
|
}
|
|
for key := range raw {
|
|
switch key {
|
|
case "model", "stream", "instructions", "input", "temperature":
|
|
default:
|
|
return responsesResumeRequest{}, fmt.Errorf("invalid OpenAI Responses resume request")
|
|
}
|
|
}
|
|
|
|
var model string
|
|
if err := json.Unmarshal(raw["model"], &model); err != nil || strings.TrimSpace(model) == "" {
|
|
return responsesResumeRequest{}, fmt.Errorf("invalid OpenAI Responses resume request")
|
|
}
|
|
var stream bool
|
|
if err := json.Unmarshal(raw["stream"], &stream); err != nil || stream {
|
|
return responsesResumeRequest{}, fmt.Errorf("invalid OpenAI Responses resume request")
|
|
}
|
|
var instructions string
|
|
if err := json.Unmarshal(raw["instructions"], &instructions); err != nil || instructions != openAIRepeatResumeDirective {
|
|
return responsesResumeRequest{}, fmt.Errorf("invalid OpenAI Responses resume request")
|
|
}
|
|
var temperature *float64
|
|
if rawTemperature, ok := raw["temperature"]; ok {
|
|
var value float64
|
|
if err := json.Unmarshal(rawTemperature, &value); err != nil || value < 0 || value > 2 {
|
|
return responsesResumeRequest{}, fmt.Errorf("invalid OpenAI Responses resume request")
|
|
}
|
|
temperature = &value
|
|
}
|
|
|
|
var items []json.RawMessage
|
|
if err := json.Unmarshal(raw["input"], &items); err != nil || len(items) < 1 || len(items) > 2 {
|
|
return responsesResumeRequest{}, fmt.Errorf("invalid OpenAI Responses resume request")
|
|
}
|
|
|
|
decodeTextItem := func(rawItem json.RawMessage, wantType string) (string, error) {
|
|
var item map[string]json.RawMessage
|
|
if err := json.Unmarshal(rawItem, &item); err != nil {
|
|
return "", err
|
|
}
|
|
wantItemFields := 2
|
|
if wantType == "message" {
|
|
wantItemFields = 3
|
|
}
|
|
if len(item) != wantItemFields {
|
|
return "", fmt.Errorf("unexpected item fields")
|
|
}
|
|
var itemType string
|
|
if err := json.Unmarshal(item["type"], &itemType); err != nil || itemType != wantType {
|
|
return "", fmt.Errorf("unexpected item type")
|
|
}
|
|
if wantType == "message" {
|
|
var role string
|
|
if err := json.Unmarshal(item["role"], &role); err != nil || role != "assistant" {
|
|
return "", fmt.Errorf("unexpected item role")
|
|
}
|
|
}
|
|
var content []json.RawMessage
|
|
if err := json.Unmarshal(item["content"], &content); err != nil || len(content) != 1 {
|
|
return "", fmt.Errorf("invalid item content")
|
|
}
|
|
var part map[string]json.RawMessage
|
|
if err := json.Unmarshal(content[0], &part); err != nil {
|
|
return "", err
|
|
}
|
|
if len(part) != 2 {
|
|
return "", fmt.Errorf("unexpected content fields")
|
|
}
|
|
partType := "output_text"
|
|
if wantType == "reasoning" {
|
|
partType = "reasoning_text"
|
|
}
|
|
var gotPartType, text string
|
|
if err := json.Unmarshal(part["type"], &gotPartType); err != nil || gotPartType != partType {
|
|
return "", fmt.Errorf("unexpected content type")
|
|
}
|
|
if err := json.Unmarshal(part["text"], &text); err != nil {
|
|
return "", err
|
|
}
|
|
return text, nil
|
|
}
|
|
|
|
var content, reasoning string
|
|
if len(items) == 2 {
|
|
var err error
|
|
reasoning, err = decodeTextItem(items[0], "reasoning")
|
|
if err != nil {
|
|
return responsesResumeRequest{}, fmt.Errorf("invalid OpenAI Responses resume request")
|
|
}
|
|
content, err = decodeTextItem(items[1], "message")
|
|
if err != nil {
|
|
return responsesResumeRequest{}, fmt.Errorf("invalid OpenAI Responses resume request")
|
|
}
|
|
} else {
|
|
var err error
|
|
content, err = decodeTextItem(items[0], "message")
|
|
if err != nil {
|
|
return responsesResumeRequest{}, fmt.Errorf("invalid OpenAI Responses resume request")
|
|
}
|
|
}
|
|
|
|
return responsesResumeRequest{
|
|
Request: responsesRequest{
|
|
Model: model,
|
|
Input: raw["input"],
|
|
Instructions: instructions,
|
|
Stream: false,
|
|
Temperature: temperature,
|
|
},
|
|
Content: content,
|
|
Reasoning: reasoning,
|
|
}, nil
|
|
}
|
|
|
|
// decodeResponsesEnvelope leniently extracts only the routing-relevant fields
|
|
// (model, metadata, stream, background) from a /v1/responses request body. It
|
|
// does not reject unknown fields so the provider tunnel passthrough can forward
|
|
// Codex/Responses payloads verbatim; strict field validation stays on the
|
|
// normalized non-provider path.
|
|
func decodeResponsesEnvelope(rawBody []byte) (responsesEnvelope, error) {
|
|
var env responsesEnvelope
|
|
if err := json.Unmarshal(rawBody, &env); err != nil {
|
|
return responsesEnvelope{}, fmt.Errorf("invalid JSON request")
|
|
}
|
|
return env, nil
|
|
}
|
|
|
|
func parseResponsesInput(raw json.RawMessage) (string, error) {
|
|
if len(raw) == 0 {
|
|
return "", fmt.Errorf("input is required")
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return "", fmt.Errorf("input must be a string")
|
|
}
|
|
if strings.TrimSpace(s) == "" {
|
|
return "", fmt.Errorf("input is required")
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func buildResponsesPrompt(instructions, input string) string {
|
|
instructions = strings.TrimSpace(instructions)
|
|
if instructions == "" {
|
|
return input
|
|
}
|
|
return instructions + "\n\n" + input
|
|
}
|
|
|
|
func parseOpenAIMetadata(raw json.RawMessage) (map[string]string, string, error) {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return make(map[string]string), "", nil
|
|
}
|
|
|
|
var rawMap map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &rawMap); err != nil {
|
|
return nil, "", fmt.Errorf("metadata must be an object")
|
|
}
|
|
|
|
if len(rawMap) > 16 {
|
|
return nil, "", fmt.Errorf("metadata must contain at most 16 keys")
|
|
}
|
|
|
|
flat := make(map[string]string, len(rawMap))
|
|
var workspace string
|
|
for key, rawValue := range rawMap {
|
|
if len(key) > 64 {
|
|
return nil, "", fmt.Errorf("metadata key %q exceeds 64 characters", key)
|
|
}
|
|
if key == "source" {
|
|
return nil, "", fmt.Errorf("metadata.source is not supported")
|
|
}
|
|
if key == "workspace" {
|
|
workspaceValue, err := metadataStringValue(key, rawValue)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
workspace = strings.TrimSpace(workspaceValue)
|
|
continue
|
|
}
|
|
value, err := metadataStringValue(key, rawValue)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
flat[key] = value
|
|
}
|
|
|
|
return flat, workspace, nil
|
|
}
|
|
|
|
// errProviderRequestValidation is a sentinel for client request validation
|
|
// failures that occur inside the normalized provider-pool PrepareRun hook
|
|
// (strict decode, stream/background rejection, input parsing). These errors
|
|
// are reported as HTTP 400 invalid_request_error, distinct from backend
|
|
// dispatch errors that return 502.
|
|
var errProviderRequestValidation = errors.New("provider_request_validation")
|
|
|
|
// isValidationError reports whether err originated from a PrepareRun validation
|
|
// failure in the normalized provider-pool path.
|
|
func isValidationError(err error) bool {
|
|
return errors.Is(err, errProviderRequestValidation)
|
|
}
|
|
|
|
// metadataStringValue extracts a single string value from a json.RawMessage
|
|
// for the metadata field. It rejects non-string values and enforces the
|
|
// 512-character length cap.
|
|
func metadataStringValue(key string, raw json.RawMessage) (string, error) {
|
|
var value string
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
return "", fmt.Errorf("metadata.%s must be a string", key)
|
|
}
|
|
if len(value) > 512 {
|
|
return "", fmt.Errorf("metadata.%s exceeds 512 characters", key)
|
|
}
|
|
return value, nil
|
|
}
|