935 lines
29 KiB
Go
935 lines
29 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
const (
|
|
maxHotPathSelectorProviderInstructionBytes = 4096
|
|
maxHotPathSelectorOutputTokens = 4096
|
|
hotPathArtifactPairToolName = "iop_write_artifact_pair"
|
|
hotPathCallerWorkspacePlanBoundary = "Use the caller workspace current working directory as the task root; never place task outputs under .iop."
|
|
)
|
|
|
|
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
|
|
Analyze the immutable user task, then return exactly one iop_write_artifact_pair tool call and no other tool call.
|
|
Keep every explicit requirement, exact literal, filename, command, output string, and acceptance condition. Do not add scope.
|
|
Write all fields in English using ASCII characters only. Supply one concise goal, 1-5 executable worker steps, and 1-3 observable verification checks.
|
|
Do not write markdown; IOP renders the fixed Plan and pending Review templates.
|
|
Do not mention or infer an absolute workspace path. Task outputs belong under the caller workspace current working directory, never under .iop unless the user explicitly requests that exact path.
|
|
PLAN path: %s
|
|
REVIEW path: %s
|
|
IOP will append the final Review handoff step and matching pending status itself.`, 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 prepareHotPathSelectorOutputLimit(tunnel edgeservice.SubmitProviderTunnelRequest) (edgeservice.SubmitProviderTunnelRequest, error) {
|
|
rewrite := func(body []byte) ([]byte, error) {
|
|
limitJSON, err := json.Marshal(maxHotPathSelectorOutputTokens)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plan, err := planTopLevelJSONPatches(body, []topLevelJSONPatch{
|
|
{name: "max_tokens", value: limitJSON},
|
|
{name: "max_completion_tokens"},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return plan.apply(), nil
|
|
}
|
|
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 prepareHotPathSelectorCanonicalTools(tunnel edgeservice.SubmitProviderTunnelRequest, instruction string, preset config.ExecutionPreset) (edgeservice.SubmitProviderTunnelRequest, error) {
|
|
operation := ""
|
|
switch {
|
|
case strings.Contains(instruction, "Operation: prepare-only"):
|
|
operation = "prepare"
|
|
case strings.Contains(instruction, "Operation: pair-write"):
|
|
operation = "write"
|
|
default:
|
|
return tunnel, fmt.Errorf("selector canonical tool operation is unavailable")
|
|
}
|
|
toolNames := make(map[string]struct{})
|
|
for _, alternative := range preset.WorkspaceTools {
|
|
configured, ok := alternative.Operations[operation]
|
|
if ok && strings.TrimSpace(configured.ToolName) != "" {
|
|
toolNames[strings.TrimSpace(configured.ToolName)] = struct{}{}
|
|
}
|
|
}
|
|
if len(toolNames) == 0 {
|
|
return tunnel, fmt.Errorf("selector canonical tool binding is unavailable")
|
|
}
|
|
rewrite := func(body []byte) ([]byte, error) {
|
|
var root map[string]any
|
|
if err := json.Unmarshal(body, &root); err != nil {
|
|
return nil, fmt.Errorf("decode selector provider tools: %w", err)
|
|
}
|
|
actual := make(map[string]map[string]any)
|
|
for _, raw := range anySlice(root["tools"]) {
|
|
tool, _ := raw.(map[string]any)
|
|
function, _ := tool["function"].(map[string]any)
|
|
name, _ := function["name"].(string)
|
|
if _, ok := toolNames[strings.TrimSpace(name)]; ok {
|
|
actual[strings.TrimSpace(name)] = function
|
|
}
|
|
}
|
|
if len(actual) == 0 {
|
|
return nil, fmt.Errorf("selector canonical tool is absent from caller tools")
|
|
}
|
|
if operation == "write" {
|
|
root["tools"] = []any{map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": hotPathArtifactPairToolName,
|
|
"description": "Return the compact fields used by IOP to render a Plan and pending Review pair.",
|
|
"parameters": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"goal": map[string]any{"type": "string", "description": "Concise task goal"},
|
|
"steps": map[string]any{
|
|
"type": "array", "description": "Executable worker steps", "items": map[string]any{"type": "string"}, "minItems": 1, "maxItems": 5,
|
|
},
|
|
"verification": map[string]any{
|
|
"type": "array", "description": "Observable pass checks", "items": map[string]any{"type": "string"}, "minItems": 1, "maxItems": 3,
|
|
},
|
|
},
|
|
"required": []any{"goal", "steps", "verification"},
|
|
"additionalProperties": false,
|
|
},
|
|
},
|
|
}}
|
|
root["tool_choice"] = "required"
|
|
delete(root, "parallel_tool_calls")
|
|
return json.Marshal(root)
|
|
}
|
|
names := make([]string, 0, len(actual))
|
|
for name := range actual {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
canonical := make([]any, 0, len(actual))
|
|
for _, name := range names {
|
|
function := actual[name]
|
|
properties := map[string]any{
|
|
"path": map[string]any{"type": "string", "description": "IOP-issued relative workspace path"},
|
|
}
|
|
required := []any{"path"}
|
|
if operation == "write" {
|
|
properties["content"] = map[string]any{"type": "string", "description": "Complete file content"}
|
|
required = append(required, "content")
|
|
}
|
|
description, _ := function["description"].(string)
|
|
canonical = append(canonical, map[string]any{
|
|
"type": "function",
|
|
"function": map[string]any{
|
|
"name": name, "description": strings.TrimSpace(description + " IOP canonical " + operation + " operation."),
|
|
"parameters": map[string]any{"type": "object", "properties": properties, "required": required, "additionalProperties": false},
|
|
},
|
|
})
|
|
}
|
|
root["tools"] = canonical
|
|
root["tool_choice"] = "required"
|
|
delete(root, "parallel_tool_calls")
|
|
return json.Marshal(root)
|
|
}
|
|
if tunnel.BuildBody == nil {
|
|
return tunnel, fmt.Errorf("selector provider body builder is unavailable")
|
|
}
|
|
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
|
|
}
|
|
|
|
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 {
|
|
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
|
|
}
|
|
toolCalls := anySlice(message["tool_calls"])
|
|
for index, rawCall := range toolCalls {
|
|
call, ok := rawCall.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Gemini's Chat-compatible stream may emit several complete tool
|
|
// calls in one delta without OpenAI's per-call index. Preserve their
|
|
// positional identity so the stream decoder does not concatenate
|
|
// independent argument objects into index zero.
|
|
if messageKey == "delta" && len(toolCalls) > 1 {
|
|
if _, present := call["index"]; !present {
|
|
call["index"] = index
|
|
changed = true
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
// prepareHotPathChatProviderOperation lets an internal caller-workspace stage
|
|
// use the closest provider wire without changing the caller-facing Chat
|
|
// surface. In particular, OpenAI Chat cannot combine reasoning effort with
|
|
// function tools, while the same profile's Responses operation can. The
|
|
// internal stage result is already decoded into the common hot-path shape, so
|
|
// this operation switch remains private to IOP.
|
|
func prepareHotPathChatProviderOperation(
|
|
tunnel edgeservice.SubmitProviderTunnelRequest,
|
|
selected edgeservice.ProviderPoolCandidate,
|
|
requirements providerRequestRequirements,
|
|
) (edgeservice.SubmitProviderTunnelRequest, error) {
|
|
if selected.ProtocolProfile == nil || tunnel.BuildBody == nil {
|
|
return tunnel, errProviderStageMissingBinding
|
|
}
|
|
prepared, err := singleRequestProviderTunnelPreparer(requirements, tunnel.BuildBody)(tunnel, selected)
|
|
if err != nil {
|
|
return tunnel, err
|
|
}
|
|
if prepared.Operation != string(config.OperationChatCompletions) {
|
|
return prepared, nil
|
|
}
|
|
prepared, err = prepareProviderChatRequestNormalization(prepared, selected)
|
|
if err != nil {
|
|
return tunnel, err
|
|
}
|
|
return prepareProviderChatToolCallNormalization(prepared, selected)
|
|
}
|
|
|
|
func chatProviderRequirements(req chatCompletionRequest) providerRequestRequirements {
|
|
requirements := providerRequestRequirements{
|
|
HasTools: len(req.Tools) > 0,
|
|
Stream: req.Stream,
|
|
StructuredOutput: req.ResponseFormat != nil,
|
|
}
|
|
if req.ReasoningEffort != nil {
|
|
requirements.Effort = strings.TrimSpace(*req.ReasoningEffort)
|
|
}
|
|
return requirements
|
|
}
|
|
|
|
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
|
|
}
|