mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
- normalize xAI `403` bad-credentials responses to `401` before conductor retry handling - preserve websocket error headers while applying the normalized status/retry hints - share bad-credentials detection across flat and nested payload shapes for HTTP/websocket paths Closes: #4046
1725 lines
69 KiB
Go
1725 lines
69 KiB
Go
package executor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
_ "github.com/router-for-me/CLIProxyAPI/v7/internal/translator"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestXAIWebsocketsEnabledForConfigAPIKey(t *testing.T) {
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"api_key": "xai-key",
|
|
"websockets": "true",
|
|
},
|
|
}
|
|
if !xaiWebsocketsEnabled(auth) {
|
|
t.Fatal("xaiWebsocketsEnabled() = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestXAIAutoExecutorRequiredUpstreamWebsocketRejectsHTTPFallback(t *testing.T) {
|
|
exec := NewXAIAutoExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "xai-http-only",
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"api_key": "xai-key",
|
|
},
|
|
}
|
|
ctx := cliproxyexecutor.WithRequiredUpstreamWebsocket(
|
|
cliproxyexecutor.WithDownstreamWebsocket(context.Background()),
|
|
)
|
|
_, errExecute := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
|
|
Model: "grok-4",
|
|
Payload: []byte(`{"model":"grok-4","previous_response_id":"resp-1","input":[{"type":"message","id":"msg-2"}]}`),
|
|
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai-response")})
|
|
if errExecute == nil {
|
|
t.Fatal("ExecuteStream() error = nil, want replay-required error")
|
|
}
|
|
statusErr, ok := errExecute.(interface{ StatusCode() int })
|
|
if !ok || statusErr.StatusCode() != http.StatusUpgradeRequired {
|
|
t.Fatalf("ExecuteStream() error = %T %v, want status 426", errExecute, errExecute)
|
|
}
|
|
if got := gjson.Get(errExecute.Error(), "error.code").String(); got != "upstream_http_replay_required" {
|
|
t.Fatalf("ExecuteStream() error code = %q, want upstream_http_replay_required", got)
|
|
}
|
|
requestScoped, ok := errExecute.(cliproxyexecutor.RequestScopedError)
|
|
if !ok || !requestScoped.IsRequestScoped() {
|
|
t.Fatalf("ExecuteStream() error = %T, want request-scoped replay signal", errExecute)
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsRequiredUpstreamRejectsCompactionHTTPFallback(t *testing.T) {
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
ctx := cliproxyexecutor.WithRequiredUpstreamWebsocket(context.Background())
|
|
_, errExecute := exec.ExecuteStream(ctx, &cliproxyauth.Auth{}, cliproxyexecutor.Request{
|
|
Model: "grok-4",
|
|
Payload: []byte(`{"model":"grok-4","input":[{"type":"compaction_trigger"}]}`),
|
|
}, cliproxyexecutor.Options{})
|
|
if !cliproxyexecutor.IsUpstreamWebsocketReplayRequired(errExecute) {
|
|
t.Fatalf("ExecuteStream() error = %T %v, want replay-required", errExecute, errExecute)
|
|
}
|
|
}
|
|
|
|
func TestMapXAIWebsocketWriteErrorStopsRetryForMessageTooBig(t *testing.T) {
|
|
networkWriteErr := errors.New("write: broken pipe")
|
|
tests := []struct {
|
|
name string
|
|
closeCode int
|
|
writeErr error
|
|
wantStatus int
|
|
wantRetry bool
|
|
}{
|
|
{
|
|
name: "close sent after message too big is request scoped",
|
|
closeCode: websocket.CloseMessageTooBig,
|
|
writeErr: websocket.ErrCloseSent,
|
|
wantStatus: http.StatusRequestEntityTooLarge,
|
|
wantRetry: false,
|
|
},
|
|
{
|
|
name: "network write error after message too big is request scoped",
|
|
closeCode: websocket.CloseMessageTooBig,
|
|
writeErr: networkWriteErr,
|
|
wantStatus: http.StatusRequestEntityTooLarge,
|
|
wantRetry: false,
|
|
},
|
|
{
|
|
name: "other close keeps stale connection retry",
|
|
closeCode: websocket.CloseNormalClosure,
|
|
writeErr: websocket.ErrCloseSent,
|
|
wantRetry: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sess := &codexWebsocketSession{}
|
|
conn := &websocket.Conn{}
|
|
sess.resetUpstreamDisconnectError(conn)
|
|
sess.setUpstreamDisconnectError(conn, &websocket.CloseError{Code: tt.closeCode})
|
|
|
|
mappedErr := mapXAIWebsocketWriteError(sess, conn, tt.writeErr)
|
|
if got := shouldRetryXAIWebsocketSend(mappedErr); got != tt.wantRetry {
|
|
t.Fatalf("shouldRetryXAIWebsocketSend() = %v, want %v; err=%v", got, tt.wantRetry, mappedErr)
|
|
}
|
|
if tt.wantStatus == 0 {
|
|
if !errors.Is(mappedErr, tt.writeErr) {
|
|
t.Fatalf("mapped error = %v, want %v", mappedErr, tt.writeErr)
|
|
}
|
|
return
|
|
}
|
|
statusErr, ok := mappedErr.(interface{ StatusCode() int })
|
|
if !ok || statusErr.StatusCode() != tt.wantStatus {
|
|
t.Fatalf("mapped status = %v, want %d; err=%v", statusErr, tt.wantStatus, mappedErr)
|
|
}
|
|
requestErr, ok := mappedErr.(interface{ IsRequestScoped() bool })
|
|
if !ok || !requestErr.IsRequestScoped() {
|
|
t.Fatalf("mapped error should be request scoped, got %T", mappedErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamMapsMessageTooBigClose(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Errorf("upgrade websocket: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
if _, _, errRead := conn.ReadMessage(); errRead != nil {
|
|
t.Errorf("read upstream websocket message: %v", errRead)
|
|
return
|
|
}
|
|
deadline := time.Now().Add(time.Second)
|
|
closeMessage := websocket.FormatCloseMessage(websocket.CloseMessageTooBig, "message too big")
|
|
if errWrite := conn.WriteControl(websocket.CloseMessage, closeMessage, deadline); errWrite != nil {
|
|
t.Errorf("write close websocket message: %v", errWrite)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
req := cliproxyexecutor.Request{
|
|
Model: "grok-4.5",
|
|
Payload: []byte(`{"model":"grok-4.5","input":[{"type":"message","role":"user","content":"hello"}]}`),
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
}
|
|
|
|
result, err := exec.ExecuteStream(context.Background(), auth, req, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", err)
|
|
}
|
|
|
|
select {
|
|
case chunk, ok := <-result.Chunks:
|
|
if !ok {
|
|
t.Fatal("stream closed before error chunk")
|
|
}
|
|
if chunk.Err == nil {
|
|
t.Fatal("error chunk Err = nil, want message-too-big error")
|
|
}
|
|
statusErr, ok := chunk.Err.(interface{ StatusCode() int })
|
|
if !ok || statusErr.StatusCode() != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("status error = %v, want %d; err=%v", statusErr, http.StatusRequestEntityTooLarge, chunk.Err)
|
|
}
|
|
if got := gjson.Get(chunk.Err.Error(), "error.code").String(); got != "message_too_big" {
|
|
t.Fatalf("error code = %q, want message_too_big; err=%v", got, chunk.Err)
|
|
}
|
|
requestErr, ok := chunk.Err.(interface{ IsRequestScoped() bool })
|
|
if !ok || !requestErr.IsRequestScoped() {
|
|
t.Fatalf("message-too-big error should be request scoped, got %T", chunk.Err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for error stream chunk")
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamSendsResponseCreateWithPreviousResponseID(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
capturedPayload := make(chan []byte, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/responses" {
|
|
t.Errorf("path = %q, want /responses", r.URL.Path)
|
|
}
|
|
if got := r.Header.Get("Authorization"); got != "Bearer xai-token" {
|
|
t.Errorf("Authorization = %q, want Bearer xai-token", got)
|
|
}
|
|
if got := r.Header.Get("x-grok-conv-id"); got != "execution-session-1" {
|
|
t.Errorf("x-grok-conv-id = %q, want execution-session-1", got)
|
|
}
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Errorf("upgrade websocket: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
_, payload, errRead := conn.ReadMessage()
|
|
if errRead != nil {
|
|
t.Errorf("read upstream websocket message: %v", errRead)
|
|
return
|
|
}
|
|
capturedPayload <- bytes.Clone(payload)
|
|
completed := []byte(`{"type":"response.completed","response":{"id":"resp-xai-1","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`)
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
|
|
t.Errorf("write completed websocket message: %v", errWrite)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "xai-auth",
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
req := cliproxyexecutor.Request{
|
|
Model: "grok-4.3",
|
|
Payload: []byte(`{"model":"grok-4.3","stream":true,"previous_response_id":"resp-prev","instructions":"system prompt","input":[{"type":"message","role":"user","content":"hello"}]}`),
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
Metadata: map[string]any{
|
|
cliproxyexecutor.ExecutionSessionMetadataKey: "execution-session-1",
|
|
},
|
|
}
|
|
ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
|
|
|
|
result, err := exec.ExecuteStream(ctx, auth, req, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", err)
|
|
}
|
|
|
|
select {
|
|
case payload := <-capturedPayload:
|
|
if got := gjson.GetBytes(payload, "type").String(); got != "response.create" {
|
|
t.Fatalf("type = %q, want response.create; payload=%s", got, payload)
|
|
}
|
|
if got := gjson.GetBytes(payload, "previous_response_id").String(); got != "resp-prev" {
|
|
t.Fatalf("previous_response_id = %q, want resp-prev; payload=%s", got, payload)
|
|
}
|
|
if gjson.GetBytes(payload, "stream").Exists() {
|
|
t.Fatalf("stream must be omitted for xAI websocket payload: %s", payload)
|
|
}
|
|
if gjson.GetBytes(payload, "instructions").Exists() {
|
|
t.Fatalf("instructions must be omitted when previous_response_id is set: %s", payload)
|
|
}
|
|
if got := gjson.GetBytes(payload, "prompt_cache_key").String(); got != "execution-session-1" {
|
|
t.Fatalf("prompt_cache_key = %q, want execution-session-1; payload=%s", got, payload)
|
|
}
|
|
if got := gjson.GetBytes(payload, "store").Bool(); !got {
|
|
t.Fatalf("store = false, want true; payload=%s", payload)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for upstream websocket payload")
|
|
}
|
|
|
|
select {
|
|
case chunk, ok := <-result.Chunks:
|
|
if !ok {
|
|
t.Fatal("stream closed before completed chunk")
|
|
}
|
|
if chunk.Err != nil {
|
|
t.Fatalf("chunk error = %v", chunk.Err)
|
|
}
|
|
if got := gjson.GetBytes(bytes.TrimSpace(chunk.Payload), "type").String(); got != "response.completed" {
|
|
t.Fatalf("chunk type = %q, want response.completed; payload=%s", got, chunk.Payload)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for completed chunk")
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamRestoresNamespaceToolCalls(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
capturedPayload := make(chan []byte, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Errorf("upgrade websocket: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
_, payload, errRead := conn.ReadMessage()
|
|
if errRead != nil {
|
|
t.Errorf("read upstream websocket message: %v", errRead)
|
|
return
|
|
}
|
|
capturedPayload <- bytes.Clone(payload)
|
|
|
|
events := [][]byte{
|
|
[]byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","name":"mcp__exa__web_search_exa","call_id":"call_1","arguments":"{}"}}`),
|
|
[]byte(`{"type":"response.completed","response":{"id":"resp_1","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`),
|
|
}
|
|
for _, event := range events {
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, event); errWrite != nil {
|
|
t.Errorf("write websocket event: %v", errWrite)
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
req := cliproxyexecutor.Request{
|
|
Model: "grok-4.3",
|
|
Payload: []byte(`{
|
|
"model":"grok-4.3",
|
|
"input":[
|
|
{"type":"additional_tools","role":"developer","tools":[{
|
|
"type":"namespace",
|
|
"name":"mcp__exa",
|
|
"tools":[{"type":"function","name":"web_search_exa","parameters":{"type":"object"}}]
|
|
}]},
|
|
{"role":"user","content":"use Exa"}
|
|
]
|
|
}`),
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
Stream: true,
|
|
}
|
|
ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
|
|
|
|
result, err := exec.ExecuteStream(ctx, auth, req, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", err)
|
|
}
|
|
|
|
select {
|
|
case payload := <-capturedPayload:
|
|
for _, item := range gjson.GetBytes(payload, "input").Array() {
|
|
if got := item.Get("type").String(); got == "additional_tools" {
|
|
t.Fatalf("upstream input contains unsupported additional_tools item: %s", payload)
|
|
}
|
|
}
|
|
if got := gjson.GetBytes(payload, "input.0.role").String(); got != "user" {
|
|
t.Fatalf("input.0.role = %q, want user; payload=%s", got, payload)
|
|
}
|
|
tool := gjson.GetBytes(payload, "tools.0")
|
|
if got := tool.Get("name").String(); got != "mcp__exa__web_search_exa" {
|
|
t.Fatalf("upstream tool name = %q, want qualified name; payload=%s", got, payload)
|
|
}
|
|
if tool.Get("tools").Exists() {
|
|
t.Fatalf("upstream tool should not contain namespace children: %s", payload)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for upstream websocket payload")
|
|
}
|
|
|
|
var outputItemDone, completed gjson.Result
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("stream chunk error = %v", chunk.Err)
|
|
}
|
|
payload := gjson.ParseBytes(bytes.TrimSpace(chunk.Payload))
|
|
switch payload.Get("type").String() {
|
|
case "response.output_item.done":
|
|
outputItemDone = payload
|
|
case "response.completed":
|
|
completed = payload
|
|
}
|
|
}
|
|
|
|
for label, item := range map[string]gjson.Result{
|
|
"output_item.done": outputItemDone.Get("item"),
|
|
"completed": completed.Get("response.output.0"),
|
|
} {
|
|
if got := item.Get("name").String(); got != "web_search_exa" {
|
|
t.Fatalf("%s name = %q, want child name; item=%s", label, got, item.Raw)
|
|
}
|
|
if got := item.Get("namespace").String(); got != "mcp__exa" {
|
|
t.Fatalf("%s namespace = %q, want mcp__exa; item=%s", label, got, item.Raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamPreservesClientSameNameToolsWithXSearch(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Errorf("upgrade websocket: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
if _, _, errRead := conn.ReadMessage(); errRead != nil {
|
|
t.Errorf("read upstream websocket message: %v", errRead)
|
|
return
|
|
}
|
|
// Collision case: internal X Search and client tools both named x_keyword_search.
|
|
events := [][]byte{
|
|
[]byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"ctc_1","type":"custom_tool_call","call_id":"xs_call-1","name":"x_keyword_search","input":"{}","status":"completed"}}`),
|
|
[]byte(`{"type":"response.output_item.done","output_index":1,"item":{"id":"fc_ns","type":"function_call","call_id":"call_ns","name":"acme__x_keyword_search","arguments":"{}","status":"completed"}}`),
|
|
[]byte(`{"type":"response.output_item.done","output_index":2,"item":{"id":"fc_plain","type":"function_call","call_id":"call_plain","name":"x_keyword_search","arguments":"{}","status":"completed"}}`),
|
|
[]byte(`{"type":"response.output_item.done","output_index":3,"item":{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}],"status":"completed"}}`),
|
|
[]byte(`{"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[{"id":"ctc_1","type":"custom_tool_call","call_id":"xs_call-1","name":"x_keyword_search","input":"{}"},{"id":"fc_ns","type":"function_call","call_id":"call_ns","name":"acme__x_keyword_search","arguments":"{}"},{"id":"fc_plain","type":"function_call","call_id":"call_plain","name":"x_keyword_search","arguments":"{}"},{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`),
|
|
}
|
|
for _, event := range events {
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, event); errWrite != nil {
|
|
t.Errorf("write websocket event: %v", errWrite)
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
req := cliproxyexecutor.Request{
|
|
Model: "grok-4.5",
|
|
Payload: []byte(`{
|
|
"model":"grok-4.5",
|
|
"input":"search X",
|
|
"tools":[
|
|
{"type":"x_search"},
|
|
{"type":"function","name":"x_keyword_search","parameters":{"type":"object"}},
|
|
{"type":"namespace","name":"acme","tools":[
|
|
{"type":"function","name":"x_keyword_search","parameters":{"type":"object"}}
|
|
]}
|
|
]
|
|
}`),
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
Stream: true,
|
|
}
|
|
ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
|
|
|
|
result, err := exec.ExecuteStream(ctx, auth, req, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", err)
|
|
}
|
|
|
|
var foundPlain, foundNamespaced bool
|
|
var completed gjson.Result
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("stream chunk error = %v", chunk.Err)
|
|
}
|
|
payload := gjson.ParseBytes(bytes.TrimSpace(chunk.Payload))
|
|
if strings.Contains(payload.Raw, "xs_call") {
|
|
t.Fatalf("internal X search call_id leaked downstream: %s", payload.Raw)
|
|
}
|
|
if strings.Contains(payload.Raw, "custom_tool_call") {
|
|
t.Fatalf("internal custom_tool_call leaked downstream: %s", payload.Raw)
|
|
}
|
|
switch payload.Get("type").String() {
|
|
case "response.output_item.done":
|
|
item := payload.Get("item")
|
|
if item.Get("type").String() == "custom_tool_call" {
|
|
t.Fatalf("internal custom_tool_call leaked in stream item: %s", item.Raw)
|
|
}
|
|
if item.Get("type").String() != "function_call" {
|
|
continue
|
|
}
|
|
if item.Get("name").String() == "x_keyword_search" && item.Get("namespace").String() == "acme" {
|
|
foundNamespaced = true
|
|
}
|
|
if item.Get("name").String() == "x_keyword_search" && item.Get("namespace").String() == "" && item.Get("call_id").String() == "call_plain" {
|
|
foundPlain = true
|
|
}
|
|
case "response.completed":
|
|
completed = payload
|
|
}
|
|
}
|
|
if !foundPlain {
|
|
t.Fatal("plain client x_keyword_search missing from websocket stream")
|
|
}
|
|
if !foundNamespaced {
|
|
t.Fatal("namespaced client acme.x_keyword_search missing from websocket stream")
|
|
}
|
|
if got := completed.Get("response.output.#").Int(); got != 3 {
|
|
t.Fatalf("completed output length = %d, want 3; completed=%s", got, completed.Raw)
|
|
}
|
|
if completed.Get(`response.output.#(type=="custom_tool_call")`).Exists() {
|
|
t.Fatalf("internal custom_tool_call present in completed output: %s", completed.Raw)
|
|
}
|
|
var completedPlain, completedNamespaced bool
|
|
for _, item := range completed.Get("response.output").Array() {
|
|
if item.Get("type").String() != "function_call" {
|
|
continue
|
|
}
|
|
if item.Get("name").String() == "x_keyword_search" && item.Get("namespace").String() == "acme" {
|
|
completedNamespaced = true
|
|
}
|
|
if item.Get("name").String() == "x_keyword_search" && item.Get("namespace").String() == "" && item.Get("call_id").String() == "call_plain" {
|
|
completedPlain = true
|
|
}
|
|
}
|
|
if !completedPlain || !completedNamespaced {
|
|
t.Fatalf("completed output missing client tools plain=%v namespaced=%v; completed=%s", completedPlain, completedNamespaced, completed.Raw)
|
|
}
|
|
}
|
|
|
|
// TestXAIWebsocketsExecuteStreamPreservesNormalizedCustomSameNameToolWithXSearch exercises
|
|
// the real request path for WebSocket: client custom tools normalize to upstream function,
|
|
// so the mock asserts the outgoing function tool and feeds back a function_call response.
|
|
func TestXAIWebsocketsExecuteStreamPreservesNormalizedCustomSameNameToolWithXSearch(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
capturedPayload := make(chan []byte, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Errorf("upgrade websocket: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
_, payload, errRead := conn.ReadMessage()
|
|
if errRead != nil {
|
|
t.Errorf("read upstream websocket message: %v", errRead)
|
|
return
|
|
}
|
|
capturedPayload <- bytes.Clone(payload)
|
|
// Internal X Search trace + legitimate client function_call for the normalized custom tool.
|
|
events := [][]byte{
|
|
[]byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"ctc_1","type":"custom_tool_call","call_id":"xs_call-1","name":"x_keyword_search","input":"{}","status":"completed"}}`),
|
|
[]byte(`{"type":"response.output_item.done","output_index":1,"item":{"id":"fc_custom","type":"function_call","call_id":"call_custom","name":"x_keyword_search","arguments":"{}","status":"completed"}}`),
|
|
[]byte(`{"type":"response.output_item.done","output_index":2,"item":{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}],"status":"completed"}}`),
|
|
[]byte(`{"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[{"id":"ctc_1","type":"custom_tool_call","call_id":"xs_call-1","name":"x_keyword_search","input":"{}"},{"id":"fc_custom","type":"function_call","call_id":"call_custom","name":"x_keyword_search","arguments":"{}"},{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`),
|
|
}
|
|
for _, event := range events {
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, event); errWrite != nil {
|
|
t.Errorf("write websocket event: %v", errWrite)
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
req := cliproxyexecutor.Request{
|
|
Model: "grok-4.5",
|
|
Payload: []byte(`{
|
|
"model":"grok-4.5",
|
|
"input":"search X",
|
|
"tools":[
|
|
{"type":"x_search"},
|
|
{"type":"custom","name":"x_keyword_search"}
|
|
]
|
|
}`),
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
Stream: true,
|
|
}
|
|
ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
|
|
|
|
result, err := exec.ExecuteStream(ctx, auth, req, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", err)
|
|
}
|
|
|
|
var foundClientFunction bool
|
|
var completed gjson.Result
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("stream chunk error = %v", chunk.Err)
|
|
}
|
|
payload := gjson.ParseBytes(bytes.TrimSpace(chunk.Payload))
|
|
if strings.Contains(payload.Raw, "xs_call") {
|
|
t.Fatalf("internal X search call_id leaked downstream: %s", payload.Raw)
|
|
}
|
|
if strings.Contains(payload.Raw, "custom_tool_call") {
|
|
t.Fatalf("internal custom_tool_call leaked downstream: %s", payload.Raw)
|
|
}
|
|
switch payload.Get("type").String() {
|
|
case "response.output_item.done":
|
|
item := payload.Get("item")
|
|
if item.Get("type").String() == "custom_tool_call" {
|
|
t.Fatalf("internal custom_tool_call leaked in stream item: %s", item.Raw)
|
|
}
|
|
if item.Get("type").String() == "function_call" &&
|
|
item.Get("name").String() == "x_keyword_search" &&
|
|
item.Get("call_id").String() == "call_custom" {
|
|
foundClientFunction = true
|
|
}
|
|
case "response.completed":
|
|
completed = payload
|
|
}
|
|
}
|
|
if !foundClientFunction {
|
|
t.Fatal("normalized client custom tool function_call missing from websocket stream")
|
|
}
|
|
if got := completed.Get("response.output.#").Int(); got != 2 {
|
|
t.Fatalf("completed output length = %d, want 2; completed=%s", got, completed.Raw)
|
|
}
|
|
if completed.Get(`response.output.#(type=="custom_tool_call")`).Exists() {
|
|
t.Fatalf("internal custom_tool_call present in completed output: %s", completed.Raw)
|
|
}
|
|
var completedClientFunction bool
|
|
for _, item := range completed.Get("response.output").Array() {
|
|
if item.Get("type").String() == "function_call" &&
|
|
item.Get("name").String() == "x_keyword_search" &&
|
|
item.Get("call_id").String() == "call_custom" {
|
|
completedClientFunction = true
|
|
}
|
|
}
|
|
if !completedClientFunction {
|
|
t.Fatalf("completed output missing normalized client custom tool function_call: %s", completed.Raw)
|
|
}
|
|
|
|
var gotBody []byte
|
|
select {
|
|
case gotBody = <-capturedPayload:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for upstream websocket request body")
|
|
}
|
|
// response.create keeps tools at the top level of the websocket payload.
|
|
tools := gjson.GetBytes(gotBody, "tools")
|
|
var foundNormalizedFunction bool
|
|
var foundRawCustom bool
|
|
for _, tool := range tools.Array() {
|
|
switch tool.Get("type").String() {
|
|
case "function":
|
|
if tool.Get("name").String() == "x_keyword_search" {
|
|
foundNormalizedFunction = true
|
|
}
|
|
case "custom":
|
|
if tool.Get("name").String() == "x_keyword_search" {
|
|
foundRawCustom = true
|
|
}
|
|
}
|
|
}
|
|
if !foundNormalizedFunction {
|
|
t.Fatalf("upstream websocket request missing normalized function tool x_keyword_search; body=%s", gotBody)
|
|
}
|
|
if foundRawCustom {
|
|
t.Fatalf("upstream websocket request still contains client custom tool type; body=%s", gotBody)
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamNormalizesReasoningTextEvents(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Errorf("upgrade websocket: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
if _, _, errRead := conn.ReadMessage(); errRead != nil {
|
|
t.Errorf("read upstream websocket message: %v", errRead)
|
|
return
|
|
}
|
|
events := [][]byte{
|
|
[]byte(`{"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"id":"rs_1","type":"reasoning","status":"in_progress","summary":[]}}`),
|
|
[]byte(`{"type":"response.content_part.added","sequence_number":2,"item_id":"rs_1","output_index":0,"content_index":0,"part":{"type":"reasoning_text","text":""}}`),
|
|
[]byte(`{"type":"response.reasoning_text.delta","sequence_number":3,"item_id":"rs_1","output_index":0,"content_index":0,"delta":"thinking"}`),
|
|
[]byte(`{"type":"response.reasoning_text.done","sequence_number":4,"item_id":"rs_1","output_index":0,"content_index":0,"text":"thinking"}`),
|
|
[]byte(`{"type":"response.output_item.done","sequence_number":5,"output_index":0,"item":{"id":"rs_1","type":"reasoning","status":"completed","summary":[],"content":[{"type":"reasoning_text","text":"thinking"}]}}`),
|
|
[]byte(`{"type":"response.completed","sequence_number":6,"response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"grok-4.3","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`),
|
|
}
|
|
for _, event := range events {
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, event); errWrite != nil {
|
|
t.Errorf("write websocket event: %v", errWrite)
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
|
|
result, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
|
|
Model: "grok-4.3",
|
|
Payload: []byte(`{"model":"grok-4.3","input":"hello"}`),
|
|
}, cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatCodex,
|
|
Stream: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", err)
|
|
}
|
|
|
|
var streamed bytes.Buffer
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("stream chunk error = %v", chunk.Err)
|
|
}
|
|
streamed.Write(chunk.Payload)
|
|
}
|
|
output := streamed.String()
|
|
if strings.Contains(output, "reasoning_text") {
|
|
t.Fatalf("stream contains xAI reasoning_text shape: %s", output)
|
|
}
|
|
for _, want := range []string{
|
|
`"type":"response.reasoning_summary_part.added"`,
|
|
`"type":"response.reasoning_summary_text.delta"`,
|
|
`"type":"response.reasoning_summary_text.done"`,
|
|
`"type":"response.reasoning_summary_part.done"`,
|
|
`"part":{"type":"summary_text","text":"thinking"}`,
|
|
`"summary_index":0`,
|
|
`"summary":[{"type":"summary_text","text":"thinking"}]`,
|
|
} {
|
|
if !strings.Contains(output, want) {
|
|
t.Fatalf("stream missing %q: %s", want, output)
|
|
}
|
|
}
|
|
textDoneIndex := strings.Index(output, `"type":"response.reasoning_summary_text.done"`)
|
|
partDoneIndex := strings.Index(output, `"type":"response.reasoning_summary_part.done"`)
|
|
if textDoneIndex < 0 || partDoneIndex < 0 || textDoneIndex > partDoneIndex {
|
|
t.Fatalf("reasoning done events are out of order: %s", output)
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamRewritesRepeatedResponseIDForDownstream(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
capturedPreviousIDs := make(chan string, 3)
|
|
releaseServer := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Errorf("upgrade websocket: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
for i := 0; i < 3; i++ {
|
|
_, payload, errRead := conn.ReadMessage()
|
|
if errRead != nil {
|
|
t.Errorf("read upstream websocket message: %v", errRead)
|
|
return
|
|
}
|
|
previousID := gjson.GetBytes(payload, "previous_response_id").String()
|
|
capturedPreviousIDs <- previousID
|
|
completed := []byte(fmt.Sprintf(`{"type":"response.completed","response":{"id":"resp-real","previous_response_id":%q,"output":[{"id":"rs_resp-real","type":"reasoning","status":"completed"}],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`, previousID))
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
|
|
t.Errorf("write completed websocket message: %v", errWrite)
|
|
return
|
|
}
|
|
}
|
|
<-releaseServer
|
|
}))
|
|
defer server.Close()
|
|
defer close(releaseServer)
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
exec.idStore = &xaiWebsocketIDStateStore{sessions: make(map[string]*xaiWebsocketIDState)}
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "xai-auth-id-map",
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
Metadata: map[string]any{
|
|
cliproxyexecutor.ExecutionSessionMetadataKey: "xai-id-map-session",
|
|
},
|
|
}
|
|
ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
|
|
|
|
runRequest := func(previousID string) (string, string, string) {
|
|
body := []byte(`{"model":"grok-4.3","input":[{"type":"message","role":"user","content":"hello"}]}`)
|
|
if previousID != "" {
|
|
body = []byte(fmt.Sprintf(`{"model":"grok-4.3","previous_response_id":%q,"input":[{"type":"function_call_output","call_id":"call-1","output":"ok"}]}`, previousID))
|
|
}
|
|
result, err := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "grok-4.3", Payload: body}, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", err)
|
|
}
|
|
select {
|
|
case chunk, ok := <-result.Chunks:
|
|
if !ok {
|
|
t.Fatal("stream closed before completed chunk")
|
|
}
|
|
if chunk.Err != nil {
|
|
t.Fatalf("chunk error = %v", chunk.Err)
|
|
}
|
|
payload := bytes.TrimSpace(chunk.Payload)
|
|
return gjson.GetBytes(payload, "response.id").String(),
|
|
gjson.GetBytes(payload, "response.output.0.id").String(),
|
|
gjson.GetBytes(payload, "response.previous_response_id").String()
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for completed chunk")
|
|
}
|
|
return "", "", ""
|
|
}
|
|
|
|
firstDownstreamID, firstOutputID, firstResponsePrevious := runRequest("")
|
|
if firstDownstreamID != "resp-real" {
|
|
t.Fatalf("first downstream id = %q, want resp-real", firstDownstreamID)
|
|
}
|
|
if firstOutputID != "rs_resp-real" {
|
|
t.Fatalf("first output item id = %q, want rs_resp-real", firstOutputID)
|
|
}
|
|
if firstResponsePrevious != "" {
|
|
t.Fatalf("first response previous_response_id = %q, want empty", firstResponsePrevious)
|
|
}
|
|
firstUpstreamPrevious := <-capturedPreviousIDs
|
|
if firstUpstreamPrevious != "" {
|
|
t.Fatalf("first upstream previous_response_id = %q, want empty", firstUpstreamPrevious)
|
|
}
|
|
|
|
secondDownstreamID, secondOutputID, secondResponsePrevious := runRequest(firstDownstreamID)
|
|
if secondDownstreamID == "" || secondDownstreamID == "resp-real" {
|
|
t.Fatalf("second downstream id = %q, want synthetic id different from resp-real", secondDownstreamID)
|
|
}
|
|
if secondOutputID == "rs_resp-real" || !strings.Contains(secondOutputID, secondDownstreamID) {
|
|
t.Fatalf("second output item id = %q, want rewritten id containing %q", secondOutputID, secondDownstreamID)
|
|
}
|
|
if secondResponsePrevious != firstDownstreamID {
|
|
t.Fatalf("second response previous_response_id = %q, want %q", secondResponsePrevious, firstDownstreamID)
|
|
}
|
|
secondUpstreamPrevious := <-capturedPreviousIDs
|
|
if secondUpstreamPrevious != "resp-real" {
|
|
t.Fatalf("second upstream previous_response_id = %q, want resp-real", secondUpstreamPrevious)
|
|
}
|
|
|
|
thirdDownstreamID, thirdOutputID, thirdResponsePrevious := runRequest(secondDownstreamID)
|
|
if thirdDownstreamID == "" || thirdDownstreamID == "resp-real" || thirdDownstreamID == secondDownstreamID {
|
|
t.Fatalf("third downstream id = %q, want a new synthetic id", thirdDownstreamID)
|
|
}
|
|
if thirdOutputID == "rs_resp-real" || !strings.Contains(thirdOutputID, thirdDownstreamID) {
|
|
t.Fatalf("third output item id = %q, want rewritten id containing %q", thirdOutputID, thirdDownstreamID)
|
|
}
|
|
if thirdResponsePrevious != secondDownstreamID {
|
|
t.Fatalf("third response previous_response_id = %q, want %q", thirdResponsePrevious, secondDownstreamID)
|
|
}
|
|
thirdUpstreamPrevious := <-capturedPreviousIDs
|
|
if thirdUpstreamPrevious != "resp-real" {
|
|
t.Fatalf("third upstream previous_response_id = %q, want resp-real", thirdUpstreamPrevious)
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamRewritesRepeatedResponseIDWithoutPreviousResponseID(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
capturedPreviousIDs := make(chan string, 2)
|
|
releaseServer := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Errorf("upgrade websocket: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
for i := 0; i < 2; i++ {
|
|
_, payload, errRead := conn.ReadMessage()
|
|
if errRead != nil {
|
|
t.Errorf("read upstream websocket message: %v", errRead)
|
|
return
|
|
}
|
|
capturedPreviousIDs <- gjson.GetBytes(payload, "previous_response_id").String()
|
|
completed := []byte(`{"type":"response.completed","response":{"id":"resp-real","output":[{"id":"msg_resp-real","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`)
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
|
|
t.Errorf("write completed websocket message: %v", errWrite)
|
|
return
|
|
}
|
|
}
|
|
<-releaseServer
|
|
}))
|
|
defer server.Close()
|
|
defer close(releaseServer)
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
exec.idStore = &xaiWebsocketIDStateStore{sessions: make(map[string]*xaiWebsocketIDState)}
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "xai-auth-id-map-no-prev",
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
Metadata: map[string]any{
|
|
cliproxyexecutor.ExecutionSessionMetadataKey: "xai-id-map-no-prev-session",
|
|
},
|
|
}
|
|
ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
|
|
|
|
runRequest := func(content string) (string, string) {
|
|
body := []byte(fmt.Sprintf(`{"model":"grok-4.3","input":[{"type":"message","role":"user","content":%q}]}`, content))
|
|
result, err := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "grok-4.3", Payload: body}, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", err)
|
|
}
|
|
select {
|
|
case chunk, ok := <-result.Chunks:
|
|
if !ok {
|
|
t.Fatal("stream closed before completed chunk")
|
|
}
|
|
if chunk.Err != nil {
|
|
t.Fatalf("chunk error = %v", chunk.Err)
|
|
}
|
|
payload := bytes.TrimSpace(chunk.Payload)
|
|
return gjson.GetBytes(payload, "response.id").String(),
|
|
gjson.GetBytes(payload, "response.output.0.id").String()
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for completed chunk")
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
firstDownstreamID, firstOutputID := runRequest("first")
|
|
if firstDownstreamID != "resp-real" {
|
|
t.Fatalf("first downstream id = %q, want resp-real", firstDownstreamID)
|
|
}
|
|
if firstOutputID != "msg_resp-real" {
|
|
t.Fatalf("first output item id = %q, want msg_resp-real", firstOutputID)
|
|
}
|
|
if firstUpstreamPrevious := <-capturedPreviousIDs; firstUpstreamPrevious != "" {
|
|
t.Fatalf("first upstream previous_response_id = %q, want empty", firstUpstreamPrevious)
|
|
}
|
|
|
|
secondDownstreamID, secondOutputID := runRequest("second")
|
|
if secondDownstreamID == "" || secondDownstreamID == "resp-real" {
|
|
t.Fatalf("second downstream id = %q, want synthetic id different from resp-real", secondDownstreamID)
|
|
}
|
|
if secondOutputID == "msg_resp-real" || !strings.Contains(secondOutputID, secondDownstreamID) {
|
|
t.Fatalf("second output item id = %q, want rewritten id containing %q", secondOutputID, secondDownstreamID)
|
|
}
|
|
if secondUpstreamPrevious := <-capturedPreviousIDs; secondUpstreamPrevious != "" {
|
|
t.Fatalf("second upstream previous_response_id = %q, want empty", secondUpstreamPrevious)
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamReplaysTranscriptWhenAuthChanges(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
type capturedRequest struct {
|
|
authorization string
|
|
payload []byte
|
|
}
|
|
captured := make(chan capturedRequest, 2)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, errUpgrade := upgrader.Upgrade(w, r, nil)
|
|
if errUpgrade != nil {
|
|
t.Errorf("upgrade websocket: %v", errUpgrade)
|
|
return
|
|
}
|
|
defer func() {
|
|
if errClose := conn.Close(); errClose != nil {
|
|
t.Errorf("close upstream websocket: %v", errClose)
|
|
}
|
|
}()
|
|
|
|
for {
|
|
_, payload, errRead := conn.ReadMessage()
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
authorization := r.Header.Get("Authorization")
|
|
captured <- capturedRequest{authorization: authorization, payload: bytes.Clone(payload)}
|
|
responseID := "resp-auth-a"
|
|
if strings.Contains(authorization, "token-c") {
|
|
responseID = "resp-auth-c"
|
|
}
|
|
completed := []byte(fmt.Sprintf(`{"type":"response.completed","response":{"id":%q,"output":[{"type":"message","id":%q,"role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}`, responseID, "msg-"+responseID))
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
|
|
t.Errorf("write completed websocket message: %v", errWrite)
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
rejectedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "rejected", http.StatusUnauthorized)
|
|
}))
|
|
defer rejectedServer.Close()
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
exec.idStore = &xaiWebsocketIDStateStore{sessions: make(map[string]*xaiWebsocketIDState)}
|
|
defer exec.CloseExecutionSession("xai-auth-switch-session")
|
|
|
|
newAuth := func(id string, token string, baseURL string) *cliproxyauth.Auth {
|
|
return &cliproxyauth.Auth{
|
|
ID: id,
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": baseURL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": token},
|
|
}
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
Metadata: map[string]any{
|
|
cliproxyexecutor.ExecutionSessionMetadataKey: "xai-auth-switch-session",
|
|
},
|
|
}
|
|
ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
|
|
|
|
runRequest := func(auth *cliproxyauth.Auth, body []byte) []byte {
|
|
result, errExecute := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "grok-4.3", Payload: body}, opts)
|
|
if errExecute != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", errExecute)
|
|
}
|
|
var completed []byte
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("stream chunk error = %v", chunk.Err)
|
|
}
|
|
if gjson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
|
|
completed = bytes.Clone(chunk.Payload)
|
|
}
|
|
}
|
|
if len(completed) == 0 {
|
|
t.Fatal("stream did not return response.completed")
|
|
}
|
|
return completed
|
|
}
|
|
|
|
firstCompleted := runRequest(newAuth("auth-a", "token-a", server.URL), []byte(`{"model":"grok-4.3","input":[{"type":"message","id":"user-1","role":"user","content":"first"}]}`))
|
|
firstResponseID := gjson.GetBytes(firstCompleted, "response.id").String()
|
|
if firstResponseID != "resp-auth-a" {
|
|
t.Fatalf("first response ID = %q, want resp-auth-a", firstResponseID)
|
|
}
|
|
firstUpstream := <-captured
|
|
if firstUpstream.authorization != "Bearer token-a" {
|
|
t.Fatalf("first Authorization = %q, want Bearer token-a", firstUpstream.authorization)
|
|
}
|
|
|
|
secondBody := []byte(fmt.Sprintf(`{"model":"grok-4.3","previous_response_id":%q,"input":[{"type":"message","id":"user-2","role":"user","content":"second"}]}`, firstResponseID))
|
|
if _, errExecute := exec.ExecuteStream(ctx, newAuth("auth-b", "token-b", rejectedServer.URL), cliproxyexecutor.Request{Model: "grok-4.3", Payload: secondBody}, opts); errExecute == nil {
|
|
t.Fatal("expected auth B websocket handshake to fail")
|
|
}
|
|
runRequest(newAuth("auth-c", "token-c", server.URL), secondBody)
|
|
secondUpstream := <-captured
|
|
if secondUpstream.authorization != "Bearer token-c" {
|
|
t.Fatalf("second successful Authorization = %q, want Bearer token-c", secondUpstream.authorization)
|
|
}
|
|
if gjson.GetBytes(secondUpstream.payload, "previous_response_id").Exists() {
|
|
t.Fatalf("previous_response_id was sent after auth switch: %s", secondUpstream.payload)
|
|
}
|
|
input := gjson.GetBytes(secondUpstream.payload, "input").Array()
|
|
if len(input) != 3 {
|
|
t.Fatalf("replayed input len = %d, want 3: %s", len(input), secondUpstream.payload)
|
|
}
|
|
if input[0].Get("id").String() != "user-1" || input[1].Get("id").String() != "msg-resp-auth-a" || input[2].Get("id").String() != "user-2" {
|
|
t.Fatalf("unexpected replayed input: %s", secondUpstream.payload)
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamCompactionTriggerUsesHTTPCompactWithRecordedContext(t *testing.T) {
|
|
nativeEncryptedContent := testValidGrokEncryptedContent()
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
capturedWebsocketPayload := make(chan []byte, 1)
|
|
capturedCompactPayload := make(chan []byte, 1)
|
|
compactResponse := []byte(fmt.Sprintf(`{"id":"resp_compact","model":"grok-4.3","output":[{"type":"compaction","encrypted_content":%q}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`, nativeEncryptedContent))
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/responses":
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Errorf("upgrade websocket: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
for i := 0; i < 2; i++ {
|
|
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
_, payload, errRead := conn.ReadMessage()
|
|
if errRead != nil {
|
|
t.Errorf("read upstream websocket message: %v", errRead)
|
|
return
|
|
}
|
|
capturedWebsocketPayload <- bytes.Clone(payload)
|
|
completed := []byte(`{"type":"response.completed","response":{"id":"resp-real","output":[{"type":"message","id":"out-1","role":"assistant","content":"first answer"}],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`)
|
|
if i == 1 {
|
|
completed = []byte(`{"type":"response.completed","response":{"id":"resp-after-compact","output":[{"type":"message","id":"out-2","role":"assistant","content":"second answer"}],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`)
|
|
}
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
|
|
t.Errorf("write completed websocket message: %v", errWrite)
|
|
return
|
|
}
|
|
}
|
|
case "/responses/compact":
|
|
body, errRead := io.ReadAll(r.Body)
|
|
if errRead != nil {
|
|
t.Errorf("read compact body: %v", errRead)
|
|
return
|
|
}
|
|
capturedCompactPayload <- bytes.Clone(body)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(compactResponse)
|
|
default:
|
|
t.Errorf("path = %q, want /responses", r.URL.Path)
|
|
http.Error(w, "unexpected path", http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
|
|
exec.idStore = &xaiWebsocketIDStateStore{sessions: make(map[string]*xaiWebsocketIDState)}
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "xai-auth-compaction",
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
Stream: true,
|
|
Metadata: map[string]any{
|
|
cliproxyexecutor.ExecutionSessionMetadataKey: "xai-compaction-session",
|
|
},
|
|
}
|
|
|
|
result, err := exec.ExecuteStream(cliproxyexecutor.WithDownstreamWebsocket(context.Background()), auth, cliproxyexecutor.Request{
|
|
Model: "grok-4.3",
|
|
Payload: []byte(`{"model":"grok-4.3","stream":true,"input":[{"type":"message","id":"msg-1","role":"user","content":"first"}]}`),
|
|
}, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream first turn error: %v", err)
|
|
}
|
|
for chunk := range result.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("stream chunk error = %v", chunk.Err)
|
|
}
|
|
}
|
|
|
|
select {
|
|
case payload := <-capturedWebsocketPayload:
|
|
if got := gjson.GetBytes(payload, "type").String(); got != "response.create" {
|
|
t.Fatalf("type = %q, want response.create; payload=%s", got, payload)
|
|
}
|
|
input := gjson.GetBytes(payload, "input")
|
|
if !input.IsArray() || len(input.Array()) != 1 {
|
|
t.Fatalf("input = %s, want one first-turn item", input.Raw)
|
|
}
|
|
if gjson.GetBytes(payload, "stream").Exists() {
|
|
t.Fatalf("stream must be omitted for xAI websocket payload: %s", payload)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for upstream websocket payload")
|
|
}
|
|
|
|
compactResult, err := exec.ExecuteStream(cliproxyexecutor.WithDownstreamWebsocket(context.Background()), auth, cliproxyexecutor.Request{
|
|
Model: "grok-4.3",
|
|
Payload: []byte(`{"model":"grok-4.3","stream":true,"previous_response_id":"resp-real-xai-1","input":[{"type":"compaction_trigger"}]}`),
|
|
}, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream compaction trigger error: %v", err)
|
|
}
|
|
for chunk := range compactResult.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("compact stream chunk error = %v", chunk.Err)
|
|
}
|
|
}
|
|
|
|
select {
|
|
case payload := <-capturedCompactPayload:
|
|
if xaiInputHasItemType(payload, "compaction_trigger") {
|
|
t.Fatalf("compaction_trigger reached xai compact body: %s", payload)
|
|
}
|
|
input := gjson.GetBytes(payload, "input")
|
|
if !input.IsArray() || len(input.Array()) != 2 {
|
|
t.Fatalf("compact input = %s, want first request input plus response output", input.Raw)
|
|
}
|
|
if got := input.Array()[0].Get("id").String(); got != "msg-1" {
|
|
t.Fatalf("compact input[0].id = %q, want msg-1; payload=%s", got, payload)
|
|
}
|
|
if got := input.Array()[1].Get("id").String(); got != "out-1" {
|
|
t.Fatalf("compact input[1].id = %q, want out-1; payload=%s", got, payload)
|
|
}
|
|
if got := gjson.GetBytes(payload, "previous_response_id").String(); got != "" {
|
|
t.Fatalf("compact previous_response_id = %q, want empty; payload=%s", got, payload)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for compact HTTP payload")
|
|
}
|
|
|
|
nextResult, err := exec.ExecuteStream(cliproxyexecutor.WithDownstreamWebsocket(context.Background()), auth, cliproxyexecutor.Request{
|
|
Model: "grok-4.3",
|
|
Payload: []byte(`{"model":"grok-4.3","stream":true,"previous_response_id":"resp_compact","input":[{"type":"message","id":"msg-2","role":"user","content":"second"}]}`),
|
|
}, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream post-compaction turn error: %v", err)
|
|
}
|
|
for chunk := range nextResult.Chunks {
|
|
if chunk.Err != nil {
|
|
t.Fatalf("post-compaction stream chunk error = %v", chunk.Err)
|
|
}
|
|
}
|
|
select {
|
|
case payload := <-capturedWebsocketPayload:
|
|
if got := gjson.GetBytes(payload, "previous_response_id").String(); got != "" {
|
|
t.Fatalf("post-compaction previous_response_id = %q, want empty; payload=%s", got, payload)
|
|
}
|
|
input := gjson.GetBytes(payload, "input")
|
|
if !input.IsArray() || len(input.Array()) != 2 {
|
|
t.Fatalf("post-compaction input = %s, want compaction item plus new message", input.Raw)
|
|
}
|
|
if got := input.Array()[0].Get("type").String(); got != "compaction" {
|
|
t.Fatalf("post-compaction input[0].type = %q, want compaction; payload=%s", got, payload)
|
|
}
|
|
if got := input.Array()[0].Get("encrypted_content").String(); got != nativeEncryptedContent {
|
|
t.Fatalf("post-compaction input[0].encrypted_content = %q, want native sample; payload=%s", got, payload)
|
|
}
|
|
if got := input.Array()[1].Get("id").String(); got != "msg-2" {
|
|
t.Fatalf("post-compaction input[1].id = %q, want msg-2; payload=%s", got, payload)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for post-compaction websocket payload")
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketPostCompactionAppendWithoutPreviousReplaysCompactedTranscript(t *testing.T) {
|
|
store := &xaiWebsocketIDStateStore{sessions: make(map[string]*xaiWebsocketIDState)}
|
|
state := getXAIWebsocketIDState(store, "post-compaction-append-session")
|
|
state.replaceTranscriptWithItems([]byte(`{"type":"compaction","encrypted_content":"compact-state"}`))
|
|
state.mapDownstreamToUpstream("resp-compact", "")
|
|
|
|
fullReset := []byte(`{"type":"response.create","model":"grok-4.3","input":[{"type":"message","id":"msg-full"}]}`)
|
|
fullMapper := newXAIWebsocketRequestIDMapper(store, "post-compaction-append-session", fullReset)
|
|
if full := fullMapper.upstreamRequestPayload(fullReset); len(gjson.GetBytes(full, "input").Array()) != 1 {
|
|
t.Fatalf("self-contained response.create unexpectedly replayed compacted transcript: %s", full)
|
|
}
|
|
|
|
payload := []byte(`{"type":"response.append","model":"grok-4.3","input":[{"type":"message","id":"msg-2","role":"user","content":"second"}]}`)
|
|
mapper := newXAIWebsocketRequestIDMapper(store, "post-compaction-append-session", payload)
|
|
got := mapper.upstreamRequestPayload(payload)
|
|
input := gjson.GetBytes(got, "input").Array()
|
|
if len(input) != 2 {
|
|
t.Fatalf("post-compaction append input len = %d, want 2: %s", len(input), got)
|
|
}
|
|
if gotType := input[0].Get("type").String(); gotType != "compaction" {
|
|
t.Fatalf("post-compaction append input[0].type = %q, want compaction: %s", gotType, got)
|
|
}
|
|
if gotID := input[1].Get("id").String(); gotID != "msg-2" {
|
|
t.Fatalf("post-compaction append input[1].id = %q, want msg-2: %s", gotID, got)
|
|
}
|
|
|
|
state.recordTranscriptTurn(got, []byte(`{"type":"response.completed","response":{"id":"resp-after-compact","output":[{"type":"message","id":"out-2"}]}}`), true)
|
|
nextPayload := []byte(`{"type":"response.create","model":"grok-4.3","input":[{"type":"message","id":"msg-3"}]}`)
|
|
nextMapper := newXAIWebsocketRequestIDMapper(store, "post-compaction-append-session", nextPayload)
|
|
next := nextMapper.upstreamRequestPayload(nextPayload)
|
|
if nextInput := gjson.GetBytes(next, "input").Array(); len(nextInput) != 1 || nextInput[0].Get("id").String() != "msg-3" {
|
|
t.Fatalf("compacted transcript replay was not cleared after success: %s", next)
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketPostCompactionWarmupPreservesTranscriptForLaterCompaction(t *testing.T) {
|
|
store := &xaiWebsocketIDStateStore{sessions: make(map[string]*xaiWebsocketIDState)}
|
|
state := getXAIWebsocketIDState(store, "warmup-reset-session")
|
|
state.replaceTranscriptWithItems([]byte(`{"type":"compaction","encrypted_content":"compact-state"}`))
|
|
|
|
warmupPayload := []byte(`{"type":"response.append","model":"grok-4.3","generate":false,"input":[{"type":"message","id":"warmup-context"}]}`)
|
|
warmupMapper := newXAIWebsocketRequestIDMapper(store, "warmup-reset-session", warmupPayload)
|
|
warmupUpstream := warmupMapper.upstreamRequestPayload(warmupPayload)
|
|
if !warmupMapper.replayedCompactedTranscript {
|
|
t.Fatal("post-compaction warmup did not mark full transcript replay")
|
|
}
|
|
state.recordTranscriptTurn(
|
|
warmupUpstream,
|
|
[]byte(`{"type":"response.completed","response":{"id":"resp-warmup","output":[]}}`),
|
|
true,
|
|
)
|
|
|
|
appendPayload := []byte(`{"type":"response.append","model":"grok-4.3","input":[{"type":"message","id":"msg-after-warmup"}]}`)
|
|
appendMapper := newXAIWebsocketRequestIDMapper(store, "warmup-reset-session", appendPayload)
|
|
appendUpstream := appendMapper.upstreamRequestPayload(appendPayload)
|
|
input := gjson.GetBytes(appendUpstream, "input").Array()
|
|
if len(input) != 1 || input[0].Get("id").String() != "msg-after-warmup" {
|
|
t.Fatalf("warmup retained pending replay instead of native append: %s", appendUpstream)
|
|
}
|
|
state.recordTranscriptTurn(
|
|
appendUpstream,
|
|
[]byte(`{"type":"response.completed","response":{"id":"resp-after-warmup","output":[{"type":"message","id":"out-after-warmup"}]}}`),
|
|
false,
|
|
)
|
|
|
|
transcript := gjson.ParseBytes(state.snapshotTranscriptInput()).Array()
|
|
wantTypes := []string{"compaction", "message", "message", "message"}
|
|
if len(transcript) != len(wantTypes) {
|
|
t.Fatalf("post-warmup transcript len = %d, want %d: %s", len(transcript), len(wantTypes), state.snapshotTranscriptInput())
|
|
}
|
|
for i, wantType := range wantTypes {
|
|
if gotType := transcript[i].Get("type").String(); gotType != wantType {
|
|
t.Fatalf("post-warmup transcript[%d].type = %q, want %q: %s", i, gotType, wantType, state.snapshotTranscriptInput())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketEmptyFullResetClearsPendingCompactionReplay(t *testing.T) {
|
|
store := &xaiWebsocketIDStateStore{sessions: make(map[string]*xaiWebsocketIDState)}
|
|
state := getXAIWebsocketIDState(store, "empty-reset-session")
|
|
state.replaceTranscriptWithItems([]byte(`{"type":"compaction","encrypted_content":"stale-compact-state"}`))
|
|
state.recordTranscriptTurn(
|
|
[]byte(`{"type":"response.create","model":"grok-4.3","input":[]}`),
|
|
[]byte(`{"type":"response.completed","response":{"id":"resp-empty","output":[]}}`),
|
|
true,
|
|
)
|
|
|
|
appendPayload := []byte(`{"type":"response.append","model":"grok-4.3","input":[{"type":"message","id":"msg-new"}]}`)
|
|
mapper := newXAIWebsocketRequestIDMapper(store, "empty-reset-session", appendPayload)
|
|
got := mapper.upstreamRequestPayload(appendPayload)
|
|
input := gjson.GetBytes(got, "input").Array()
|
|
if len(input) != 1 || input[0].Get("id").String() != "msg-new" {
|
|
t.Fatalf("empty full reset retained stale compaction replay: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestValidateXAIWebsocketCompactionResponse(t *testing.T) {
|
|
valid := []byte(`{"id":"resp_compact","output":[{"type":"compaction","encrypted_content":"opaque-state"}]}`)
|
|
responseID, item, err := validateXAIWebsocketCompactionResponse(valid)
|
|
if err != nil {
|
|
t.Fatalf("valid compaction response error: %v", err)
|
|
}
|
|
if responseID != "resp_compact" || gjson.GetBytes(item, "encrypted_content").String() != "opaque-state" {
|
|
t.Fatalf("validated compaction response = id:%q item:%s", responseID, item)
|
|
}
|
|
|
|
for _, payload := range [][]byte{
|
|
nil,
|
|
[]byte(`{}`),
|
|
[]byte(`{"id":"resp_empty","output":[]}`),
|
|
[]byte(`{"id":123,"output":[{"type":"compaction","encrypted_content":"opaque"}]}`),
|
|
[]byte(`{"id":"resp_object","output":{"0":{"type":"compaction","encrypted_content":"opaque"}}}`),
|
|
[]byte(`{"id":"resp_numeric_state","output":[{"type":"compaction","encrypted_content":123}]}`),
|
|
[]byte(`{"id":"resp_missing_state","output":[{"type":"compaction"}]}`),
|
|
} {
|
|
if _, _, errInvalid := validateXAIWebsocketCompactionResponse(payload); errInvalid == nil {
|
|
t.Fatalf("invalid compaction response accepted: %s", payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildXAIWebsocketRequestBodySetsStoreAndKeepsPromptCacheKey(t *testing.T) {
|
|
body := []byte(`{"model":"grok-4.3","stream":true,"stream_options":{"include_usage":true},"background":true,"prompt_cache_key":"cache-1","previous_response_id":"resp-prev","instructions":"system prompt","input":[{"type":"message","role":"user","content":"hello"}]}`)
|
|
|
|
payload := buildXAIWebsocketRequestBody(body)
|
|
|
|
if got := gjson.GetBytes(payload, "type").String(); got != "response.create" {
|
|
t.Fatalf("type = %q, want response.create; payload=%s", got, payload)
|
|
}
|
|
if gjson.GetBytes(payload, "stream").Exists() {
|
|
t.Fatalf("stream must be omitted for xAI websocket payload: %s", payload)
|
|
}
|
|
if gjson.GetBytes(payload, "stream_options").Exists() {
|
|
t.Fatalf("stream_options must be omitted for xAI websocket payload: %s", payload)
|
|
}
|
|
if gjson.GetBytes(payload, "background").Exists() {
|
|
t.Fatalf("background must be omitted for xAI websocket payload: %s", payload)
|
|
}
|
|
if got := gjson.GetBytes(payload, "prompt_cache_key").String(); got != "cache-1" {
|
|
t.Fatalf("prompt_cache_key = %q, want cache-1; payload=%s", got, payload)
|
|
}
|
|
if got := gjson.GetBytes(payload, "store").Bool(); !got {
|
|
t.Fatalf("store = false, want true; payload=%s", payload)
|
|
}
|
|
if gjson.GetBytes(payload, "instructions").Exists() {
|
|
t.Fatalf("instructions must be omitted when previous_response_id is set: %s", payload)
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamCompletesGenerateFalseWarmup(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
capturedPayload := make(chan []byte, 1)
|
|
releaseServer := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Errorf("upgrade websocket: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
_, payload, errRead := conn.ReadMessage()
|
|
if errRead != nil {
|
|
t.Errorf("read upstream websocket message: %v", errRead)
|
|
return
|
|
}
|
|
capturedPayload <- bytes.Clone(payload)
|
|
created := []byte(`{"type":"response.created","response":{"id":"resp-warmup-1","object":"response","status":"in_progress","output":[]}}`)
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, created); errWrite != nil {
|
|
t.Errorf("write created websocket message: %v", errWrite)
|
|
return
|
|
}
|
|
<-releaseServer
|
|
}))
|
|
defer server.Close()
|
|
defer close(releaseServer)
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "xai-auth-warmup",
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
req := cliproxyexecutor.Request{
|
|
Model: "grok-4.3",
|
|
Payload: []byte(`{"model":"grok-4.3","generate":false,"input":[{"type":"message","role":"user","content":"warm up"}]}`),
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
}
|
|
ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
|
|
|
|
result, err := exec.ExecuteStream(ctx, auth, req, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", err)
|
|
}
|
|
|
|
select {
|
|
case payload := <-capturedPayload:
|
|
if got := gjson.GetBytes(payload, "generate").Bool(); got {
|
|
t.Fatalf("generate = true, want false; payload=%s", payload)
|
|
}
|
|
if got := gjson.GetBytes(payload, "type").String(); got != "response.create" {
|
|
t.Fatalf("type = %q, want response.create; payload=%s", got, payload)
|
|
}
|
|
if got := gjson.GetBytes(payload, "store").Bool(); !got {
|
|
t.Fatalf("store = false, want true; payload=%s", payload)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for upstream websocket payload")
|
|
}
|
|
|
|
var gotTypes []string
|
|
for {
|
|
select {
|
|
case chunk, ok := <-result.Chunks:
|
|
if !ok {
|
|
if len(gotTypes) != 2 {
|
|
t.Fatalf("event types = %v, want response.created and response.completed", gotTypes)
|
|
}
|
|
return
|
|
}
|
|
if chunk.Err != nil {
|
|
t.Fatalf("chunk error = %v", chunk.Err)
|
|
}
|
|
gotTypes = append(gotTypes, gjson.GetBytes(bytes.TrimSpace(chunk.Payload), "type").String())
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatalf("timed out waiting for warmup stream to close; event types so far: %v", gotTypes)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamHandshakeFreeUsageExhaustedSetsRetryAfter(t *testing.T) {
|
|
body := []byte(`{"code":"subscription:free-usage-exhausted","error":"You've used all the included free usage for now."}`)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
if _, errWrite := w.Write(body); errWrite != nil {
|
|
t.Errorf("write handshake rejection: %v", errWrite)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "xai-auth-free-usage",
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
req := cliproxyexecutor.Request{
|
|
Model: "grok-4.3",
|
|
Payload: []byte(`{"model":"grok-4.3","input":"hello"}`),
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
}
|
|
|
|
_, err := exec.ExecuteStream(context.Background(), auth, req, opts)
|
|
if err == nil {
|
|
t.Fatal("ExecuteStream() error = nil, want handshake rejection")
|
|
}
|
|
status, ok := err.(interface{ StatusCode() int })
|
|
if !ok || status.StatusCode() != http.StatusTooManyRequests {
|
|
t.Fatalf("status = %#v, want 429", err)
|
|
}
|
|
retryable, ok := err.(interface{ RetryAfter() *time.Duration })
|
|
if !ok || retryable.RetryAfter() == nil {
|
|
t.Fatalf("expected RetryAfter for free-usage-exhausted handshake error: %#v", err)
|
|
}
|
|
if got := *retryable.RetryAfter(); got != 24*time.Hour {
|
|
t.Fatalf("RetryAfter = %v, want 24h", got)
|
|
}
|
|
if got := err.Error(); got != string(body) {
|
|
t.Fatalf("error payload = %q, want %q", got, body)
|
|
}
|
|
}
|
|
|
|
func TestParseXAIWebsocketErrorFreeUsageExhaustedSetsRetryAfter(t *testing.T) {
|
|
payload := []byte(`{"type":"error","status":429,"error":{"code":"subscription:free-usage-exhausted","message":"You've used all the included free usage for now."}}`)
|
|
err, ok := parseXAIWebsocketError(payload)
|
|
if !ok {
|
|
t.Fatal("expected xAI websocket error")
|
|
}
|
|
|
|
retryable, ok := err.(interface{ RetryAfter() *time.Duration })
|
|
if !ok || retryable.RetryAfter() == nil {
|
|
t.Fatalf("expected RetryAfter for free-usage-exhausted websocket event: %#v", err)
|
|
}
|
|
if got := *retryable.RetryAfter(); got != 24*time.Hour {
|
|
t.Fatalf("RetryAfter = %v, want 24h", got)
|
|
}
|
|
parsed := gjson.Parse(err.Error())
|
|
if got := parsed.Get("status").Int(); got != http.StatusTooManyRequests {
|
|
t.Fatalf("error status = %d, want 429; payload=%s", got, err)
|
|
}
|
|
if got := parsed.Get("error.code").String(); got != "subscription:free-usage-exhausted" {
|
|
t.Fatalf("error code = %q, want free-usage-exhausted; payload=%s", got, err)
|
|
}
|
|
}
|
|
|
|
func TestParseXAIWebsocketErrorBadCredentialsRemapsToUnauthorized(t *testing.T) {
|
|
payload := []byte(`{"type":"error","status":403,"headers":{"x-request-id":"req-bad-credentials"},"error":{"code":"unauthenticated:bad-credentials","message":"The OAuth2 access token could not be validated."}}`)
|
|
err, ok := parseXAIWebsocketError(payload)
|
|
if !ok {
|
|
t.Fatal("expected xAI websocket error")
|
|
}
|
|
|
|
status, okStatus := err.(interface{ StatusCode() int })
|
|
if !okStatus || status.StatusCode() != http.StatusUnauthorized {
|
|
t.Fatalf("status = %#v, want 401", err)
|
|
}
|
|
headerSource, okHeaders := err.(interface{ Headers() http.Header })
|
|
if !okHeaders {
|
|
t.Fatalf("expected websocket error to preserve headers, got %#v", err)
|
|
}
|
|
if got := headerSource.Headers().Get("x-request-id"); got != "req-bad-credentials" {
|
|
t.Fatalf("x-request-id = %q, want req-bad-credentials", got)
|
|
}
|
|
parsed := gjson.Parse(err.Error())
|
|
if got := parsed.Get("error.code").String(); got != "unauthenticated:bad-credentials" {
|
|
t.Fatalf("error code = %q, want unauthenticated:bad-credentials; payload=%s", got, err)
|
|
}
|
|
}
|
|
|
|
func TestParseXAIWebsocketBareErrorBadCredentialsRemapsToUnauthorized(t *testing.T) {
|
|
payload := []byte(`{"status":403,"error":{"code":"unauthenticated:bad-credentials","message":"The OAuth2 access token could not be validated."}}`)
|
|
err, ok := parseXAIWebsocketError(payload)
|
|
if !ok {
|
|
t.Fatal("expected bare xAI websocket error")
|
|
}
|
|
|
|
status, okStatus := err.(interface{ StatusCode() int })
|
|
if !okStatus || status.StatusCode() != http.StatusUnauthorized {
|
|
t.Fatalf("status = %#v, want 401", err)
|
|
}
|
|
}
|
|
|
|
func TestParseXAIWebsocketBareErrorFreeUsageExhaustedSetsRetryAfter(t *testing.T) {
|
|
payload := []byte(`{"status":429,"error":{"code":"subscription:free-usage-exhausted","message":"You've used all the included free usage for now."}}`)
|
|
err, ok := parseXAIWebsocketError(payload)
|
|
if !ok {
|
|
t.Fatal("expected bare xAI websocket error")
|
|
}
|
|
|
|
retryable, ok := err.(interface{ RetryAfter() *time.Duration })
|
|
if !ok || retryable.RetryAfter() == nil {
|
|
t.Fatalf("expected RetryAfter for bare free-usage-exhausted websocket event: %#v", err)
|
|
}
|
|
if got := *retryable.RetryAfter(); got != 24*time.Hour {
|
|
t.Fatalf("RetryAfter = %v, want 24h", got)
|
|
}
|
|
parsed := gjson.Parse(err.Error())
|
|
if got := parsed.Get("type").String(); got != "error" {
|
|
t.Fatalf("error type = %q, want error; payload=%s", got, err)
|
|
}
|
|
if got := parsed.Get("error.code").String(); got != "subscription:free-usage-exhausted" {
|
|
t.Fatalf("error code = %q, want free-usage-exhausted; payload=%s", got, err)
|
|
}
|
|
}
|
|
|
|
func TestXAIWebsocketsExecuteStreamStopsOnBareErrorPayload(t *testing.T) {
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
|
releaseServer := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
t.Errorf("upgrade websocket: %v", err)
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
|
|
if _, _, errRead := conn.ReadMessage(); errRead != nil {
|
|
t.Errorf("read upstream websocket message: %v", errRead)
|
|
return
|
|
}
|
|
payload := []byte(`{"error":{"message":"Request validation error: {\"code\":\"400\",\"error\":\"Argument not supported: instructions and previous_response_id together\"}","type":"api_error"}}`)
|
|
if errWrite := conn.WriteMessage(websocket.TextMessage, payload); errWrite != nil {
|
|
t.Errorf("write error websocket message: %v", errWrite)
|
|
return
|
|
}
|
|
<-releaseServer
|
|
}))
|
|
defer server.Close()
|
|
defer close(releaseServer)
|
|
|
|
exec := NewXAIWebsocketsExecutor(&config.Config{})
|
|
auth := &cliproxyauth.Auth{
|
|
ID: "xai-auth-error",
|
|
Provider: "xai",
|
|
Attributes: map[string]string{
|
|
"base_url": server.URL,
|
|
"websockets": "true",
|
|
},
|
|
Metadata: map[string]any{"access_token": "xai-token"},
|
|
}
|
|
req := cliproxyexecutor.Request{
|
|
Model: "grok-4.3",
|
|
Payload: []byte(`{"model":"grok-4.3","input":"hello"}`),
|
|
}
|
|
opts := cliproxyexecutor.Options{
|
|
SourceFormat: sdktranslator.FormatOpenAIResponse,
|
|
ResponseFormat: sdktranslator.FormatOpenAIResponse,
|
|
}
|
|
ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background())
|
|
|
|
result, err := exec.ExecuteStream(ctx, auth, req, opts)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteStream() error = %v", err)
|
|
}
|
|
|
|
select {
|
|
case chunk, ok := <-result.Chunks:
|
|
if !ok {
|
|
t.Fatal("stream closed before error chunk")
|
|
}
|
|
if chunk.Err == nil {
|
|
t.Fatalf("chunk error = nil, want upstream error; payload=%s", chunk.Payload)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for bare upstream error")
|
|
}
|
|
}
|