OpenCode의 일반 Chat 요청을 GPT provider가 거부한 뒤 재시도 가능한 오류로 왜곡해 벤치가 장시간 정체됐다. 선택된 protocol profile에 맞춰 출력 토큰 필드를 정규화하고 upstream 400을 비재시도 validation 오류로 유지한다.
759 lines
22 KiB
Go
759 lines
22 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
const maxHotPathSelectorProviderInstructionBytes = 4096
|
|
|
|
func buildHotPathSelectorProviderInstruction(requestID string, state selectorInstructionState) (string, error) {
|
|
if !validLogicalRequestID(requestID) {
|
|
return "", fmt.Errorf("selector artifact request identity is invalid")
|
|
}
|
|
paths := newReservedPaths(requestID)
|
|
var instruction string
|
|
switch state {
|
|
case selectorInstructionPrepareOnly:
|
|
instruction = fmt.Sprintf(`IOP caller-workspace selector instruction.
|
|
Operation: prepare-only
|
|
Return exactly one admitted prepare tool call for this request-local job directory and no other tool call:
|
|
JOB directory: %s
|
|
Do not write PLAN or REVIEW in this turn. Do not mention or infer an absolute workspace path.`, paths.JobDir)
|
|
case selectorInstructionPairWrite:
|
|
instruction = fmt.Sprintf(`IOP caller-workspace selector instruction.
|
|
Operation: pair-write
|
|
Return exactly two admitted write tool calls for the request-local artifact pair and no other tool call. Do not mention or infer an absolute workspace path.
|
|
PLAN path: %s
|
|
REVIEW path: %s
|
|
The default PLAN grammar accepts 2-6 consecutive steps and 1-3 verification bullets. For this deterministic seed, use exactly the following two-step, one-verification form and replace only angle-bracketed text:
|
|
# Plan
|
|
|
|
## Goal
|
|
<one non-empty line>
|
|
|
|
## Steps
|
|
- [P1] <non-empty one-line step>
|
|
- [P2] <non-empty one-line step>
|
|
|
|
## Verification
|
|
- <non-empty one-line verification>
|
|
The pending REVIEW content must be exactly this deterministic seed:
|
|
# Review
|
|
|
|
## Worker Item Status
|
|
- P1: pending
|
|
- P2: pending
|
|
|
|
## Worker Changes
|
|
Pending worker execution.
|
|
|
|
## Worker Verification
|
|
Pending worker verification.
|
|
|
|
## Deviations
|
|
None recorded.`, paths.PlanPath, paths.ReviewPath)
|
|
default:
|
|
return "", fmt.Errorf("selector provider instruction state is invalid")
|
|
}
|
|
if len(instruction) > maxHotPathSelectorProviderInstructionBytes {
|
|
return "", fmt.Errorf("selector provider instruction exceeds bounded size")
|
|
}
|
|
return instruction, nil
|
|
}
|
|
|
|
func prepareHotPathSelectorProviderInstruction(tunnel edgeservice.SubmitProviderTunnelRequest, instruction string) (edgeservice.SubmitProviderTunnelRequest, error) {
|
|
if strings.TrimSpace(instruction) == "" {
|
|
return tunnel, nil
|
|
}
|
|
rewrite := func(body []byte) ([]byte, error) {
|
|
switch config.ProtocolOperation(tunnel.Operation) {
|
|
case config.OperationChatCompletions:
|
|
return injectHotPathChatSelectorInstruction(body, instruction)
|
|
case config.OperationMessages:
|
|
return injectHotPathAnthropicSelectorInstruction(body, instruction)
|
|
default:
|
|
return nil, fmt.Errorf("selector provider instruction does not support operation %q", tunnel.Operation)
|
|
}
|
|
}
|
|
if tunnel.BuildBody != nil {
|
|
build := tunnel.BuildBody
|
|
tunnel.BuildBody = func(target string) ([]byte, error) {
|
|
body, err := build(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rewrite(body)
|
|
}
|
|
return tunnel, nil
|
|
}
|
|
if len(tunnel.Body) == 0 {
|
|
return tunnel, fmt.Errorf("selector provider body is unavailable")
|
|
}
|
|
body, err := rewrite(tunnel.Body)
|
|
if err != nil {
|
|
return tunnel, err
|
|
}
|
|
tunnel.Body = body
|
|
return tunnel, nil
|
|
}
|
|
|
|
func injectHotPathChatSelectorInstruction(body []byte, instruction string) ([]byte, error) {
|
|
var envelope struct {
|
|
Messages []json.RawMessage `json:"messages"`
|
|
}
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
return nil, fmt.Errorf("decode selector Chat provider body: %w", err)
|
|
}
|
|
if len(envelope.Messages) == 0 {
|
|
return nil, fmt.Errorf("selector Chat provider body has no messages")
|
|
}
|
|
insertAt := 0
|
|
for insertAt < len(envelope.Messages) {
|
|
var message struct {
|
|
Role string `json:"role"`
|
|
}
|
|
if err := json.Unmarshal(envelope.Messages[insertAt], &message); err != nil || message.Role != "system" {
|
|
break
|
|
}
|
|
insertAt++
|
|
}
|
|
injected, err := json.Marshal(map[string]string{"role": "system", "content": instruction})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
messages := make([]json.RawMessage, 0, len(envelope.Messages)+1)
|
|
messages = append(messages, envelope.Messages[:insertAt]...)
|
|
messages = append(messages, injected)
|
|
messages = append(messages, envelope.Messages[insertAt:]...)
|
|
messagesJSON, err := json.Marshal(messages)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plan, err := planTopLevelJSONPatches(body, []topLevelJSONPatch{{name: "messages", value: messagesJSON}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return plan.apply(), nil
|
|
}
|
|
|
|
func injectHotPathAnthropicSelectorInstruction(body []byte, instruction string) ([]byte, error) {
|
|
fields, _, err := scanTopLevelJSONObject(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode selector Messages provider body: %w", err)
|
|
}
|
|
blocks := make([]json.RawMessage, 0, 2)
|
|
for _, field := range fields {
|
|
if field.name != "system" {
|
|
continue
|
|
}
|
|
raw := bytes.TrimSpace(body[field.valueFrom:field.valueTo])
|
|
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
|
break
|
|
}
|
|
if raw[0] == '"' {
|
|
var text string
|
|
if err := json.Unmarshal(raw, &text); err != nil {
|
|
return nil, fmt.Errorf("decode selector Messages system text: %w", err)
|
|
}
|
|
block, err := json.Marshal(map[string]string{"type": "text", "text": text})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
blocks = append(blocks, block)
|
|
break
|
|
}
|
|
if err := json.Unmarshal(raw, &blocks); err != nil {
|
|
return nil, fmt.Errorf("decode selector Messages system blocks: %w", err)
|
|
}
|
|
break
|
|
}
|
|
injected, err := json.Marshal(map[string]string{"type": "text", "text": instruction})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
blocks = append(blocks, injected)
|
|
systemJSON, err := json.Marshal(blocks)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plan, err := planTopLevelJSONPatches(body, []topLevelJSONPatch{{name: "system", value: systemJSON}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return plan.apply(), nil
|
|
}
|
|
|
|
type openAIProviderBodyBuilder struct {
|
|
mu sync.Mutex
|
|
build func(string) (*openAIRebuiltLease, error)
|
|
lease *openAIRebuiltLease
|
|
built bool
|
|
closed bool
|
|
}
|
|
|
|
func newOpenAIProviderBodyBuilder(build func(string) (*openAIRebuiltLease, error)) *openAIProviderBodyBuilder {
|
|
return &openAIProviderBodyBuilder{build: build}
|
|
}
|
|
|
|
func (b *openAIProviderBodyBuilder) BuildBody(target string) ([]byte, error) {
|
|
if b == nil {
|
|
return nil, fmt.Errorf("OpenAI provider body builder is unavailable")
|
|
}
|
|
b.mu.Lock()
|
|
if b.closed || b.built || b.build == nil {
|
|
b.mu.Unlock()
|
|
return nil, fmt.Errorf("OpenAI provider body builder is unavailable")
|
|
}
|
|
b.built = true
|
|
build := b.build
|
|
b.mu.Unlock()
|
|
|
|
lease, err := build(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b.mu.Lock()
|
|
if b.closed {
|
|
b.mu.Unlock()
|
|
lease.release()
|
|
return nil, fmt.Errorf("OpenAI provider body builder is unavailable")
|
|
}
|
|
b.lease = lease
|
|
b.mu.Unlock()
|
|
body, err := lease.body()
|
|
if err != nil {
|
|
b.Close()
|
|
return nil, err
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func (b *openAIProviderBodyBuilder) Close() {
|
|
if b == nil {
|
|
return
|
|
}
|
|
b.mu.Lock()
|
|
b.closed = true
|
|
b.build = nil
|
|
lease := b.lease
|
|
b.lease = nil
|
|
b.mu.Unlock()
|
|
lease.release()
|
|
}
|
|
|
|
type providerModelRewriter struct {
|
|
streaming bool
|
|
model string
|
|
toolCallWire string
|
|
pending []byte
|
|
}
|
|
|
|
func newProviderModelRewriter(streaming bool, model string) *providerModelRewriter {
|
|
return newProviderModelRewriterWithToolCallWire(streaming, model, "")
|
|
}
|
|
|
|
func newProviderModelRewriterForDispatch(streaming bool, model string, dispatch edgeservice.RunDispatch) *providerModelRewriter {
|
|
return newProviderModelRewriterWithToolCallWire(streaming, model, dispatch.ProfileToolCallWire)
|
|
}
|
|
|
|
func newProviderModelRewriterWithToolCallWire(streaming bool, model, toolCallWire string) *providerModelRewriter {
|
|
model = strings.TrimSpace(model)
|
|
toolCallWire = strings.TrimSpace(toolCallWire)
|
|
if model == "" && toolCallWire == "" {
|
|
return nil
|
|
}
|
|
return &providerModelRewriter{streaming: streaming, model: model, toolCallWire: toolCallWire}
|
|
}
|
|
|
|
func (r *providerModelRewriter) setToolCallWire(toolCallWire string) {
|
|
if r != nil {
|
|
r.toolCallWire = strings.TrimSpace(toolCallWire)
|
|
}
|
|
}
|
|
|
|
func (r *providerModelRewriter) AppendStream(chunk []byte) []byte {
|
|
if r == nil || len(chunk) == 0 {
|
|
return chunk
|
|
}
|
|
if !r.streaming {
|
|
r.pending = append(r.pending, chunk...)
|
|
return nil
|
|
}
|
|
r.pending = append(r.pending, chunk...)
|
|
var out bytes.Buffer
|
|
for {
|
|
idx := bytes.IndexByte(r.pending, '\n')
|
|
if idx < 0 {
|
|
break
|
|
}
|
|
line := r.pending[:idx+1]
|
|
out.Write(rewriteProviderSSELine(line, r.model, r.toolCallWire))
|
|
r.pending = r.pending[idx+1:]
|
|
}
|
|
return out.Bytes()
|
|
}
|
|
|
|
func (r *providerModelRewriter) FlushStream() []byte {
|
|
if r == nil || len(r.pending) == 0 {
|
|
return nil
|
|
}
|
|
pending := r.pending
|
|
r.pending = nil
|
|
if !r.streaming {
|
|
return r.RewriteComplete(pending)
|
|
}
|
|
return rewriteProviderSSELine(pending, r.model, r.toolCallWire)
|
|
}
|
|
|
|
func (r *providerModelRewriter) RewriteComplete(body []byte) []byte {
|
|
if r == nil || len(body) == 0 {
|
|
return body
|
|
}
|
|
return rewriteProviderJSONResponse(body, r.model, r.toolCallWire)
|
|
}
|
|
|
|
func rewriteProviderSSEModelLine(line []byte, model string) []byte {
|
|
return rewriteProviderSSELine(line, model, "")
|
|
}
|
|
|
|
func rewriteProviderSSELine(line []byte, model, toolCallWire string) []byte {
|
|
body, ending := splitLineEnding(line)
|
|
prefix, payload, ok := bytes.Cut(body, []byte(":"))
|
|
if !ok || strings.TrimSpace(string(prefix)) != "data" {
|
|
return line
|
|
}
|
|
payload = bytes.TrimSpace(payload)
|
|
if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
|
|
return line
|
|
}
|
|
rewritten := rewriteProviderJSONResponse(payload, model, toolCallWire)
|
|
if bytes.Equal(rewritten, payload) {
|
|
return line
|
|
}
|
|
out := make([]byte, 0, len("data: ")+len(rewritten)+len(ending))
|
|
out = append(out, "data: "...)
|
|
out = append(out, rewritten...)
|
|
out = append(out, ending...)
|
|
return out
|
|
}
|
|
|
|
func rewriteProviderJSONResponse(body []byte, model, toolCallWire string) []byte {
|
|
rewritten := rewriteProviderJSONModel(body, model)
|
|
if toolCallWire != config.ProtocolToolCallWireGeminiChat {
|
|
return rewritten
|
|
}
|
|
return normalizeGeminiChatProviderResponse(rewritten)
|
|
}
|
|
|
|
func normalizeGeminiChatProviderResponse(body []byte) []byte {
|
|
if !bytes.Contains(body, []byte(`"thought_signature"`)) {
|
|
return body
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
decoder.UseNumber()
|
|
var response map[string]any
|
|
if err := decoder.Decode(&response); err != nil {
|
|
return body
|
|
}
|
|
changed := false
|
|
for _, rawChoice := range anySlice(response["choices"]) {
|
|
choice, ok := rawChoice.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, messageKey := range []string{"message", "delta"} {
|
|
message, ok := choice[messageKey].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, rawCall := range anySlice(message["tool_calls"]) {
|
|
call, ok := rawCall.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id, idOK := call["id"].(string)
|
|
extra, extraOK := call["extra_content"].(map[string]any)
|
|
if !idOK || id == "" || !extraOK {
|
|
continue
|
|
}
|
|
google, googleOK := extra["google"].(map[string]any)
|
|
if !googleOK {
|
|
continue
|
|
}
|
|
signature, signatureOK := google["thought_signature"].(string)
|
|
if !signatureOK || signature == "" {
|
|
continue
|
|
}
|
|
call["id"] = encodeGeminiThoughtSignatureToolID(id, signature)
|
|
delete(google, "thought_signature")
|
|
if len(google) == 0 {
|
|
delete(extra, "google")
|
|
}
|
|
if len(extra) == 0 {
|
|
delete(call, "extra_content")
|
|
}
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
if !changed {
|
|
return body
|
|
}
|
|
encoded, err := json.Marshal(response)
|
|
if err != nil {
|
|
return body
|
|
}
|
|
return encoded
|
|
}
|
|
|
|
func normalizeGeminiChatProviderRequest(body []byte) ([]byte, error) {
|
|
if !bytes.Contains(body, []byte(geminiThoughtSignatureToolIDPrefix)) {
|
|
return body, nil
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(body))
|
|
decoder.UseNumber()
|
|
var request map[string]any
|
|
if err := decoder.Decode(&request); err != nil {
|
|
return nil, fmt.Errorf("decode Gemini Chat provider request: %w", err)
|
|
}
|
|
changed := false
|
|
for _, rawMessage := range anySlice(request["messages"]) {
|
|
message, ok := rawMessage.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, rawCall := range anySlice(message["tool_calls"]) {
|
|
call, ok := rawCall.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
encodedID, ok := call["id"].(string)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id, signature, encoded, err := decodeGeminiThoughtSignatureToolID(encodedID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !encoded {
|
|
continue
|
|
}
|
|
if err := restoreGeminiThoughtSignature(call, signature); err != nil {
|
|
return nil, err
|
|
}
|
|
call["id"] = id
|
|
changed = true
|
|
}
|
|
if encodedID, ok := message["tool_call_id"].(string); ok {
|
|
id, _, encoded, err := decodeGeminiThoughtSignatureToolID(encodedID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if encoded {
|
|
message["tool_call_id"] = id
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
if !changed {
|
|
return body, nil
|
|
}
|
|
encoded, err := json.Marshal(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode Gemini Chat provider request: %w", err)
|
|
}
|
|
return encoded, nil
|
|
}
|
|
|
|
func restoreGeminiThoughtSignature(call map[string]any, signature string) error {
|
|
extra, ok := call["extra_content"].(map[string]any)
|
|
if !ok {
|
|
if call["extra_content"] != nil {
|
|
return fmt.Errorf("restore Gemini thought signature: extra_content is not an object")
|
|
}
|
|
extra = make(map[string]any)
|
|
call["extra_content"] = extra
|
|
}
|
|
google, ok := extra["google"].(map[string]any)
|
|
if !ok {
|
|
if extra["google"] != nil {
|
|
return fmt.Errorf("restore Gemini thought signature: extra_content.google is not an object")
|
|
}
|
|
google = make(map[string]any)
|
|
extra["google"] = google
|
|
}
|
|
if existing, exists := google["thought_signature"]; exists && existing != signature {
|
|
return fmt.Errorf("restore Gemini thought signature: conflicting signature")
|
|
}
|
|
google["thought_signature"] = signature
|
|
return nil
|
|
}
|
|
|
|
func prepareProviderChatToolCallNormalization(tunnel edgeservice.SubmitProviderTunnelRequest, selected edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) {
|
|
if selected.ProtocolProfile == nil {
|
|
return tunnel, nil
|
|
}
|
|
mapping, ok := selected.ProtocolProfile.ToolCallMapping(config.OperationChatCompletions)
|
|
if !ok || mapping.Wire != config.ProtocolToolCallWireGeminiChat {
|
|
return tunnel, nil
|
|
}
|
|
build := tunnel.BuildBody
|
|
if build == nil {
|
|
return tunnel, fmt.Errorf("Gemini Chat provider body builder is unavailable")
|
|
}
|
|
tunnel.BuildBody = func(target string) ([]byte, error) {
|
|
body, err := build(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return normalizeGeminiChatProviderRequest(body)
|
|
}
|
|
return tunnel, nil
|
|
}
|
|
|
|
// prepareProviderChatRequestNormalization applies the bounded Chat field
|
|
// aliases owned by the selected protocol profile. A generic OpenAI-compatible
|
|
// caller can legally send either token-limit spelling, but current OpenAI Chat
|
|
// profiles require max_completion_tokens while Gemini and legacy Chat wires
|
|
// use max_tokens. Unknown fields and their original byte ranges remain
|
|
// untouched.
|
|
func prepareProviderChatRequestNormalization(tunnel edgeservice.SubmitProviderTunnelRequest, selected edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) {
|
|
profile := selected.ProtocolProfile
|
|
if profile == nil || tunnel.Operation != string(config.OperationChatCompletions) {
|
|
return tunnel, nil
|
|
}
|
|
mapping, ok := profile.EffortMapping(config.OperationChatCompletions)
|
|
if !ok {
|
|
return tunnel, nil
|
|
}
|
|
var targetField string
|
|
switch mapping.Wire {
|
|
case config.ProtocolEffortWireOpenAIChat:
|
|
targetField = "max_completion_tokens"
|
|
case config.ProtocolEffortWireGeminiChat:
|
|
targetField = "max_tokens"
|
|
default:
|
|
return tunnel, nil
|
|
}
|
|
rewrite := func(body []byte) ([]byte, error) {
|
|
return normalizeChatTokenLimitField(body, targetField)
|
|
}
|
|
if tunnel.BuildBody != nil {
|
|
build := tunnel.BuildBody
|
|
tunnel.BuildBody = func(target string) ([]byte, error) {
|
|
body, err := build(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rewrite(body)
|
|
}
|
|
return tunnel, nil
|
|
}
|
|
if len(tunnel.Body) == 0 {
|
|
return tunnel, nil
|
|
}
|
|
body, err := rewrite(tunnel.Body)
|
|
if err != nil {
|
|
return tunnel, err
|
|
}
|
|
tunnel.Body = body
|
|
return tunnel, nil
|
|
}
|
|
|
|
func normalizeChatTokenLimitField(body []byte, targetField string) ([]byte, error) {
|
|
var limits struct {
|
|
MaxTokens json.RawMessage `json:"max_tokens"`
|
|
MaxCompletionTokens json.RawMessage `json:"max_completion_tokens"`
|
|
}
|
|
if err := json.Unmarshal(body, &limits); err != nil {
|
|
return nil, fmt.Errorf("decode Chat token limit fields: %w", err)
|
|
}
|
|
var value json.RawMessage
|
|
var remove string
|
|
switch targetField {
|
|
case "max_completion_tokens":
|
|
value, remove = limits.MaxCompletionTokens, "max_tokens"
|
|
if len(value) == 0 {
|
|
value = limits.MaxTokens
|
|
}
|
|
case "max_tokens":
|
|
value, remove = limits.MaxTokens, "max_completion_tokens"
|
|
if len(value) == 0 {
|
|
value = limits.MaxCompletionTokens
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported Chat token limit target %q", targetField)
|
|
}
|
|
if len(value) == 0 {
|
|
return body, nil
|
|
}
|
|
plan, err := planTopLevelJSONPatches(body, []topLevelJSONPatch{
|
|
{name: targetField, value: append(json.RawMessage(nil), value...)},
|
|
{name: remove},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return plan.apply(), nil
|
|
}
|
|
|
|
func splitLineEnding(line []byte) ([]byte, []byte) {
|
|
if len(line) == 0 || line[len(line)-1] != '\n' {
|
|
return line, nil
|
|
}
|
|
if len(line) >= 2 && line[len(line)-2] == '\r' {
|
|
return line[:len(line)-2], []byte("\r\n")
|
|
}
|
|
return line[:len(line)-1], []byte("\n")
|
|
}
|
|
|
|
func rewriteProviderJSONModel(body []byte, model string) []byte {
|
|
modelJSON, err := json.Marshal(model)
|
|
if err != nil {
|
|
return body
|
|
}
|
|
fields, _, err := scanTopLevelJSONObject(body)
|
|
if err != nil {
|
|
return body
|
|
}
|
|
for _, field := range fields {
|
|
if field.name == "model" {
|
|
plan, err := planTopLevelJSONPatches(body, []topLevelJSONPatch{{name: "model", value: modelJSON}})
|
|
if err == nil {
|
|
return plan.apply()
|
|
}
|
|
break
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
// rewriteChatCompletionModel replaces only the model field of the caller's
|
|
// original request JSON so the provider receives its served model name. The
|
|
// rest of the caller payload is forwarded without IOP rewriting.
|
|
func rewriteChatCompletionModel(rawBody []byte, target string, req chatCompletionRequest) ([]byte, error) {
|
|
patches, err := chatCompletionProviderPatches(target, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(patches) == 0 {
|
|
return rawBody, nil
|
|
}
|
|
plan, err := planTopLevelJSONPatches(rawBody, patches)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return plan.apply(), nil
|
|
}
|
|
|
|
func rewriteChatCompletionModelFromIngress(ingress *openAIIngressSnapshot, target string, req chatCompletionRequest) (*openAIRebuiltLease, error) {
|
|
patches, err := chatCompletionProviderPatches(target, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rewriteIngressTopLevelJSON(ingress, patches)
|
|
}
|
|
|
|
func chatCompletionProviderPatches(target string, req chatCompletionRequest) ([]topLevelJSONPatch, error) {
|
|
patches := make([]topLevelJSONPatch, 0, 5)
|
|
if strings.TrimSpace(target) != "" {
|
|
modelJSON, err := json.Marshal(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
patches = append(patches, topLevelJSONPatch{name: "model", value: modelJSON})
|
|
}
|
|
if req.MaxTokens != nil {
|
|
maxTokensJSON, err := json.Marshal(*req.MaxTokens)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
patches = append(patches,
|
|
topLevelJSONPatch{name: "max_tokens", value: maxTokensJSON},
|
|
topLevelJSONPatch{name: "max_completion_tokens"},
|
|
)
|
|
}
|
|
if req.ThinkingTokenBudget != nil {
|
|
budgetJSON, err := json.Marshal(*req.ThinkingTokenBudget)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
patches = append(patches, topLevelJSONPatch{name: "thinking_token_budget", value: budgetJSON})
|
|
}
|
|
if req.Think != nil {
|
|
thinkJSON, err := json.Marshal(*req.Think)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
patches = append(patches, topLevelJSONPatch{name: "think", value: thinkJSON})
|
|
}
|
|
return patches, nil
|
|
}
|
|
|
|
// rewriteResponsesModel replaces only the model field of the caller's original
|
|
// /v1/responses request JSON so the provider receives its served model name.
|
|
// Every other field (input, instructions, tools, max_output_tokens, and any
|
|
// Codex/Responses-specific field) is forwarded without IOP rewriting. An empty
|
|
// target leaves the body untouched; invalid JSON is rejected.
|
|
func rewriteResponsesModel(rawBody []byte, target string) ([]byte, error) {
|
|
if strings.TrimSpace(target) == "" {
|
|
return rawBody, nil
|
|
}
|
|
modelJSON, err := json.Marshal(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plan, err := planTopLevelJSONPatches(rawBody, []topLevelJSONPatch{{name: "model", value: modelJSON}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return plan.apply(), nil
|
|
}
|
|
|
|
func rewriteResponsesModelFromIngress(ingress *openAIIngressSnapshot, target string) (*openAIRebuiltLease, error) {
|
|
if strings.TrimSpace(target) == "" {
|
|
return rewriteIngressTopLevelJSON(ingress, nil)
|
|
}
|
|
modelJSON, err := json.Marshal(target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rewriteIngressTopLevelJSON(ingress, []topLevelJSONPatch{{name: "model", value: modelJSON}})
|
|
}
|
|
|
|
// rewriteIngressTopLevelJSON places both allocation checks around the single
|
|
// patched output and returns a lease that stays live through synchronous
|
|
// provider submission. The canonical ingress owner remains unchanged.
|
|
func rewriteIngressTopLevelJSON(ingress *openAIIngressSnapshot, patches []topLevelJSONPatch) (*openAIRebuiltLease, error) {
|
|
body, err := ingress.canonicalBody()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plan, err := planTopLevelJSONPatches(body, patches)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(plan.edits) == 0 {
|
|
return &openAIRebuiltLease{ingress: ingress, bodyAlias: body}, nil
|
|
}
|
|
guard, err := ingress.reserveRebuild(int64(plan.outputSize))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output := plan.apply()
|
|
rebuilt, err := guard.CommitOwnedTyped(openAIRebuiltBodyViewName, output)
|
|
if err != nil {
|
|
guard.Close()
|
|
return nil, err
|
|
}
|
|
return &openAIRebuiltLease{
|
|
ingress: ingress, guard: guard, rebuilt: rebuilt, bodyAlias: output,
|
|
}, nil
|
|
}
|