Files
2026-07-13 12:35:43 +08:00

308 lines
11 KiB
Go

package auth
import (
"bytes"
"github.com/tidwall/gjson"
"strings"
"testing"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
)
func TestStreamRewriter_RewriteChunk_KimiMessagesDataPrefixWithoutSpace(t *testing.T) {
rewriter := NewStreamRewriter(StreamRewriteOptions{RewriteModel: "k2.5"})
chunk := []byte("event:message_start\n" +
`data:{"type":"message_start","message":{"model":"kimi-k2.5"}}` + "\n\n")
got := string(rewriter.RewriteChunk(chunk))
if !strings.Contains(got, `"model":"k2.5"`) {
t.Fatalf("rewritten chunk = %q, want alias model k2.5", got)
}
if strings.Contains(got, "kimi-k2.5") {
t.Fatalf("rewritten chunk still contains upstream model: %q", got)
}
if !strings.Contains(got, "data:{") {
t.Fatalf("rewritten chunk should preserve data: prefix without space: %q", got)
}
}
func TestStreamRewriter_RewriteChunk_AnthropicMessagesDataPrefixWithSpace(t *testing.T) {
rewriter := NewStreamRewriter(StreamRewriteOptions{RewriteModel: "grok-latest"})
chunk := []byte(`data: {"type":"message_start","message":{"model":"grok-4.3"}}` + "\n\n")
got := string(rewriter.RewriteChunk(chunk))
if !strings.Contains(got, `"model":"grok-latest"`) {
t.Fatalf("rewritten chunk = %q, want alias model grok-latest", got)
}
if strings.Contains(got, "grok-4.3") {
t.Fatalf("rewritten chunk still contains upstream model: %q", got)
}
if !strings.Contains(got, "data: {") {
t.Fatalf("rewritten chunk should preserve spaced data: prefix: %q", got)
}
}
func TestStreamRewriter_Finish_FlushesCodexResponsesEventChunk(t *testing.T) {
rewriter := NewStreamRewriter(StreamRewriteOptions{RewriteModel: "gpt-5.4-fast"})
part1 := []byte("event: response.created\n")
part2 := []byte(`data: {"type":"response.created","response":{"model":"gpt-5.4"}}` + "\n\n")
got1 := rewriter.RewriteChunk(part1)
if got1 != nil {
t.Fatalf("first partial chunk should buffer, got %q", string(got1))
}
got2 := string(rewriter.RewriteChunk(part2))
gotTail := string(rewriter.Finish())
combined := got2 + gotTail
if !strings.Contains(combined, "gpt-5.4-fast") {
t.Fatalf("combined output = %q, want rewritten alias", combined)
}
if strings.Contains(combined, `"model":"gpt-5.4"`) {
t.Fatalf("combined output still has upstream model: %q", combined)
}
}
func TestStreamRewriter_RewriteChunk_CodexResponsesLineChunks(t *testing.T) {
rewriter := NewStreamRewriter(StreamRewriteOptions{RewriteModel: "gpt-5.4-fast"})
lines := [][]byte{
[]byte("event: response.created\n"),
[]byte(`data: {"type":"response.created","response":{"model":"gpt-5.4"}}` + "\n"),
[]byte("\n"),
[]byte("event: response.completed\n"),
[]byte(`data: {"type":"response.completed","response":{"model":"gpt-5.4"}}` + "\n"),
[]byte("\n"),
}
var out []byte
for _, line := range lines {
if rewritten := rewriter.RewriteChunk(line); len(rewritten) > 0 {
out = append(out, rewritten...)
}
}
if tail := rewriter.Finish(); len(tail) > 0 {
out = append(out, tail...)
}
got := string(out)
if !strings.Contains(got, "gpt-5.4-fast") {
t.Fatalf("rewritten output = %q, want alias gpt-5.4-fast", got)
}
if strings.Contains(got, `"model":"gpt-5.4"`) {
t.Fatalf("rewritten output still contains upstream model: %q", got)
}
}
func TestRewriteForceMappedStreamChunk_CodexLineChunksDoNotDuplicateBufferedEvent(t *testing.T) {
rewriter := NewStreamRewriter(StreamRewriteOptions{RewriteModel: "gpt-5.4-fast"})
chunks := [][]byte{
[]byte("event: response.created\n"),
[]byte(`data: {"type":"response.created","response":{"model":"gpt-5.4"}}` + "\n\n"),
}
var out []byte
for _, chunk := range chunks {
if rewritten := rewriteForceMappedStreamChunk(rewriter, chunk); len(rewritten) > 0 {
out = append(out, rewritten...)
}
}
if tail := finishForceMappedStreamChunks(rewriter); len(tail) > 0 {
out = append(out, tail...)
}
got := string(out)
if count := strings.Count(got, "event: response.created"); count != 1 {
t.Fatalf("event count = %d, want 1; output=%q", count, got)
}
if !strings.HasSuffix(got, "\n\n") {
t.Fatalf("rewritten output = %q, want complete SSE frame terminator", got)
}
if !strings.Contains(got, `"model":"gpt-5.4-fast"`) {
t.Fatalf("rewritten output = %q, want alias model", got)
}
if strings.Contains(got, `"model":"gpt-5.4"`) {
t.Fatalf("rewritten output still contains upstream model: %q", got)
}
}
func TestRewriteModelInResponse_AntigravityModelVersion(t *testing.T) {
payload := []byte(`{"response":{"modelVersion":"gemini-3-flash","candidates":[{"content":{"role":"model","parts":[{"text":"AGYMSG"}]}}]}}`)
got := string(rewriteModelInResponse(payload, "claude-haiku-4-5-20251001"))
if !strings.Contains(got, `"modelVersion":"claude-haiku-4-5-20251001"`) {
t.Fatalf("rewritten payload = %q, want alias modelVersion", got)
}
if strings.Contains(got, "gemini-3-flash") {
t.Fatalf("rewritten payload still contains upstream modelVersion: %q", got)
}
}
func TestStreamRewriter_RewriteChunk_LiveDerivedProviderChunks(t *testing.T) {
cases := []struct {
name string
rewriteModel string
upstream string
chunk string
}{
{
name: "kimi_chat_stream",
rewriteModel: "k2.5",
upstream: "kimi-k2.5",
chunk: `data:{"id":"chatcmpl-live","object":"chat.completion.chunk","created":1782272323,"model":"kimi-k2.5","choices":[{"index":0,"delta":{"content":"KCHATS"},"finish_reason":null}]}` + "\n\n",
},
{
name: "kimi_messages_stream",
rewriteModel: "k2.5",
upstream: "kimi-k2.5",
chunk: "event:message_start\n" + `data:{"type":"message_start","message":{"model":"kimi-k2.5"}}` + "\n\n",
},
{
name: "xai_messages_stream",
rewriteModel: "grok-latest",
upstream: "grok-4.3",
chunk: `data: {"type":"message_start","message":{"model":"grok-4.3"}}` + "\n\n",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rewriter := NewStreamRewriter(StreamRewriteOptions{RewriteModel: tc.rewriteModel})
got := string(rewriter.RewriteChunk([]byte(tc.chunk)))
if !strings.Contains(got, tc.rewriteModel) {
t.Fatalf("rewritten chunk = %q, want alias %q", got, tc.rewriteModel)
}
if strings.Contains(got, tc.upstream) {
t.Fatalf("rewritten chunk still contains upstream %q: %q", tc.upstream, got)
}
})
}
}
func TestRewriteSSEPayloadLines_CodexResponsesLiveFrame(t *testing.T) {
chunk := []byte("event: response.created\n" +
`data: {"type":"response.created","response":{"model":"gpt-5.4"}}` + "\n\n" +
"event: response.completed\n" +
`data: {"type":"response.completed","response":{"model":"gpt-5.4"}}` + "\n\n")
got := string(rewriteSSEPayloadLines(chunk, "gpt-5.4-fast"))
if !strings.Contains(got, "gpt-5.4-fast") {
t.Fatalf("rewritten chunk = %q, want alias gpt-5.4-fast", got)
}
if strings.Contains(got, `"model":"gpt-5.4"`) {
t.Fatalf("rewritten chunk still contains upstream model: %q", got)
}
}
func TestRewriteForceMappedResponse_NoRewriteWhenForceMappingDisabled(t *testing.T) {
upstream := []byte(`{"model":"gpt-5.4","choices":[]}`)
resp := &cliproxyexecutor.Response{Payload: append([]byte(nil), upstream...)}
rewriteForceMappedResponse(resp, OAuthModelAliasResult{
UpstreamModel: "gpt-5.4",
ForceMapping: false,
OriginalAlias: "gpt-5.4-fast",
})
if string(resp.Payload) != string(upstream) {
t.Fatalf("payload = %s, want unchanged %s", resp.Payload, upstream)
}
}
func TestRewriteForceMappedStreamChunk_NoRewriteWhenRewriterNil(t *testing.T) {
chunk := []byte(`data: {"model":"gpt-5.4"}` + "\n\n")
got := rewriteForceMappedStreamChunk(nil, chunk)
if string(got) != string(chunk) {
t.Fatalf("chunk = %q, want unchanged upstream payload", got)
}
}
func TestNormalizeGluedSSEEvents_SplitsValidGlueOnly(t *testing.T) {
glued := []byte("event: response.created\ndata: {\"type\":\"response.created\"}event: response.completed\ndata: {\"type\":\"response.completed\"}")
got := normalizeGluedSSEEvents(glued)
if !bytes.Contains(got, []byte("}\n\nevent:")) {
t.Fatalf("expected glued frame split, got %q", got)
}
inside := []byte("event: response.output_text.delta\ndata: {\"type\":\"delta\",\"text\":\"literal }event: inside string\"}")
gotInside := string(normalizeGluedSSEEvents(inside))
if strings.Contains(gotInside, "}\n\nevent:") {
t.Fatalf("should not split inside JSON string, got %q", gotInside)
}
for _, line := range bytes.Split(inside, []byte("\n")) {
if bytes.HasPrefix(line, []byte("data:")) {
_, jd, ok := extractSSEDataLine(line)
if !ok || !gjson.ValidBytes(jd) {
t.Fatalf("baseline invalid")
}
}
}
for _, line := range bytes.Split([]byte(gotInside), []byte("\n")) {
if bytes.HasPrefix(line, []byte("data:")) {
_, jd, ok := extractSSEDataLine(line)
if !ok || !gjson.ValidBytes(jd) {
t.Fatalf("corrupted JSON after normalize: %q", gotInside)
}
}
}
}
func TestNormalizeGluedSSEEvents_SplitsCodexDataGlueOnly(t *testing.T) {
glued := []byte(`data: {"type":"response.created"}data: {"type":"response.completed"}`)
got := normalizeGluedSSEEvents(glued)
if !bytes.Contains(got, []byte("}\ndata:")) {
t.Fatalf("expected codex glued split, got %q", got)
}
inside := []byte(`data: {"type":"delta","text":"literal }data: inside"}`)
gotInside := string(normalizeGluedSSEEvents(inside))
if strings.Contains(gotInside, "}\ndata:") && !bytes.Equal([]byte(gotInside), inside) {
// Only fail if we actually inserted a split (unchanged is OK)
for _, line := range bytes.Split([]byte(gotInside), []byte("\n")) {
if bytes.HasPrefix(line, []byte("data:")) {
_, jd, ok := extractSSEDataLine(line)
if !ok || !gjson.ValidBytes(jd) {
t.Fatalf("corrupted JSON: %q", gotInside)
}
}
}
}
}
func parseResponsesWSDataEventTypes(payload []byte) []string {
lines := bytes.Split(payload, []byte("\n"))
var types []string
for _, line := range lines {
line = bytes.TrimSpace(line)
if len(line) == 0 || bytes.HasPrefix(line, []byte("event:")) {
continue
}
if bytes.HasPrefix(line, []byte("data:")) {
line = bytes.TrimSpace(line[len("data:"):])
}
if len(line) == 0 || !gjson.ValidBytes(line) {
continue
}
types = append(types, gjson.GetBytes(line, "type").String())
}
return types
}
func TestRewriteForceMappedStreamChunk_CodexDataLinesWithoutNewlines_FinishParsesCompleted(t *testing.T) {
rewriter := NewStreamRewriter(StreamRewriteOptions{RewriteModel: "gpt-5.4-fast"})
lines := [][]byte{
[]byte(`data: {"type":"response.created","response":{"model":"gpt-5.4"}}`),
[]byte(`data: {"type":"response.in_progress","response":{"model":"gpt-5.4"}}`),
[]byte(`data: {"type":"response.completed","response":{"model":"gpt-5.4","output":[]}}`),
}
var types []string
for _, ln := range lines {
if out := rewriteForceMappedStreamChunk(rewriter, ln); len(out) > 0 {
types = append(types, parseResponsesWSDataEventTypes(out)...)
}
}
if tail := finishForceMappedStreamChunks(rewriter); len(tail) > 0 {
types = append(types, parseResponsesWSDataEventTypes(tail)...)
}
found := false
for _, typ := range types {
if typ == "response.completed" {
found = true
break
}
}
if !found {
t.Fatalf("missing response.completed; types=%v", types)
}
}