package provider import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "strings" "testing" "golang.org/x/net/websocket" ) func TestResponsesCacheDiagnosticsRequireEstimatedThreshold(t *testing.T) { newWire := func(text string) *codexRequest { return &codexRequest{ PromptCacheOptions: &codexPromptCacheOptions{Mode: "implicit"}, Input: []any{codexInputMessage{Role: "user", Content: []any{ codexInputText{Type: "input_text", Text: text}, }}}, } } if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 64)), "http_sse", "full"); diagnostics.Eligible { t.Fatalf("short request diagnostics = %#v, want ineligible", diagnostics) } if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 5_000)), "http_sse", "full"); !diagnostics.Eligible { t.Fatalf("long request diagnostics = %#v, want eligible", diagnostics) } } // An image-only tool result must not serialize to an empty // function_call_output (the Responses API may reject it) and a // following user-message image must serialize as input_image so the // model actually receives the bytes. func TestCodexImageToolResultMirror(t *testing.T) { c := newOpenAICodexClient("token", "acct", "") wire, err := c.buildRequest(Request{ Model: "gpt-5.5", Messages: []Message{ {Role: RoleUser, Content: []Content{TextBlock{Text: "look at this"}}}, {Role: RoleAssistant, Content: []Content{ ToolCallBlock{ID: "call_1", Name: "read", Arguments: []byte(`{"path":"x.png"}`)}, }}, {Role: RoleTool, Content: []Content{ ToolResultBlock{CallID: "call_1", Content: []Content{ ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")}, }}, }}, // The agent loop appends this mirror after an image tool result. {Role: RoleUser, Content: []Content{ TextBlock{Text: "Tool output included the following image content:"}, ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")}, }}, }, }) if err != nil { t.Fatal(err) } var sawFnOutput, sawInputImage bool for _, item := range wire.Input { switch v := item.(type) { case codexFunctionCallOutput: sawFnOutput = true if strings.TrimSpace(v.Output) == "" { t.Fatalf("image-only tool result produced empty function_call_output") } if !strings.Contains(strings.ToLower(v.Output), "image") { t.Fatalf("placeholder should mention image, got %q", v.Output) } case codexInputMessage: for _, ct := range v.Content { if img, ok := ct.(codexInputImage); ok && img.Type == "input_image" { sawInputImage = true } } } } if !sawFnOutput { t.Fatalf("no function_call_output emitted") } if !sawInputImage { t.Fatalf("mirrored user image was not serialized as input_image") } } type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) { c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses") var gotReq *http.Request var gotBody codexRequest c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { gotReq = r if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { t.Fatal(err) } return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("")), }, nil }) events, err := c.Stream(context.Background(), Request{ Model: "gpt-5.6-terra", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, }) if err != nil { t.Fatal(err) } for range events { } if gotReq == nil { t.Fatal("request was not sent") } if gotReq.Header.Get("originator") != "codex_cli_rs" { t.Fatalf("originator = %q", gotReq.Header.Get("originator")) } if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) } if gotBody.PromptCacheKey != "cache-root-1" { t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey) } if gotReq.Header.Get("session-id") != "cache-root-1" || gotReq.Header.Get("thread-id") != "thread-1" || gotReq.Header.Get("x-client-request-id") != "thread-1" { t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id")) } } func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) { c := newOpenAICodexClient("token", "acct", "") wire, err := c.buildRequest(Request{Model: " gpt-5.6-sol ", Reasoning: "max"}) if err != nil { t.Fatal(err) } if wire.Model != "gpt-5.6-sol" { t.Fatalf("model = %q, want trimmed id", wire.Model) } if wire.Reasoning == nil || wire.Reasoning.Effort != "max" { t.Fatalf("reasoning = %+v", wire.Reasoning) } } func TestResponsesToolsSelectStrictModeFromSchema(t *testing.T) { c := newOpenAICodexClient("token", "acct", "") wire, err := c.buildRequest(Request{ Model: "gpt-5.6-sol", Tools: []Tool{ { Name: "grep", Schema: json.RawMessage(`{ "type":"object", "properties":{"pattern":{"type":"string"},"path":{"type":"string"}}, "required":["pattern","path"], "additionalProperties":false }`), }, { Name: "subagent_spawn", Schema: json.RawMessage(`{ "type":"object", "properties":{ "task":{"type":"string"}, "budget_ratio":{"type":"number"}, "budget_tokens":{"type":"integer"} }, "required":["task"] }`), }, }, }) if err != nil { t.Fatal(err) } if len(wire.Tools) != 2 { t.Fatalf("tools = %d, want 2", len(wire.Tools)) } if !wire.Tools[0].Strict { t.Fatal("closed, fully-required grep schema should use strict mode") } if wire.Tools[1].Strict { t.Fatal("schema with optional properties should opt out of strict normalization") } c.providerName = ProviderOpenCodeGo wire, err = c.buildRequest(Request{ Model: "gpt-5.6-sol", Tools: []Tool{{ Name: "grep", Schema: wire.Tools[0].Parameters, }}, }) if err != nil { t.Fatal(err) } if wire.Tools[0].Strict { t.Fatal("non-OpenAI Responses provider should opt out of strict mode") } } func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) { SetLiveModels([]Model{{ Provider: "custom-responses", ID: "reasoning-model", API: APIResponses, Reasoning: true, ReasoningLevelMap: map[string]string{"max": "max"}, }}) t.Cleanup(func() { SetLiveModels(nil) }) named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient) c := named.inner.(*codexClient) wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"}) if err != nil { t.Fatal(err) } if wire.Reasoning == nil || wire.Reasoning.Effort != "max" { t.Fatalf("reasoning = %+v", wire.Reasoning) } } func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) { named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient) c := named.inner.(*codexClient) if c.baseURL != "https://api.x.ai/v1/responses" { t.Fatalf("base URL = %q", c.baseURL) } for _, model := range []string{"grok-4.5", "grok-build-0.1"} { if _, err := c.buildRequest(Request{Model: model}); err != nil { t.Errorf("build request for %s: %v", model, err) } } } func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) { named := NewOpenAIResponsesNamed("token", "https://api.openai.com/v1/responses", "openai").(*renamedClient) ws := named.inner.(*responsesWebSocketClient) c := ws.http var gotReq *http.Request var gotBody codexRequest ws.dial = func(context.Context, Request) (*websocket.Conn, error) { return nil, errors.New("test websocket unavailable") } c.http = openaiResponsesHTTPClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { gotReq = r if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { t.Fatal(err) } return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("")), }, nil })}) events, err := ws.Stream(context.Background(), Request{ Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, }) if err != nil { t.Fatal(err) } for range events { } if gotReq == nil { t.Fatal("request was not sent") } if gotReq.Header.Get("session-id") != "" { t.Fatalf("session-id = %q", gotReq.Header.Get("session-id")) } if gotBody.PromptCacheKey != "cache-root-2" { t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey) } if gotBody.PromptCacheOptions == nil || gotBody.PromptCacheOptions.Mode != "implicit" || len(gotBody.Input) < 2 { t.Fatalf("explicit cache boundary = options=%+v input=%#v", gotBody.PromptCacheOptions, gotBody.Input) } boundary, ok := gotBody.Input[0].(map[string]any) if !ok || boundary["role"] != "developer" { t.Fatalf("stable cache boundary = %#v", gotBody.Input[0]) } content, ok := boundary["content"].([]any) if !ok || len(content) != 1 { t.Fatalf("stable cache boundary content = %#v", boundary) } text, ok := content[0].(map[string]any) if !ok || text["type"] != "input_text" { t.Fatalf("stable cache text block = %#v", content) } breakpoint, ok := text["prompt_cache_breakpoint"].(map[string]any) if !ok || breakpoint["mode"] != "explicit" { t.Fatalf("stable cache breakpoint = %#v", text) } if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" { t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header) } } func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T) { named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient) c := named.inner.(*codexClient) var gotBody codexRequest c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { t.Fatal(err) } return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil }) events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread", TurnID: "turn"}}) if err != nil { t.Fatal(err) } for range events { } if gotBody.PromptCacheKey != "" { t.Fatalf("prompt_cache_key = %q, want omitted for undeclared endpoint", gotBody.PromptCacheKey) } } func TestCodexNestedStreamError(t *testing.T) { c := newOpenAICodexClient("token", "acct", "") resp := &http.Response{ Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")), } out := make(chan Event, 16) go c.runStream(context.Background(), resp, Request{Model: "gpt-5.6-sol"}, out) var got error for ev := range out { if done, ok := ev.(EventDone); ok { got = done.Err } } if got == nil || got.Error() != "codex error: limited preview" { t.Fatalf("error = %v", got) } } // TestCodexSubscriptionAlwaysUsesCodexCLIShape pins the ChatGPT // subscription backend to the Codex CLI request identity for every // model. The backend load-sheds unrecognized originator/user-agent // pairs with "Our servers are currently overloaded" stream errors // even when capacity is fine, so the CLI shape is required for // reliable service, not just for preview-model admission. func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) { c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses") var gotReq *http.Request var body bytes.Buffer c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { gotReq = r _, _ = body.ReadFrom(r.Body) return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("")), }, nil }) events, err := c.Stream(context.Background(), Request{ Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, }) if err != nil { t.Fatal(err) } for range events { } if gotReq == nil { t.Fatal("request was not sent") } if gotReq.Header.Get("originator") != "codex_cli_rs" { t.Fatalf("originator = %q", gotReq.Header.Get("originator")) } if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" { t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent")) } if gotReq.Header.Get("session-id") != "cache-root-2" || gotReq.Header.Get("thread-id") != "thread-2" || gotReq.Header.Get("x-client-request-id") != "thread-2" { t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id")) } if !strings.Contains(body.String(), "prompt_cache_key") { t.Fatalf("request missing prompt_cache_key: %s", body.String()) } } func TestCodexRetriesUnsupportedPromptCacheRetentionWithNativeIdentity(t *testing.T) { c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses") var headers []http.Header var bodies []string c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { headers = append(headers, r.Header.Clone()) body, err := io.ReadAll(r.Body) if err != nil { t.Fatal(err) } bodies = append(bodies, string(body)) if len(headers) == 1 { return &http.Response{ StatusCode: http.StatusBadRequest, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{ "error": { "message": "prompt_cache_retention is not supported on this model", "type": "invalid_request_error", "param": "prompt_cache_retention", "code": "invalid_parameter" } }`)), }, nil } return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("")), }, nil }) events, err := c.Stream(context.Background(), Request{ Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-3", ThreadID: "thread-3", TurnID: "turn-3"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, }) if err != nil { t.Fatal(err) } for event := range events { if done, ok := event.(EventDone); ok && done.Err != nil { t.Fatalf("retry stream failed: %v", done.Err) } } if len(headers) != 2 { t.Fatalf("requests = %d, want exactly one retry", len(headers)) } if got := headers[0].Get("originator"); got != "codex_cli_rs" { t.Fatalf("initial originator = %q, want codex_cli_rs", got) } // The retry drops the Codex CLI identity and uses the native one on the // same session/request correlation and body. if got := headers[1].Get("originator"); got != "zot" { t.Fatalf("retry originator = %q, want zot", got) } if headers[1].Get("session-id") != "cache-root-3" || headers[1].Get("thread-id") != "thread-3" || headers[1].Get("x-client-request-id") != "thread-3" { t.Fatalf("retry correlation headers = session=%q thread=%q request=%q", headers[1].Get("session-id"), headers[1].Get("thread-id"), headers[1].Get("x-client-request-id")) } if len(bodies) != 2 || !strings.Contains(bodies[1], `"prompt_cache_key":"cache-root-3"`) { t.Fatalf("retry body did not preserve prompt cache key: %v", bodies) } } func TestCodexDoesNotRetryUnrelatedBadRequest(t *testing.T) { c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses") requests := 0 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { requests++ return &http.Response{ StatusCode: http.StatusBadRequest, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{ "error": {"message": "bad input", "param": "input", "code": "invalid_parameter"} }`)), }, nil }) _, err := c.Stream(context.Background(), Request{ Model: "gpt-5.6-sol", Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, }) if err == nil { t.Fatal("expected error, got nil") } if requests != 1 { t.Fatalf("requests = %d, want 1 (no retry)", requests) } if !strings.Contains(err.Error(), "http 400") { t.Fatalf("error = %v, want http 400", err) } } func TestCodexUnsupportedRetentionSecondFailureReturns(t *testing.T) { c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses") requests := 0 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { requests++ return &http.Response{ StatusCode: http.StatusBadRequest, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{ "error": { "message": "prompt_cache_retention is not supported", "param": "prompt_cache_retention", "code": "unsupported_parameter" } }`)), }, nil }) _, err := c.Stream(context.Background(), Request{ Model: "gpt-5.6-sol", Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}}, }) if err == nil { t.Fatal("expected error, got nil") } if requests != 2 { t.Fatalf("requests = %d, want exactly one retry", requests) } if !strings.Contains(err.Error(), "http 400") { t.Fatalf("error = %v, want second http 400", err) } } func TestUnsupportedPromptCacheRetentionPredicate(t *testing.T) { retention := func(code string) string { return `{"error":{"message":"` + code + `","param":"prompt_cache_retention","code":"invalid_parameter"}}` } cases := []struct { name string status int body string want bool }{ {"code match", http.StatusBadRequest, retention("anything"), true}, {"message match", http.StatusBadRequest, `{"error":{"message":"retention not supported here","param":"prompt_cache_retention","code":"other"}}`, true}, {"wrong status", http.StatusForbidden, retention("anything"), false}, {"wrong param", http.StatusBadRequest, `{"error":{"message":"x","param":"input","code":"invalid_parameter"}}`, false}, {"no signal", http.StatusBadRequest, `{"error":{"message":"x","param":"prompt_cache_retention","code":"other"}}`, false}, {"malformed", http.StatusBadRequest, "not json", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := unsupportedPromptCacheRetention(tc.status, []byte(tc.body)); got != tc.want { t.Fatalf("predicate = %v, want %v", got, tc.want) } }) } }