mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-25 09:03:17 +08:00
- Track dropped attachments and reject emptied user turns during OpenAI Responses to Interactions translation - Register checked request translator for OpenAI Responses to Interactions format conversion
139 lines
7.3 KiB
Go
139 lines
7.3 KiB
Go
package executor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
|
|
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
|
|
)
|
|
|
|
const devinInteractionsHistory = `{"type":"user_input","content":[{"type":"text","text":"a"}]},{"type":"model_output","content":[{"type":"text","text":"b"}]}`
|
|
|
|
func devinInteractionsPayload(steps string) string {
|
|
return `{"input":[` + devinInteractionsHistory + `,` + steps + `]}`
|
|
}
|
|
|
|
func requireDevinUnsupportedPart(t *testing.T, err error, wantType string) {
|
|
t.Helper()
|
|
var unsupported *translatorcommon.UnsupportedPartError
|
|
if !errors.As(err, &unsupported) || unsupported.Type != wantType || unsupported.StatusCode() != 400 {
|
|
t.Fatalf("err = %v, want unsupported content part: %s", err, wantType)
|
|
}
|
|
if got, want := err.Error(), "unsupported content part: "+wantType; got != want {
|
|
t.Fatalf("error text = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// Devin cannot fetch a remote uri or take audio, video or documents, so a user
|
|
// turn that only holds such media is refused before any HTTP call, for both
|
|
// execution paths and for Interactions and non-Interactions sources.
|
|
func TestDevinExecutorRefusesAnUnsendableMediaOnlyTurnBeforeCallingUpstream(t *testing.T) {
|
|
var upstreamCalls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
upstreamCalls.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
const claudeWhitespaceImage = `{"max_tokens":8,"messages":[{"role":"user","content":"a"},{"role":"assistant","content":"b"},{"role":"user","content":[{"type":"text","text":" "},{"type":"image","source":{"type":"file","file_id":"f1"}}]}]}`
|
|
const geminiFile = `{"contents":[{"role":"user","parts":[{"text":"a"}]},{"role":"model","parts":[{"text":"b"}]},{"role":"user","parts":[{"fileData":{"mimeType":"image/png","fileUri":"gs://b/a.png"}}]}]}`
|
|
cases := []struct {
|
|
name string
|
|
source sdktranslator.Format
|
|
payload string
|
|
wantPart string
|
|
}{
|
|
{"image by uri", sdktranslator.FormatInteractions, devinInteractionsPayload(`{"type":"user_input","content":[{"type":"image","mime_type":"image/png","uri":"https://example.test/a.png"}]}`), "image"},
|
|
{"image by file_uri", sdktranslator.FormatInteractions, devinInteractionsPayload(`{"type":"user_input","content":[{"type":"image","mime_type":"image/png","file_uri":"gs://b/a.png"}]}`), "image"},
|
|
{"document by fileUri", sdktranslator.FormatInteractions, devinInteractionsPayload(`{"type":"user_input","content":[{"type":"document","mime_type":"application/pdf","fileUri":"gs://b/a.pdf"}]}`), "document"},
|
|
{"inline audio", sdktranslator.FormatInteractions, devinInteractionsPayload(`{"type":"user_input","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="}]}`), "audio"},
|
|
{"whitespace text does not hide it", sdktranslator.FormatInteractions, devinInteractionsPayload(`{"type":"user_input","content":[{"type":"text","text":" \n"},{"type":"video","mime_type":"video/mp4","uri":"https://example.test/a.mp4"}]}`), "video"},
|
|
{"emptied turn before a later text turn", sdktranslator.FormatInteractions, `{"input":[{"type":"user_input","content":[{"type":"image","mime_type":"image/png","uri":"https://example.test/a.png"}]},{"type":"model_output","content":[{"type":"text","text":"ok"}]},{"type":"user_input","content":[{"type":"text","text":"next"}]}]}`, "image"},
|
|
{"gemini fileData through interactions", sdktranslator.FormatGemini, geminiFile, "image"},
|
|
{"claude whitespace text beside an unrepresentable image", sdktranslator.FormatClaude, claudeWhitespaceImage, "image"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
exec := NewDevinExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{ID: "devin-media-only", Provider: "devin", Attributes: map[string]string{"api_key": "test-key", "base_url": server.URL}}
|
|
req := cliproxyexecutor.Request{Model: "devin/swe-2", Payload: []byte(tc.payload)}
|
|
opts := cliproxyexecutor.Options{SourceFormat: tc.source}
|
|
|
|
_, errExecute := exec.Execute(context.Background(), auth, req, opts)
|
|
requireDevinUnsupportedPart(t, errExecute, tc.wantPart)
|
|
opts.Stream = true
|
|
_, errStream := exec.ExecuteStream(context.Background(), auth, req, opts)
|
|
requireDevinUnsupportedPart(t, errStream, tc.wantPart)
|
|
_, _, _, errPrepare := exec.prepareDevinHTTPRequest(context.Background(), auth, req, opts)
|
|
requireDevinUnsupportedPart(t, errPrepare, tc.wantPart)
|
|
})
|
|
}
|
|
if got := upstreamCalls.Load(); got != 0 {
|
|
t.Fatalf("upstream was called %d times, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestDevinExecutorSendsTextBesideUnsendableMediaWithoutAPlaceholder(t *testing.T) {
|
|
exec := NewDevinExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{ID: "devin-text-beside-media", Provider: "devin", Attributes: map[string]string{"api_key": "test-key"}}
|
|
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatInteractions}
|
|
cases := map[string]string{
|
|
"same step": `{"type":"user_input","content":[{"type":"text","text":"keep me"},{"type":"image","mime_type":"image/png","uri":"https://example.test/a.png"}]}`,
|
|
"neighbouring step": `{"type":"user_input","content":[{"type":"text","text":"keep me"}]},{"type":"user_input","content":[{"type":"image","mime_type":"image/png","uri":"https://example.test/a.png"}]}`,
|
|
}
|
|
for name, steps := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
req := cliproxyexecutor.Request{Model: "devin/swe-2", Payload: []byte(devinInteractionsPayload(steps))}
|
|
httpReq, _, _, err := exec.prepareDevinHTTPRequest(context.Background(), auth, req, opts)
|
|
if err != nil {
|
|
t.Fatalf("prepareDevinHTTPRequest: %v", err)
|
|
}
|
|
body, errRead := io.ReadAll(httpReq.Body)
|
|
if errRead != nil {
|
|
t.Fatalf("read body: %v", errRead)
|
|
}
|
|
if !bytes.Contains(body, []byte("keep me")) {
|
|
t.Fatalf("text was lost from the request")
|
|
}
|
|
for _, forbidden := range []string{"example.test", "omitted", "[Image"} {
|
|
if bytes.Contains(body, []byte(forbidden)) {
|
|
t.Fatalf("request mentions %q although the media was not sent", forbidden)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDevinExecutorStillSendsInlineImages(t *testing.T) {
|
|
exec := NewDevinExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{ID: "devin-inline-image", Provider: "devin", Attributes: map[string]string{"api_key": "test-key"}}
|
|
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatInteractions}
|
|
for _, part := range []string{
|
|
`{"type":"image","mime_type":"image/png","data":"aGVsbG8="}`,
|
|
`{"type":"image","mime_type":"image/png","uri":"data:image/png;base64,aGVsbG8="}`,
|
|
} {
|
|
req := cliproxyexecutor.Request{Model: "devin/swe-2", Payload: []byte(devinInteractionsPayload(`{"type":"user_input","content":[` + part + `]}`))}
|
|
httpReq, _, _, err := exec.prepareDevinHTTPRequest(context.Background(), auth, req, opts)
|
|
if err != nil {
|
|
t.Fatalf("prepareDevinHTTPRequest(%s): %v", part, err)
|
|
}
|
|
body, errRead := io.ReadAll(httpReq.Body)
|
|
if errRead != nil {
|
|
t.Fatalf("read body: %v", errRead)
|
|
}
|
|
if !bytes.Contains(body, []byte("pasted_image_1.png")) {
|
|
t.Fatalf("inline image was not sent for %s", part)
|
|
}
|
|
}
|
|
}
|