Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -0,0 +1,204 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/gemini"
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func ConvertOpenAIResponsesRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
rawJSON = ConvertOpenAIResponsesRequestToGemini(modelName, rawJSON, stream)
|
||||
rawJSON = rewriteOpenAIResponsesReasoningForAntigravityClaude(modelName, inputRawJSON, rawJSON)
|
||||
return ConvertGeminiRequestToAntigravity(modelName, rawJSON, stream)
|
||||
}
|
||||
|
||||
type antigravityClaudeReasoningSignature struct {
|
||||
Signature string
|
||||
HasRawSignature bool
|
||||
RawSignatureLen int
|
||||
DetectedProvider sigcompat.SignatureProvider
|
||||
}
|
||||
|
||||
func rewriteOpenAIResponsesReasoningForAntigravityClaude(modelName string, inputRawJSON, geminiJSON []byte) []byte {
|
||||
if sigcompat.SignatureProviderFromModelName(modelName) != sigcompat.SignatureProviderClaude {
|
||||
return geminiJSON
|
||||
}
|
||||
|
||||
reasoningSignatures := antigravityClaudeReasoningSignatures(inputRawJSON)
|
||||
if len(reasoningSignatures) == 0 {
|
||||
return geminiJSON
|
||||
}
|
||||
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(geminiJSON, &root); err != nil {
|
||||
log.WithError(err).Debug("antigravity responses translator: failed to parse Gemini request for Claude signature rewrite")
|
||||
return geminiJSON
|
||||
}
|
||||
|
||||
contents, ok := root["contents"].([]any)
|
||||
if !ok {
|
||||
return geminiJSON
|
||||
}
|
||||
|
||||
reasoningIndex := 0
|
||||
changed := false
|
||||
rewrittenContents := make([]any, 0, len(contents))
|
||||
for contentIndex, contentValue := range contents {
|
||||
content, ok := contentValue.(map[string]any)
|
||||
if !ok {
|
||||
rewrittenContents = append(rewrittenContents, contentValue)
|
||||
continue
|
||||
}
|
||||
|
||||
parts, ok := content["parts"].([]any)
|
||||
if !ok {
|
||||
rewrittenContents = append(rewrittenContents, content)
|
||||
continue
|
||||
}
|
||||
|
||||
rewrittenParts := make([]any, 0, len(parts))
|
||||
for partIndex, partValue := range parts {
|
||||
part, ok := partValue.(map[string]any)
|
||||
if !ok || part["thought"] != true {
|
||||
rewrittenParts = append(rewrittenParts, partValue)
|
||||
continue
|
||||
}
|
||||
|
||||
var reasoningSig antigravityClaudeReasoningSignature
|
||||
if reasoningIndex < len(reasoningSignatures) {
|
||||
reasoningSig = reasoningSignatures[reasoningIndex]
|
||||
}
|
||||
reasoningIndex++
|
||||
|
||||
if reasoningSig.Signature == "" {
|
||||
changed = true
|
||||
logDroppedOpenAIResponsesAntigravityClaudeReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
|
||||
continue
|
||||
}
|
||||
if text, _ := part["text"].(string); strings.TrimSpace(text) == "" {
|
||||
changed = true
|
||||
logDroppedOpenAIResponsesAntigravityClaudeEmptyReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
|
||||
continue
|
||||
}
|
||||
|
||||
if currentSignature, _ := part["thoughtSignature"].(string); currentSignature != reasoningSig.Signature {
|
||||
changed = true
|
||||
logNormalizedOpenAIResponsesAntigravityClaudeReasoning(modelName, contentIndex, partIndex, reasoningIndex-1, reasoningSig)
|
||||
}
|
||||
part["thoughtSignature"] = reasoningSig.Signature
|
||||
rewrittenParts = append(rewrittenParts, part)
|
||||
}
|
||||
|
||||
if len(rewrittenParts) == 0 {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
content["parts"] = rewrittenParts
|
||||
rewrittenContents = append(rewrittenContents, content)
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return geminiJSON
|
||||
}
|
||||
|
||||
root["contents"] = rewrittenContents
|
||||
out, err := json.Marshal(root)
|
||||
if err != nil {
|
||||
log.WithError(err).Debug("antigravity responses translator: failed to marshal Claude signature rewrite")
|
||||
return geminiJSON
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func antigravityClaudeReasoningSignatures(inputRawJSON []byte) []antigravityClaudeReasoningSignature {
|
||||
input := gjson.GetBytes(inputRawJSON, "input")
|
||||
if !input.IsArray() {
|
||||
return nil
|
||||
}
|
||||
|
||||
signatures := make([]antigravityClaudeReasoningSignature, 0)
|
||||
input.ForEach(func(_, item gjson.Result) bool {
|
||||
itemType := item.Get("type").String()
|
||||
if itemType == "" && item.Get("role").Exists() {
|
||||
itemType = "message"
|
||||
}
|
||||
if itemType != "reasoning" {
|
||||
return true
|
||||
}
|
||||
|
||||
rawSignatureResult := item.Get("encrypted_content")
|
||||
rawSignature := rawSignatureResult.String()
|
||||
signature, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSignature)
|
||||
reasoningSignature := antigravityClaudeReasoningSignature{
|
||||
HasRawSignature: rawSignatureResult.Exists(),
|
||||
RawSignatureLen: len(rawSignature),
|
||||
DetectedProvider: sigcompat.SignatureProviderUnknown,
|
||||
}
|
||||
if rawSignature != "" {
|
||||
reasoningSignature.DetectedProvider = sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking)
|
||||
}
|
||||
if ok {
|
||||
reasoningSignature.Signature = signature
|
||||
}
|
||||
signatures = append(signatures, reasoningSignature)
|
||||
return true
|
||||
})
|
||||
return signatures
|
||||
}
|
||||
|
||||
func logDroppedOpenAIResponsesAntigravityClaudeReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "signature_sanitizer",
|
||||
"translator": "antigravity_openai_responses",
|
||||
"target_provider": string(sigcompat.SignatureProviderClaude),
|
||||
"action": "drop_thinking_block",
|
||||
"reason": "missing_or_incompatible_signature",
|
||||
"model": modelName,
|
||||
"content_index": contentIndex,
|
||||
"part_index": partIndex,
|
||||
"reasoning_index": reasoningIndex,
|
||||
"has_signature": sig.HasRawSignature,
|
||||
"signature_length": sig.RawSignatureLen,
|
||||
"detected_provider": string(sig.DetectedProvider),
|
||||
}).Debug("antigravity responses translator: dropped Claude reasoning block with incompatible encrypted_content")
|
||||
}
|
||||
|
||||
func logDroppedOpenAIResponsesAntigravityClaudeEmptyReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "signature_sanitizer",
|
||||
"translator": "antigravity_openai_responses",
|
||||
"target_provider": string(sigcompat.SignatureProviderClaude),
|
||||
"action": "drop_thinking_block",
|
||||
"reason": "empty_thinking_text",
|
||||
"model": modelName,
|
||||
"content_index": contentIndex,
|
||||
"part_index": partIndex,
|
||||
"reasoning_index": reasoningIndex,
|
||||
"has_signature": sig.HasRawSignature,
|
||||
"signature_length": sig.RawSignatureLen,
|
||||
"detected_provider": string(sig.DetectedProvider),
|
||||
}).Debug("antigravity responses translator: dropped Claude reasoning block with empty thinking text")
|
||||
}
|
||||
|
||||
func logNormalizedOpenAIResponsesAntigravityClaudeReasoning(modelName string, contentIndex, partIndex, reasoningIndex int, sig antigravityClaudeReasoningSignature) {
|
||||
log.WithFields(log.Fields{
|
||||
"component": "signature_sanitizer",
|
||||
"translator": "antigravity_openai_responses",
|
||||
"target_provider": string(sigcompat.SignatureProviderClaude),
|
||||
"action": "normalize_signature",
|
||||
"reason": "compatible_claude_signature",
|
||||
"model": modelName,
|
||||
"content_index": contentIndex,
|
||||
"part_index": partIndex,
|
||||
"reasoning_index": reasoningIndex,
|
||||
"has_signature": sig.HasRawSignature,
|
||||
"signature_length": sig.RawSignatureLen,
|
||||
"detected_provider": string(sig.DetectedProvider),
|
||||
}).Debug("antigravity responses translator: normalized Claude reasoning encrypted_content before upstream")
|
||||
}
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningKeepsClaudeSignature(t *testing.T) {
|
||||
nativeSig := testAntigravityResponsesClaudeSignature(t)
|
||||
antigravitySig, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(nativeSig)
|
||||
if !ok {
|
||||
t.Fatal("test Claude signature should be compatible with Antigravity Claude")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
encrypted string
|
||||
}{
|
||||
{
|
||||
name: "Claude native E signature",
|
||||
encrypted: nativeSig,
|
||||
},
|
||||
{
|
||||
name: "Antigravity double-layer R signature",
|
||||
encrypted: antigravitySig,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"model": "claude-opus-4-6-thinking",
|
||||
"input": [
|
||||
{
|
||||
"id": "rs_prev",
|
||||
"type": "reasoning",
|
||||
"encrypted_content": "` + tt.encrypted + `",
|
||||
"summary": [{"type": "summary_text", "text": "internal reasoning"}]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "visible answer"}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "continue"}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
|
||||
part := gjson.GetBytes(out, "request.contents.0.parts.0")
|
||||
if !part.Get("thought").Bool() {
|
||||
t.Fatalf("first part should remain a thought block. Output: %s", out)
|
||||
}
|
||||
if got := part.Get("thoughtSignature").String(); got != antigravitySig {
|
||||
t.Fatalf("thoughtSignature prefix/len = %q/%d, want %q/%d. Output: %s",
|
||||
firstByte(got), len(got), firstByte(antigravitySig), len(antigravitySig), out)
|
||||
}
|
||||
if got := part.Get("text").String(); got != "internal reasoning" {
|
||||
t.Fatalf("thought text = %q, want internal reasoning. Output: %s", got, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsIncompatibleSignature(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"model": "claude-opus-4-6-thinking",
|
||||
"input": [
|
||||
{
|
||||
"id": "rs_prev",
|
||||
"type": "reasoning",
|
||||
"encrypted_content": "` + testAntigravityResponsesGPTSignature() + `",
|
||||
"summary": [{"type": "summary_text", "text": "must not reach Claude"}]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "visible answer"}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "continue"}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
|
||||
if strings.Contains(string(out), sigcompat.GeminiSkipThoughtSignatureValidator) {
|
||||
t.Fatalf("Claude target must not receive Gemini bypass signature. Output: %s", out)
|
||||
}
|
||||
if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
|
||||
t.Fatalf("incompatible reasoning block should be dropped. Output: %s", out)
|
||||
}
|
||||
if strings.Contains(string(out), "must not reach Claude") {
|
||||
t.Fatalf("incompatible reasoning text should be dropped. Output: %s", out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
|
||||
t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsEmptyThinkingText(t *testing.T) {
|
||||
rawSignature := testAntigravityResponsesClaudeSignature(t)
|
||||
raw := []byte(`{
|
||||
"model": "claude-opus-4-6-thinking",
|
||||
"input": [
|
||||
{
|
||||
"id": "rs_prev",
|
||||
"type": "reasoning",
|
||||
"encrypted_content": "` + rawSignature + `",
|
||||
"summary": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "visible answer"}]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "continue"}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
|
||||
if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
|
||||
t.Fatalf("empty-text reasoning block should be dropped for Antigravity Claude. Output: %s", out)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
|
||||
t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func testAntigravityResponsesClaudeSignature(t *testing.T) string {
|
||||
t.Helper()
|
||||
return testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6")
|
||||
}
|
||||
|
||||
func testAntigravityResponsesClaudeSignatureForModel(t *testing.T, model string) string {
|
||||
t.Helper()
|
||||
channelBlock := []byte{}
|
||||
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
|
||||
channelBlock = protowire.AppendVarint(channelBlock, 12)
|
||||
channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
|
||||
channelBlock = protowire.AppendVarint(channelBlock, 2)
|
||||
channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
|
||||
channelBlock = protowire.AppendString(channelBlock, model)
|
||||
|
||||
container := []byte{}
|
||||
container = protowire.AppendTag(container, 1, protowire.BytesType)
|
||||
container = protowire.AppendBytes(container, channelBlock)
|
||||
|
||||
payload := []byte{}
|
||||
payload = protowire.AppendTag(payload, 2, protowire.BytesType)
|
||||
payload = protowire.AppendBytes(payload, container)
|
||||
payload = protowire.AppendTag(payload, 3, protowire.VarintType)
|
||||
payload = protowire.AppendVarint(payload, 1)
|
||||
return base64.StdEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func testAntigravityResponsesGPTSignature() string {
|
||||
payload := make([]byte, 1+8+16+16+32)
|
||||
payload[0] = 0x80
|
||||
payload[8] = 1
|
||||
for i := 9; i < len(payload); i++ {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func firstByte(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return s[:1]
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_EmptyClaudeReasoningDoesNotShiftLaterSignature(t *testing.T) {
|
||||
rawSig1 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6")
|
||||
rawSig2 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-opus-4-6")
|
||||
expectedSig2, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSig2)
|
||||
if !ok {
|
||||
t.Fatal("second Claude signature should be compatible")
|
||||
}
|
||||
raw := []byte(`{
|
||||
"model":"claude-opus-4-6-thinking",
|
||||
"input":[
|
||||
{"type":"reasoning","encrypted_content":"` + rawSig1 + `","summary":[]},
|
||||
{"role":"user","content":[{"type":"input_text","text":"boundary"}]},
|
||||
{"type":"reasoning","encrypted_content":"` + rawSig2 + `","summary":[{"type":"summary_text","text":"second reasoning"}]},
|
||||
{"role":"user","content":[{"type":"input_text","text":"continue"}]}
|
||||
]
|
||||
}`)
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
|
||||
var thoughts []gjson.Result
|
||||
for _, content := range gjson.GetBytes(out, "request.contents").Array() {
|
||||
for _, part := range content.Get("parts").Array() {
|
||||
if part.Get("thought").Bool() {
|
||||
thoughts = append(thoughts, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(thoughts) != 1 {
|
||||
t.Fatalf("thought count = %d, want only the non-empty reasoning item. Output: %s", len(thoughts), out)
|
||||
}
|
||||
if got := thoughts[0].Get("text").String(); got != "second reasoning" {
|
||||
t.Fatalf("thought text = %q, want second reasoning. Output: %s", got, out)
|
||||
}
|
||||
if got := thoughts[0].Get("thoughtSignature").String(); got != expectedSig2 {
|
||||
t.Fatalf("later thought received the wrong signature prefix/len = %q/%d, want %q/%d. Output: %s", firstByte(got), len(got), firstByte(expectedSig2), len(expectedSig2), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_EmptyClaudeReasoningBeforeFunctionDoesNotShiftLaterSignature(t *testing.T) {
|
||||
rawSig1 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6")
|
||||
rawSig2 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-opus-4-6")
|
||||
expectedSig2, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSig2)
|
||||
if !ok {
|
||||
t.Fatal("second Claude signature should be compatible")
|
||||
}
|
||||
raw := []byte(`{
|
||||
"model":"claude-opus-4-6-thinking",
|
||||
"input":[
|
||||
{"type":"reasoning","encrypted_content":"` + rawSig1 + `","summary":[]},
|
||||
{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call-1","output":"ok"},
|
||||
{"type":"reasoning","encrypted_content":"` + rawSig2 + `","summary":[{"type":"summary_text","text":"second reasoning"}]},
|
||||
{"role":"user","content":[{"type":"input_text","text":"continue"}]}
|
||||
]
|
||||
}`)
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
|
||||
var thoughts []gjson.Result
|
||||
for _, content := range gjson.GetBytes(out, "request.contents").Array() {
|
||||
for _, part := range content.Get("parts").Array() {
|
||||
if part.Get("thought").Bool() {
|
||||
thoughts = append(thoughts, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(thoughts) != 1 || thoughts[0].Get("text").String() != "second reasoning" {
|
||||
t.Fatalf("later reasoning placement malformed. Output: %s", out)
|
||||
}
|
||||
if got := thoughts[0].Get("thoughtSignature").String(); got != expectedSig2 {
|
||||
t.Fatalf("later thought received the wrong signature prefix/len = %q/%d, want %q/%d. Output: %s", firstByte(got), len(got), firstByte(expectedSig2), len(expectedSig2), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_GeminiReasoningUsesNativeThoughtSignaturePlacement(t *testing.T) {
|
||||
sig := "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
|
||||
raw := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"reasoning","encrypted_content":"gemini#` + sig + `","summary":[{"type":"summary_text","text":"reasoning summary"}]}]}`)
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash-agent", raw, false)
|
||||
parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("parts length = %d, want 1. Output: %s", len(parts), out)
|
||||
}
|
||||
if got := parts[0].Get("thought").Bool(); !got {
|
||||
t.Fatalf("parts[0] should be thought. Output: %s", out)
|
||||
}
|
||||
if got := parts[0].Get("thoughtSignature").String(); got != sig {
|
||||
t.Fatalf("parts[0].thoughtSignature = %q, want preserved Gemini signature. Output: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_PreservesToolResultImage(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"input": [
|
||||
{"role": "user", "content": [{"type": "input_text", "text": "请帮我读取分析这张图片"}]},
|
||||
{"type": "function_call", "id": "fc_read", "call_id": "call_read_1", "name": "read", "arguments": "{\"path\":\"/path/to/image.png\"}"},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_read_1",
|
||||
"output": [
|
||||
{"type": "input_text", "text": "Read image file [image/png]"},
|
||||
{"type": "input_image", "detail": "auto", "image_url": "data:image/png;base64,QUJD"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
|
||||
contents := gjson.GetBytes(out, "request.contents").Array()
|
||||
if len(contents) != 3 {
|
||||
t.Fatalf("expected 3 contents, got %d. Output: %s", len(contents), out)
|
||||
}
|
||||
funcContent := contents[2]
|
||||
if got := funcContent.Get("role").String(); got != "user" {
|
||||
t.Fatalf("role = %q, want user. Output: %s", got, out)
|
||||
}
|
||||
funcResp := funcContent.Get("parts.0.functionResponse")
|
||||
if !funcResp.Exists() {
|
||||
t.Fatalf("functionResponse should exist. Output: %s", out)
|
||||
}
|
||||
if got := funcResp.Get("id").String(); got != "call_read_1" {
|
||||
t.Fatalf("id = %q, want call_read_1", got)
|
||||
}
|
||||
if got := funcResp.Get("name").String(); got != "read" {
|
||||
t.Fatalf("name = %q, want read", got)
|
||||
}
|
||||
inlineData := funcResp.Get("parts.0.inlineData")
|
||||
if !inlineData.Exists() {
|
||||
t.Fatalf("expected functionResponse.parts.0.inlineData to exist, got: %s", out)
|
||||
}
|
||||
if got := inlineData.Get("mimeType").String(); got != "image/png" {
|
||||
t.Errorf("expected mimeType image/png, got %q", got)
|
||||
}
|
||||
if got := inlineData.Get("data").String(); got != "QUJD" {
|
||||
t.Errorf("expected data QUJD, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_AttachesParallelToolImagesToNearestResponse(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"input": [
|
||||
{"role": "user", "content": [{"type": "input_text", "text": "read both"}]},
|
||||
{"type": "function_call", "id": "fc_a", "call_id": "call_a", "name": "read", "arguments": "{\"path\":\"/tmp/a.png\"}"},
|
||||
{"type": "function_call", "id": "fc_b", "call_id": "call_b", "name": "read", "arguments": "{\"path\":\"/tmp/b.png\"}"},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_a",
|
||||
"output": [
|
||||
{"type": "input_text", "text": "file A"},
|
||||
{"type": "input_image", "image_url": "data:image/png;base64,AAA"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_b",
|
||||
"output": [
|
||||
{"type": "input_text", "text": "file B"},
|
||||
{"type": "input_image", "image_url": "data:image/jpeg;base64,BBB"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
|
||||
parts := gjson.GetBytes(out, "request.contents.2.parts").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("function parts = %d, want 2. Output: %s", len(parts), out)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, part := range parts {
|
||||
fr := part.Get("functionResponse")
|
||||
got[fr.Get("id").String()] = fr.Get("parts.0.inlineData.data").String()
|
||||
}
|
||||
if got["call_a"] != "AAA" {
|
||||
t.Fatalf("call_a image = %q, want AAA. Output: %s", got["call_a"], out)
|
||||
}
|
||||
if got["call_b"] != "BBB" {
|
||||
t.Fatalf("call_b image = %q, want BBB. Output: %s", got["call_b"], out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToAntigravity_PreservesAdditionalToolsAndToolConfig(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"input": [
|
||||
{
|
||||
"type": "additional_tools",
|
||||
"tools": [
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "functions",
|
||||
"tools": [
|
||||
{"type": "custom", "name": "exec", "description": "Execute a command"},
|
||||
{"type": "function", "name": "continuity_probe", "description": "Probe", "parameters": {"type": "object", "properties": {"value": {"type": "string"}}, "required": ["value"]}}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{"role": "user", "content": [{"type": "input_text", "text": "test"}]}
|
||||
],
|
||||
"tool_choice": {
|
||||
"type": "function",
|
||||
"name": "continuity_probe",
|
||||
"namespace": "functions"
|
||||
}
|
||||
}`
|
||||
|
||||
out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
|
||||
if !gjson.ValidBytes(out) {
|
||||
t.Fatalf("invalid JSON output: %s", out)
|
||||
}
|
||||
|
||||
decls := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array()
|
||||
if len(decls) != 2 {
|
||||
t.Fatalf("expected 2 functionDeclarations in request.tools, got %d; raw: %s", len(decls), out)
|
||||
}
|
||||
|
||||
mode := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String()
|
||||
if mode != "ANY" {
|
||||
t.Fatalf("mode = %q, want ANY", mode)
|
||||
}
|
||||
allowed := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String()
|
||||
if allowed != "functions__continuity_probe" {
|
||||
t.Fatalf("allowedFunctionNames.0 = %q, want functions__continuity_probe", allowed)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/openai/responses"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func ConvertAntigravityResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
responseResult := gjson.GetBytes(rawJSON, "response")
|
||||
if responseResult.Exists() {
|
||||
rawJSON = []byte(responseResult.Raw)
|
||||
}
|
||||
return ConvertGeminiResponseToOpenAIResponses(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)
|
||||
}
|
||||
|
||||
func ConvertAntigravityResponseToOpenAIResponsesNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
|
||||
responseResult := gjson.GetBytes(rawJSON, "response")
|
||||
if responseResult.Exists() {
|
||||
rawJSON = []byte(responseResult.Raw)
|
||||
}
|
||||
|
||||
requestResult := gjson.GetBytes(originalRequestRawJSON, "request")
|
||||
if requestResult.Exists() {
|
||||
originalRequestRawJSON = []byte(requestResult.Raw)
|
||||
}
|
||||
|
||||
requestResult = gjson.GetBytes(requestRawJSON, "request")
|
||||
if requestResult.Exists() {
|
||||
requestRawJSON = []byte(requestResult.Raw)
|
||||
}
|
||||
|
||||
return ConvertGeminiResponseToOpenAIResponsesNonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertAntigravityResponseToOpenAIResponsesNonStream_PreservesOpenAITools(t *testing.T) {
|
||||
originalRequest := []byte(`{
|
||||
"model": "gemini-3.5-flash-low",
|
||||
"input": "Call get_weather for Tokyo.",
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"description": "Get weather for a city",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"]
|
||||
}
|
||||
}],
|
||||
"tool_choice": "required"
|
||||
}`)
|
||||
translatedRequest := []byte(`{
|
||||
"request": {
|
||||
"model": "gemini-3.5-flash-low",
|
||||
"tools": [{
|
||||
"functionDeclarations": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather for a city",
|
||||
"parameters": {
|
||||
"type": "OBJECT",
|
||||
"properties": {"city": {"type": "STRING"}},
|
||||
"required": ["city"]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}`)
|
||||
rawResponse := []byte(`{
|
||||
"response": {
|
||||
"responseId": "antigravity-tool-response",
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [{
|
||||
"functionCall": {
|
||||
"name": "get_weather",
|
||||
"args": {"city": "Tokyo"}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}]
|
||||
}
|
||||
}`)
|
||||
|
||||
output := ConvertAntigravityResponseToOpenAIResponsesNonStream(
|
||||
context.Background(),
|
||||
"gemini-3.5-flash-low",
|
||||
originalRequest,
|
||||
translatedRequest,
|
||||
rawResponse,
|
||||
nil,
|
||||
)
|
||||
|
||||
if !gjson.ValidBytes(output) {
|
||||
t.Fatalf("converter returned invalid JSON: %s", output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "tools.0.type").String(); got != "function" {
|
||||
t.Fatalf("tools.0.type = %q, want function; output=%s", got, output)
|
||||
}
|
||||
if gjson.GetBytes(output, "tools.0.functionDeclarations").Exists() {
|
||||
t.Fatalf("OpenAI response contains Gemini-native functionDeclarations: %s", output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.type").String(); got != "function_call" {
|
||||
t.Fatalf("output.0.type = %q, want function_call; output=%s", got, output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.name").String(); got != "get_weather" {
|
||||
t.Fatalf("output.0.name = %q, want get_weather; output=%s", got, output)
|
||||
}
|
||||
arguments := gjson.GetBytes(output, "output.0.arguments").String()
|
||||
if !gjson.Valid(arguments) || gjson.Get(arguments, "city").String() != "Tokyo" {
|
||||
t.Fatalf("output.0.arguments = %q, want JSON arguments with city Tokyo; output=%s", arguments, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertAntigravityResponseToOpenAIResponses_RestoresAdditionalNamespaceCustomToolCall(t *testing.T) {
|
||||
originalRequest := []byte(`{
|
||||
"model": "gemini-3.5-flash-low",
|
||||
"input": [{
|
||||
"type": "additional_tools",
|
||||
"tools": [{
|
||||
"type": "namespace",
|
||||
"name": "functions",
|
||||
"tools": [{"type": "custom", "name": "exec"}]
|
||||
}]
|
||||
}]
|
||||
}`)
|
||||
rawResponse := []byte(`{
|
||||
"response": {
|
||||
"responseId": "antigravity-custom-response",
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [{
|
||||
"functionCall": {
|
||||
"name": "functions__exec",
|
||||
"args": {"input": "pwd"}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}]
|
||||
}
|
||||
}`)
|
||||
|
||||
output := ConvertAntigravityResponseToOpenAIResponsesNonStream(
|
||||
context.Background(),
|
||||
"gemini-3.5-flash-low",
|
||||
originalRequest,
|
||||
nil,
|
||||
rawResponse,
|
||||
nil,
|
||||
)
|
||||
|
||||
if !gjson.ValidBytes(output) {
|
||||
t.Fatalf("invalid JSON output: %s", output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.type").String(); got != "custom_tool_call" {
|
||||
t.Fatalf("output.0.type = %q, want custom_tool_call; output=%s", got, output)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.name").String(); got != "exec" {
|
||||
t.Fatalf("output.0.name = %q, want exec", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.namespace").String(); got != "functions" {
|
||||
t.Fatalf("output.0.namespace = %q, want functions", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "output.0.input").String(); got != "pwd" {
|
||||
t.Fatalf("output.0.input = %q, want pwd", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
OpenaiResponse,
|
||||
Antigravity,
|
||||
ConvertOpenAIResponsesRequestToAntigravity,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertAntigravityResponseToOpenAIResponses,
|
||||
NonStream: ConvertAntigravityResponseToOpenAIResponsesNonStream,
|
||||
},
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue