Files
CLIProxyAPI/internal/runtime/executor/helps/gemini_interactions.go
sususu a68c7d8ed7 fix(signature): strip OpenRouter private RouterSuffix on official and non-OpenRouter upstreams
- Only preserve OpenRouter dot-delimited router suffixes when the upstream baseURL is openrouter.ai / *.openrouter.ai
- Strip RouterSuffix down to the native GPT/Grok/Meta envelope when forwarding to official tokens (chatgpt.com, api.openai.com, api.x.ai) or non-OpenRouter third-party providers (while keeping native api.meta.ai fail-closed against cross-caller envelopes)
- Strip RouterSuffix before storing GPT signatures in the Codex reasoning replay cache
2026-10-18 20:57:01 +08:00

244 lines
8.3 KiB
Go

package helps
import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
// GeminiInteractionsAPIRevision is the Api-Revision header required for the Interactions API.
GeminiInteractionsAPIRevision = "2026-05-20"
// StreamScannerBuffer sets the scanner buffer capacity for streaming response chunks (50MB).
StreamScannerBuffer = 52_428_800
)
// SanitizeGeminiInteractionsUnsupportedInputIDs aligns input step IDs with the
// official Gemini Interactions API schema:
// - `function_call` (FunctionCallStep) requires `id` and rejects `call_id`
// - `function_result` (FunctionResultStep) requires `call_id` and rejects `id`
// - other steps and content parts do not support `id`
func SanitizeGeminiInteractionsUnsupportedInputIDs(body []byte) []byte {
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body
}
items := input.Array()
kept := make([]string, 0, len(items))
changed := false
for _, item := range items {
rawItem := item.Raw
stepType := item.Get("type").String()
if stepType == "thought" {
sig := strings.TrimSpace(item.Get("signature").String())
if sig == "" {
sig = strings.TrimSpace(item.Get("thought_signature").String())
}
if sig == "" {
sig = strings.TrimSpace(item.Get("thoughtSignature").String())
}
normalizedSig, ok := signature.CompatibleSignatureForProviderBlock(
signature.SignatureProviderGemini,
sig,
signature.SignatureBlockKindGeminiModelPart,
)
if !ok || normalizedSig == "" || signature.IsGeminiThoughtSignatureBypass(signature.SignaturePayloadWithoutProviderPrefix(normalizedSig)) {
// Official Gemini Interactions API rejects thought steps without a
// valid Gemini signature (or with foreign/corrupt base64 signatures
// or "content" arrays).
changed = true
continue
}
if normalizedSig != item.Get("signature").String() {
rawItem, _ = sjson.Set(rawItem, "signature", normalizedSig)
changed = true
}
for _, unsupportedField := range []string{"id", "content", "text", "thought_signature", "thoughtSignature"} {
if gjson.Get(rawItem, unsupportedField).Exists() {
rawItem, _ = sjson.Delete(rawItem, unsupportedField)
changed = true
}
}
kept = append(kept, rawItem)
continue
}
if stepType == "function_call" {
if !item.Get("id").Exists() && item.Get("call_id").Exists() {
rawItem, _ = sjson.Set(rawItem, "id", item.Get("call_id").String())
changed = true
}
if item.Get("call_id").Exists() {
rawItem, _ = sjson.Delete(rawItem, "call_id")
changed = true
}
} else if item.Get("id").Exists() {
rawItem, _ = sjson.Delete(rawItem, "id")
changed = true
}
content := gjson.Get(rawItem, "content")
if content.IsArray() {
for j, part := range content.Array() {
if part.Get("id").Exists() {
rawItem, _ = sjson.Delete(rawItem, fmt.Sprintf("content.%d.id", j))
changed = true
}
}
}
kept = append(kept, rawItem)
}
if !changed {
return body
}
var b strings.Builder
b.WriteByte('[')
for i, raw := range kept {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(raw)
}
b.WriteByte(']')
updated, err := sjson.SetRawBytes(body, "input", []byte(b.String()))
if err != nil {
return body
}
return updated
}
// TranslateGeminiInteractionsRequestBody translates a request payload to FormatInteractions.
func TranslateGeminiInteractionsRequestBody(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) ([]byte, error) {
if opts.SourceFormat == "" || opts.SourceFormat == sdktranslator.FormatInteractions {
return bytes.Clone(payload), nil
}
return TranslateRequestReturningError(ctx, opts.Headers, cfg, opts.SourceFormat, sdktranslator.FormatInteractions, model, payload, stream, isCompat)
}
// TranslateGeminiInteractionsRequestPair translates the working payload and the
// payload-config baseline. Identical inputs are translated once, including plugin
// hooks. The baseline is captured before model and thinking mutations, and the
// working buffer is a separate copy so those mutations cannot change it.
func TranslateGeminiInteractionsRequestPair(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) (original, working []byte, err error) {
source := geminiInteractionsPayloadConfigInput(opts, payload)
if geminiInteractionsSameByteSlice(payload, source) {
original, err = TranslateGeminiInteractionsRequestBody(ctx, cfg, model, payload, opts, stream, isCompat)
return original, bytes.Clone(original), err
}
working, err = TranslateGeminiInteractionsRequestBody(ctx, cfg, model, payload, opts, stream, isCompat)
original, _ = geminiInteractionsPayloadConfigSource(ctx, cfg, model, payload, opts, stream, isCompat)
return original, working, err
}
func geminiInteractionsPayloadConfigSource(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) ([]byte, error) {
return TranslateGeminiInteractionsRequestBody(ctx, cfg, model, geminiInteractionsPayloadConfigInput(opts, payload), opts, stream, isCompat)
}
func geminiInteractionsPayloadConfigInput(opts cliproxyexecutor.Options, payload []byte) []byte {
if len(opts.OriginalRequest) == 0 {
return payload
}
return opts.OriginalRequest
}
func geminiInteractionsSameByteSlice(a, b []byte) bool {
if len(a) != len(b) {
return false
}
if len(a) == 0 {
return true
}
return &a[0] == &b[0]
}
// ApplyGeminiInteractionsThinking applies thinking/reasoning configuration to the interactions payload.
func ApplyGeminiInteractionsThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) ([]byte, error) {
fromFormat := opts.SourceFormat.String()
if strings.TrimSpace(fromFormat) == "" {
fromFormat = sdktranslator.FormatInteractions.String()
}
return ApplyRequestThinking(body, req, opts, fromFormat, sdktranslator.FormatInteractions.String(), "gemini")
}
// ApplyGeminiInteractionsRevisionHeader sets the default Api-Revision header on outgoing HTTP requests.
func ApplyGeminiInteractionsRevisionHeader(req *http.Request) {
if req == nil {
return
}
if req.Header.Get("Api-Revision") == "" {
req.Header.Set("Api-Revision", GeminiInteractionsAPIRevision)
}
}
// ApplyGeminiInteractionsRequestHeaders forwards any incoming Api-Revision header to upstream requests.
func ApplyGeminiInteractionsRequestHeaders(req *http.Request, headers http.Header) {
if req == nil || headers == nil || req.Header.Get("Api-Revision") != "" {
return
}
if revision := headers.Get("Api-Revision"); revision != "" {
req.Header.Set("Api-Revision", revision)
}
}
// GeminiInteractionsSSEPayload extracts SSE data payload from raw SSE frames.
func GeminiInteractionsSSEPayload(frame []byte) []byte {
trimmed := bytes.TrimSpace(frame)
if len(trimmed) == 0 {
return nil
}
if bytes.HasPrefix(trimmed, []byte("{")) {
return trimmed
}
lines := bytes.Split(frame, []byte{'\n'})
var payload []byte
for _, line := range lines {
line = bytes.TrimRight(line, "\r")
if !bytes.HasPrefix(bytes.TrimSpace(line), []byte("data:")) {
continue
}
data := bytes.TrimSpace(line[bytes.Index(line, []byte("data:"))+len("data:"):])
if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) {
continue
}
if len(payload) > 0 {
payload = append(payload, '\n')
}
payload = append(payload, data...)
}
if len(payload) == 0 {
return nil
}
return payload
}
// GeminiInteractionsSSEDone reports whether the SSE frame signals stream completion.
func GeminiInteractionsSSEDone(frame []byte) bool {
trimmed := bytes.TrimSpace(frame)
if bytes.Equal(trimmed, []byte("[DONE]")) {
return true
}
lines := bytes.Split(frame, []byte{'\n'})
sawDoneEvent := false
for _, line := range lines {
line = bytes.TrimSpace(bytes.TrimRight(line, "\r"))
if bytes.EqualFold(line, []byte("event: done")) {
sawDoneEvent = true
continue
}
if bytes.HasPrefix(line, []byte("data:")) {
data := bytes.TrimSpace(line[len("data:"):])
if bytes.Equal(data, []byte("[DONE]")) {
return true
}
}
}
return sawDoneEvent
}