mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
Add gateway.models_list_read_max_bytes with the existing 8 MiB behavior as its default, and apply it consistently to generic, Codex, and Antigravity model-list reads. Read one sentinel byte for Codex manifests so oversized responses return an explicit bounded upstream error instead of malformed JSON.
1497 lines
51 KiB
Go
1497 lines
51 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/net/http2"
|
|
)
|
|
|
|
type codexModelsHTTPUpstreamStub struct {
|
|
do func(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error)
|
|
}
|
|
|
|
type codexModelsBlockingBody struct {
|
|
ctx context.Context
|
|
readStarted chan struct{}
|
|
startedOnce *sync.Once
|
|
release <-chan struct{}
|
|
body *strings.Reader
|
|
}
|
|
|
|
func (b *codexModelsBlockingBody) Read(p []byte) (int, error) {
|
|
b.startedOnce.Do(func() { close(b.readStarted) })
|
|
select {
|
|
case <-b.release:
|
|
return b.body.Read(p)
|
|
case <-b.ctx.Done():
|
|
return 0, b.ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (b *codexModelsBlockingBody) Close() error { return nil }
|
|
|
|
func (s *codexModelsHTTPUpstreamStub) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
|
return s.do(req, proxyURL, accountID, accountConcurrency)
|
|
}
|
|
|
|
func (s *codexModelsHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
|
|
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
|
}
|
|
|
|
func TestIsRetryableCodexModelsManifestTransportError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
retryable bool
|
|
}{
|
|
{name: "nil", err: nil},
|
|
{name: "configuration error", err: errors.New("invalid proxy URL")},
|
|
{name: "upstream configuration error", err: errors.New("upstream error: invalid proxy")},
|
|
{name: "proxy connection configuration error", err: errors.New("proxy connection error: invalid configuration")},
|
|
{name: "canceled request", err: context.Canceled},
|
|
{
|
|
name: "redirect policy error",
|
|
err: &url.Error{
|
|
Op: "Get",
|
|
URL: "https://upstream.example/v1/models",
|
|
Err: errors.New("stopped after 10 redirects"),
|
|
},
|
|
},
|
|
{name: "deadline exceeded", err: context.DeadlineExceeded, retryable: true},
|
|
{name: "unexpected EOF", err: io.ErrUnexpectedEOF, retryable: true},
|
|
{name: "closed connection", err: net.ErrClosed, retryable: true},
|
|
{
|
|
name: "network operation",
|
|
err: &net.OpError{
|
|
Op: "read",
|
|
Net: "tcp",
|
|
Err: errors.New("connection reset"),
|
|
},
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "DNS error",
|
|
err: &net.DNSError{Err: "temporary failure", Name: "upstream.example"},
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "typed HTTP2 GOAWAY",
|
|
err: http2.GoAwayError{ErrCode: http2.ErrCodeNo},
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "stdlib HTTP2 GOAWAY",
|
|
err: errors.New("http2: server sent GOAWAY and closed the connection; LastStreamID=1, ErrCode=NO_ERROR"),
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "stdlib HTTP2 refused stream",
|
|
err: errors.New("stream error: stream ID 3; REFUSED_STREAM"),
|
|
retryable: true,
|
|
},
|
|
{
|
|
name: "stdlib HTTP2 connection error",
|
|
err: errors.New(`Get "https://upstream.example/v1/models": connection error: PROTOCOL_ERROR`),
|
|
retryable: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := isRetryableCodexModelsManifestTransportError(tt.err); got != tt.retryable {
|
|
t.Fatalf("retryable = %v, want %v", got, tt.retryable)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newCodexModelsAPIKeyTestService(upstream HTTPUpstream) *OpenAIGatewayService {
|
|
return &OpenAIGatewayService{
|
|
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{
|
|
Enabled: false,
|
|
}}},
|
|
httpUpstream: upstream,
|
|
}
|
|
}
|
|
|
|
func newCodexModelsAPIKeyTestAccount(baseURL string) *Account {
|
|
credentials := map[string]any{"api_key": "sk-upstream"}
|
|
if baseURL != "" {
|
|
credentials["base_url"] = baseURL
|
|
}
|
|
return &Account{
|
|
ID: 2,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeAPIKey,
|
|
Credentials: credentials,
|
|
Concurrency: 3,
|
|
}
|
|
}
|
|
|
|
func newCodexModelsTestAccount() *Account {
|
|
return &Account{
|
|
ID: 1,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Credentials: map[string]any{
|
|
"access_token": "test-access-token",
|
|
"chatgpt_account_id": "acc-123",
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestPassthrough(t *testing.T) {
|
|
manifestBody := `{"models":[{"slug":"gpt-5.5","display_name":"GPT-5.5"}]}`
|
|
|
|
var gotAuth, gotAccountID, gotOriginator, gotClientVersion string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotAccountID = r.Header.Get("chatgpt-account-id")
|
|
gotOriginator = r.Header.Get("Originator")
|
|
gotClientVersion = r.URL.Query().Get("client_version")
|
|
w.Header().Set("ETag", `W/"abc123"`)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(manifestBody))
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{}
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", "")
|
|
if err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
|
|
if string(manifest.Body) != manifestBody {
|
|
t.Errorf("body not passed through verbatim: got %q", manifest.Body)
|
|
}
|
|
if manifest.ETag != `W/"abc123"` {
|
|
t.Errorf("etag not passed through: got %q", manifest.ETag)
|
|
}
|
|
if gotAuth != "Bearer test-access-token" {
|
|
t.Errorf("authorization header: got %q", gotAuth)
|
|
}
|
|
if gotAccountID != "acc-123" {
|
|
t.Errorf("chatgpt-account-id header: got %q", gotAccountID)
|
|
}
|
|
if gotOriginator != openai.CodexDefaultOriginator {
|
|
t.Errorf("originator header: got %q", gotOriginator)
|
|
}
|
|
if gotClientVersion != "0.137.0" {
|
|
t.Errorf("client_version query: got %q", gotClientVersion)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAgentIdentityUsesAssertionWithoutOAuthToken(t *testing.T) {
|
|
key, privateKey := newTestAgentIdentityKey(t)
|
|
account := &Account{
|
|
ID: 3,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Credentials: map[string]any{
|
|
"auth_mode": OpenAIAuthModeAgentIdentity,
|
|
"agent_runtime_id": key.runtimeID,
|
|
"agent_private_key": privateKey,
|
|
"task_id": key.taskID,
|
|
"chatgpt_account_id": "acc-agent",
|
|
},
|
|
}
|
|
|
|
var gotAuth, gotAccountID string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotAccountID = r.Header.Get("chatgpt-account-id")
|
|
_, _ = w.Write([]byte(`{"models":[]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{}
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
if err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
if string(manifest.Body) != `{"models":[]}` {
|
|
t.Fatalf("unexpected manifest body: %q", manifest.Body)
|
|
}
|
|
if !strings.HasPrefix(gotAuth, "AgentAssertion ") {
|
|
t.Fatalf("authorization scheme: got %q", strings.SplitN(gotAuth, " ", 2)[0])
|
|
}
|
|
if gotAccountID != "acc-agent" {
|
|
t.Fatalf("chatgpt-account-id header: got %q", gotAccountID)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAgentIdentityRecoversInvalidTaskOnce(t *testing.T) {
|
|
key, privateKey := newTestAgentIdentityKey(t)
|
|
account := &Account{
|
|
ID: 4,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Credentials: map[string]any{
|
|
"auth_mode": OpenAIAuthModeAgentIdentity,
|
|
"agent_runtime_id": key.runtimeID,
|
|
"agent_private_key": privateKey,
|
|
"task_id": "task-models-old",
|
|
"chatgpt_account_id": "acc-agent-recovery",
|
|
},
|
|
}
|
|
repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{account.ID: account}}
|
|
modelsCalls := 0
|
|
registerCalls := 0
|
|
var assertions []string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("content-type", "application/json")
|
|
if strings.Contains(r.URL.Path, "/task/register") {
|
|
registerCalls++
|
|
_, _ = w.Write([]byte(`{"task_id":"task-models-new"}`))
|
|
return
|
|
}
|
|
modelsCalls++
|
|
assertions = append(assertions, r.Header.Get("Authorization"))
|
|
if modelsCalls == 1 {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":{"code":"invalid_task_id"}}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"models":[]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
originalModelsURL := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
t.Cleanup(func() { chatgptCodexModelsURL = originalModelsURL })
|
|
originalAuthBase := openAIAgentIdentityAuthAPIBaseURL
|
|
openAIAgentIdentityAuthAPIBaseURL = server.URL
|
|
t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = originalAuthBase })
|
|
|
|
s := &OpenAIGatewayService{accountRepo: repo}
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, `{"models":[]}`, string(manifest.Body))
|
|
require.Equal(t, 2, modelsCalls)
|
|
require.Equal(t, 1, registerCalls)
|
|
require.Len(t, assertions, 2)
|
|
require.Equal(t, "task-models-old", decodeAgentAssertionTask(t, assertions[0]))
|
|
require.Equal(t, "task-models-new", decodeAgentAssertionTask(t, assertions[1]))
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAgentIdentityRedactsUpstreamErrors(t *testing.T) {
|
|
key, privateKey := newTestAgentIdentityKey(t)
|
|
account := &Account{
|
|
ID: 5,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Credentials: map[string]any{
|
|
"auth_mode": OpenAIAuthModeAgentIdentity,
|
|
"agent_runtime_id": key.runtimeID,
|
|
"agent_private_key": privateKey,
|
|
"task_id": key.taskID,
|
|
"chatgpt_account_id": "acc-agent-redaction",
|
|
},
|
|
}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = fmt.Fprintf(w, `{"error":"%s %s %s AgentAssertion leaked"}`, key.runtimeID, key.taskID, privateKey)
|
|
}))
|
|
defer server.Close()
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
t.Cleanup(func() { chatgptCodexModelsURL = original })
|
|
|
|
s := &OpenAIGatewayService{}
|
|
_, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.Error(t, err)
|
|
require.NotContains(t, err.Error(), key.runtimeID)
|
|
require.NotContains(t, err.Error(), key.taskID)
|
|
require.NotContains(t, err.Error(), privateKey)
|
|
require.NotContains(t, err.Error(), "AgentAssertion leaked")
|
|
require.Contains(t, err.Error(), "[redacted]")
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) {
|
|
var gotClientVersion string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotClientVersion = r.URL.Query().Get("client_version")
|
|
_, _ = w.Write([]byte(`{"models":[]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{}
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "", ""); err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
if gotClientVersion != CodexCanonicalClientVersion() {
|
|
t.Errorf("default client_version: got %q, want %q", gotClientVersion, CodexCanonicalClientVersion())
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestNotModified(t *testing.T) {
|
|
var gotIfNoneMatch string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotIfNoneMatch = r.Header.Get("If-None-Match")
|
|
w.Header().Set("ETag", `W/"abc123"`)
|
|
w.WriteHeader(http.StatusNotModified)
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{}
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", `W/"abc123"`)
|
|
if err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
if !manifest.NotModified {
|
|
t.Error("expected NotModified to be true")
|
|
}
|
|
if gotIfNoneMatch != `W/"abc123"` {
|
|
t.Errorf("if-none-match header: got %q", gotIfNoneMatch)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestUpstreamError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, `{"detail":"boom"}`, http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{}
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", ""); err == nil {
|
|
t.Fatal("expected error for upstream 500, got nil")
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestMissingToken(t *testing.T) {
|
|
account := newCodexModelsTestAccount()
|
|
delete(account.Credentials, "access_token")
|
|
|
|
s := &OpenAIGatewayService{}
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", ""); err == nil {
|
|
t.Fatal("expected error for missing access token, got nil")
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) {
|
|
manifestBody := `{"models":[{"slug":"gpt-5.6"}]}`
|
|
var gotRequest *http.Request
|
|
var gotProxyURL string
|
|
var gotAccountID int64
|
|
var gotConcurrency int
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
|
gotRequest = req
|
|
gotProxyURL = proxyURL
|
|
gotAccountID = accountID
|
|
gotConcurrency = accountConcurrency
|
|
header := make(http.Header)
|
|
header.Set("ETag", `W/"api-key-manifest"`)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: header,
|
|
Body: io.NopCloser(strings.NewReader(manifestBody)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
manifest, err := s.FetchCodexModelsManifest(
|
|
context.Background(),
|
|
newCodexModelsAPIKeyTestAccount("https://upstream.example/v1"),
|
|
"0.144.0",
|
|
"",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
|
|
if gotRequest == nil {
|
|
t.Fatal("expected request to custom API key upstream")
|
|
}
|
|
if gotRequest.Method != http.MethodGet {
|
|
t.Errorf("method: got %q", gotRequest.Method)
|
|
}
|
|
if gotRequest.URL.String() != "https://upstream.example/v1/models?client_version=0.144.0" {
|
|
t.Errorf("request URL: got %q", gotRequest.URL.String())
|
|
}
|
|
if gotRequest.Header.Get("Authorization") != "Bearer sk-upstream" {
|
|
t.Errorf("authorization header: got %q", gotRequest.Header.Get("Authorization"))
|
|
}
|
|
if gotRequest.Header.Get("Originator") != openai.CodexDefaultOriginator {
|
|
t.Errorf("originator header: got %q", gotRequest.Header.Get("Originator"))
|
|
}
|
|
if gotRequest.Header.Get("Version") != "0.144.0" {
|
|
t.Errorf("version header must match the client_version query param: got %q", gotRequest.Header.Get("Version"))
|
|
}
|
|
if gotRequest.Header.Get("User-Agent") != CodexCanonicalUserAgent() {
|
|
t.Errorf("user-agent header: got %q", gotRequest.Header.Get("User-Agent"))
|
|
}
|
|
if gotRequest.Header.Get("chatgpt-account-id") != "" {
|
|
t.Errorf("chatgpt-account-id must not be sent to API key upstream: got %q", gotRequest.Header.Get("chatgpt-account-id"))
|
|
}
|
|
if gotProxyURL != "" || gotAccountID != 2 || gotConcurrency != 3 {
|
|
t.Errorf("upstream routing metadata: proxy=%q account_id=%d concurrency=%d", gotProxyURL, gotAccountID, gotConcurrency)
|
|
}
|
|
if string(manifest.Body) != manifestBody {
|
|
t.Errorf("body not passed through verbatim: got %q", manifest.Body)
|
|
}
|
|
if manifest.ETag != `W/"api-key-manifest"` {
|
|
t.Errorf("etag not passed through: got %q", manifest.ETag)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testing.T) {
|
|
upstreamBody := `{"object":"list","data":[{"id":"gpt-5.6","object":"model"},{"id":" ","object":"model"},{"id":"gpt-5.6-codex","object":"model"}]}`
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
header := make(http.Header)
|
|
header.Set("ETag", `W/"openai-list"`)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: header,
|
|
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
manifest, err := s.FetchCodexModelsManifest(
|
|
context.Background(),
|
|
newCodexModelsAPIKeyTestAccount("https://upstream.example/v1"),
|
|
"0.144.0",
|
|
"",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"},{"slug":"gpt-5.6-codex"}]}`; got != want {
|
|
t.Errorf("converted body: got %q, want %q", got, want)
|
|
}
|
|
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
|
require.Equal(t, `W/"openai-list"`, manifest.upstreamETag)
|
|
}
|
|
|
|
func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
want string
|
|
}{
|
|
{
|
|
name: "affected models disable responses lite and preserve unknown fields",
|
|
body: `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true,"unknown_model":{"enabled":true}},{"slug":"gpt-5.6-terra","use_responses_lite":true},{"slug":"gpt-5.6-luna","use_responses_lite":true}],"unknown_top":{"version":1}}`,
|
|
want: `{"models":[{"slug":"gpt-5.6-sol","unknown_model":{"enabled":true},"use_responses_lite":false},{"slug":"gpt-5.6-terra","use_responses_lite":false},{"slug":"gpt-5.6-luna","use_responses_lite":false}],"unknown_top":{"version":1}}`,
|
|
},
|
|
{
|
|
name: "unaffected model unchanged",
|
|
body: ` {"models":[{"slug":"gpt-5.6-codex","use_responses_lite":true}]} `,
|
|
want: ` {"models":[{"slug":"gpt-5.6-codex","use_responses_lite":true}]} `,
|
|
},
|
|
{
|
|
name: "false missing and alternate entries unchanged",
|
|
body: `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false},{"slug":"gpt-5.6-terra"},null,"gpt-5.6-luna",{"slug":17,"use_responses_lite":true}]}`,
|
|
want: `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false},{"slug":"gpt-5.6-terra"},null,"gpt-5.6-luna",{"slug":17,"use_responses_lite":true}]}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := adjustAPIKeyCodexModelsManifest([]byte(tt.body))
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.want, string(got))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyDisablesResponsesLiteForAffectedModels(t *testing.T) {
|
|
const upstreamBody = `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true},{"slug":"gpt-5.6-codex","use_responses_lite":true}],"metadata":{"version":1}}`
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Etag": []string{`"upstream-strong"`}},
|
|
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsAPIKeyTestAccount("https://upstream.example"), "0.145.0", "")
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false},{"slug":"gpt-5.6-codex","use_responses_lite":true}],"metadata":{"version":1}}`, string(manifest.Body))
|
|
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
|
require.Equal(t, `"upstream-strong"`, manifest.upstreamETag)
|
|
|
|
notModified, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsAPIKeyTestAccount("https://upstream.example"), "0.145.0", manifest.ETag)
|
|
require.NoError(t, err)
|
|
require.True(t, notModified.NotModified)
|
|
require.Equal(t, manifest.ETag, notModified.ETag)
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestOAuthPreservesResponsesLite(t *testing.T) {
|
|
const manifestBody = ` {"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true}]} `
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(manifestBody))
|
|
}))
|
|
defer server.Close()
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{}
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.145.0", "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, manifestBody, string(manifest.Body))
|
|
}
|
|
|
|
func TestConvertOpenAIModelListToCodexManifest(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
want string
|
|
}{
|
|
{
|
|
name: "standard list",
|
|
body: `{"object":"list","data":[{"id":"m-1"},{"id":"m-2"}]}`,
|
|
want: `{"models":[{"slug":"m-1"},{"slug":"m-2"}]}`,
|
|
},
|
|
{
|
|
name: "codex manifest unchanged",
|
|
body: `{"models":[{"slug":"m-1"}]}`,
|
|
want: `{"models":[{"slug":"m-1"}]}`,
|
|
},
|
|
{
|
|
name: "empty data unchanged",
|
|
body: `{"object":"list","data":[]}`,
|
|
want: `{"object":"list","data":[]}`,
|
|
},
|
|
{
|
|
name: "data not an array unchanged",
|
|
body: `{"object":"list","data":{"id":"m-1"}}`,
|
|
want: `{"object":"list","data":{"id":"m-1"}}`,
|
|
},
|
|
{
|
|
name: "entries without usable IDs unchanged",
|
|
body: `{"object":"list","data":[{"id":""},{"object":"model"}]}`,
|
|
want: `{"object":"list","data":[{"id":""},{"object":"model"}]}`,
|
|
},
|
|
{
|
|
name: "invalid JSON unchanged",
|
|
body: `{"data":`,
|
|
want: `{"data":`,
|
|
},
|
|
{
|
|
name: "non-object unchanged",
|
|
body: `[]`,
|
|
want: `[]`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := string(convertOpenAIModelListToCodexManifest([]byte(tt.body))); got != tt.want {
|
|
t.Errorf("got %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestUsesConfiguredBodyLimit(t *testing.T) {
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6"}]}`)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
s.cfg.Gateway.ModelsListReadMaxBytes = 8
|
|
_, err := s.FetchCodexModelsManifest(
|
|
context.Background(),
|
|
newCodexModelsAPIKeyTestAccount("https://upstream.example"),
|
|
"0.144.0",
|
|
"",
|
|
)
|
|
require.Error(t, err)
|
|
require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err))
|
|
require.Contains(t, err.Error(), "response exceeds 8 bytes")
|
|
require.True(t, IsRetryableCodexModelsManifestError(err))
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAcceptsConfiguredLimitAboveLegacyBoundary(t *testing.T) {
|
|
manifestBody := `{"models":[{"slug":"gpt-5.6","display_name":"` + strings.Repeat("x", (8<<20)+1024) + `"}]}`
|
|
require.Greater(t, len(manifestBody), 8<<20)
|
|
require.Less(t, len(manifestBody), 16<<20)
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, manifestBody)
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{cfg: &config.Config{}}
|
|
s.cfg.Gateway.ModelsListReadMaxBytes = 16 << 20
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.144.0", "")
|
|
require.NoError(t, err)
|
|
require.True(t, bytes.Equal([]byte(manifestBody), manifest.Body), "manifest body must be returned intact")
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{name: "OpenAI models list", body: `{"object":"list","data":[]}`},
|
|
{name: "invalid JSON", body: `{"models":`},
|
|
{name: "non-object", body: `[]`},
|
|
{name: "null object", body: `null`},
|
|
{name: "missing models", body: `{}`},
|
|
{name: "models object", body: `{"models":{}}`},
|
|
{name: "models null", body: `{"models":null}`},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(tt.body)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
_, err := s.FetchCodexModelsManifest(
|
|
context.Background(),
|
|
newCodexModelsAPIKeyTestAccount("https://upstream.example"),
|
|
"0.144.0",
|
|
"",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected invalid manifest error, got nil")
|
|
}
|
|
if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST" {
|
|
t.Errorf("error reason: got %q", infraerrors.Reason(err))
|
|
}
|
|
if !IsRetryableCodexModelsManifestError(err) {
|
|
t.Error("invalid upstream manifest must be retryable")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyDoesNotCacheInvalidEnvelope(t *testing.T) {
|
|
var calls atomic.Int32
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
body := `{"object":"list","data":[]}`
|
|
if calls.Add(1) > 1 {
|
|
body = `{"models":[{"slug":"gpt-5.6"}]}`
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err == nil {
|
|
t.Fatal("expected invalid manifest error on first fetch")
|
|
}
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
if err != nil {
|
|
t.Fatalf("second fetch returned error: %v", err)
|
|
}
|
|
if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"}]}`; got != want {
|
|
t.Errorf("body: got %q, want %q", got, want)
|
|
}
|
|
if got := calls.Load(); got != 2 {
|
|
t.Errorf("upstream calls: got %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeySharedRefreshSurvivesCallerCancellation(t *testing.T) {
|
|
const manifestBody = `{"models":[{"slug":"gpt-5.6"}]}`
|
|
var calls atomic.Int32
|
|
var readStartedOnce sync.Once
|
|
readStarted := make(chan struct{})
|
|
deadlineRemaining := make(chan time.Duration, 1)
|
|
release := make(chan struct{})
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
calls.Add(1)
|
|
deadline, ok := req.Context().Deadline()
|
|
if !ok {
|
|
deadlineRemaining <- 0
|
|
} else {
|
|
deadlineRemaining <- time.Until(deadline)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Etag": []string{`W/"shared"`}},
|
|
Body: &codexModelsBlockingBody{
|
|
ctx: req.Context(),
|
|
readStarted: readStarted,
|
|
startedOnce: &readStartedOnce,
|
|
release: release,
|
|
body: strings.NewReader(manifestBody),
|
|
},
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
firstCtx, cancelFirst := context.WithCancel(context.Background())
|
|
firstErr := make(chan error, 1)
|
|
go func() {
|
|
_, err := s.FetchCodexModelsManifest(firstCtx, account, "0.144.0", "")
|
|
firstErr <- err
|
|
}()
|
|
|
|
select {
|
|
case <-readStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("upstream body read did not start")
|
|
}
|
|
remaining := <-deadlineRemaining
|
|
if remaining < 14*time.Second || remaining > codexModelsManifestRequestTimeout {
|
|
t.Errorf("detached refresh deadline: got %s, want approximately %s", remaining, codexModelsManifestRequestTimeout)
|
|
}
|
|
cancelFirst()
|
|
select {
|
|
case err := <-firstErr:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("first caller error: got %v, want context.Canceled", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("canceled caller did not return promptly")
|
|
}
|
|
|
|
secondResult := make(chan struct {
|
|
manifest *CodexModelsManifest
|
|
err error
|
|
}, 1)
|
|
go func() {
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
secondResult <- struct {
|
|
manifest *CodexModelsManifest
|
|
err error
|
|
}{manifest: manifest, err: err}
|
|
}()
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
if got := calls.Load(); got != 1 {
|
|
t.Errorf("upstream calls before shared refresh completed: got %d, want 1", got)
|
|
}
|
|
close(release)
|
|
select {
|
|
case result := <-secondResult:
|
|
if result.err != nil {
|
|
t.Fatalf("second caller returned error: %v", result.err)
|
|
}
|
|
if string(result.manifest.Body) != manifestBody {
|
|
t.Errorf("second caller body: got %q", result.manifest.Body)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("second caller did not receive shared refresh result")
|
|
}
|
|
if got := calls.Load(); got != 1 {
|
|
t.Errorf("total upstream calls: got %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyConcurrentRequestsShareRefresh(t *testing.T) {
|
|
const callers = 8
|
|
var calls atomic.Int32
|
|
started := make(chan struct{})
|
|
var startedOnce sync.Once
|
|
release := make(chan struct{})
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
calls.Add(1)
|
|
startedOnce.Do(func() { close(started) })
|
|
<-release
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
begin := make(chan struct{})
|
|
errs := make(chan error, callers)
|
|
for i := 0; i < callers; i++ {
|
|
go func() {
|
|
<-begin
|
|
_, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
errs <- err
|
|
}()
|
|
}
|
|
close(begin)
|
|
select {
|
|
case <-started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("upstream request did not start")
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
if got := calls.Load(); got != 1 {
|
|
t.Errorf("concurrent upstream calls: got %d, want 1", got)
|
|
}
|
|
close(release)
|
|
for i := 0; i < callers; i++ {
|
|
if err := <-errs; err != nil {
|
|
t.Errorf("caller %d returned error: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyFreshCacheHandlesETagLocally(t *testing.T) {
|
|
var calls atomic.Int32
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
calls.Add(1)
|
|
if got := req.Header.Get("If-None-Match"); got != "" {
|
|
t.Errorf("cache refresh must not inherit a caller's If-None-Match: got %q", got)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Etag": []string{`W/"cached"`}},
|
|
Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
|
|
t.Fatalf("initial fetch returned error: %v", err)
|
|
}
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", `W/"cached"`)
|
|
if err != nil {
|
|
t.Fatalf("cached fetch returned error: %v", err)
|
|
}
|
|
if !manifest.NotModified {
|
|
t.Fatal("matching cached ETag must return NotModified")
|
|
}
|
|
if got := calls.Load(); got != 1 {
|
|
t.Errorf("upstream calls: got %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyCacheKeyIsolatesRequestIdentity(t *testing.T) {
|
|
var calls atomic.Int32
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
calls.Add(1)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
|
|
}, nil
|
|
}}
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
|
|
base := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
fetch := func(account *Account, version string) {
|
|
t.Helper()
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), account, version, ""); err != nil {
|
|
t.Fatalf("fetch returned error: %v", err)
|
|
}
|
|
}
|
|
fetch(base, "0.144.0")
|
|
fetch(base, "0.144.0")
|
|
|
|
differentAccount := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
differentAccount.ID = 3
|
|
fetch(differentAccount, "0.144.0")
|
|
|
|
differentToken := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
differentToken.Credentials["api_key"] = "sk-other"
|
|
fetch(differentToken, "0.144.0")
|
|
|
|
differentUpstream := newCodexModelsAPIKeyTestAccount("https://other-upstream.example")
|
|
fetch(differentUpstream, "0.144.0")
|
|
fetch(base, "0.145.0")
|
|
|
|
differentHeaders := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
differentHeaders.Credentials[credKeyHeaderOverrideEnabled] = true
|
|
differentHeaders.Credentials[credKeyHeaderOverrides] = map[string]any{"x-tenant": "other"}
|
|
fetch(differentHeaders, "0.144.0")
|
|
|
|
proxyID := int64(9)
|
|
differentProxy := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
differentProxy.ProxyID = &proxyID
|
|
differentProxy.Proxy = &Proxy{Protocol: "http", Host: "127.0.0.1", Port: 8080}
|
|
fetch(differentProxy, "0.144.0")
|
|
fetch(differentProxy, "0.144.0")
|
|
|
|
if got := calls.Load(); got != 7 {
|
|
t.Errorf("isolated upstream calls: got %d, want 7", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing.T) {
|
|
var calls atomic.Int32
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
calls.Add(1)
|
|
body := `{"models":[]}`
|
|
if strings.Contains(req.URL.Host, "large") {
|
|
body = `{"models":[],"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}`
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
}, nil
|
|
}}
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
fetch := func(account *Account) {
|
|
t.Helper()
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
|
|
t.Fatalf("fetch returned error: %v", err)
|
|
}
|
|
}
|
|
|
|
small := newCodexModelsAPIKeyTestAccount("https://small.example")
|
|
fetch(small)
|
|
fetch(small)
|
|
large := newCodexModelsAPIKeyTestAccount("https://large.example")
|
|
large.ID = 3
|
|
fetch(large)
|
|
fetch(large)
|
|
if got := calls.Load(); got != 3 {
|
|
t.Fatalf("body-size bounded cache calls: got %d, want 3", got)
|
|
}
|
|
|
|
for i := int64(10); i < 75; i++ {
|
|
account := newCodexModelsAPIKeyTestAccount("https://bounded.example")
|
|
account.ID = i
|
|
fetch(account)
|
|
}
|
|
last := newCodexModelsAPIKeyTestAccount("https://bounded.example")
|
|
last.ID = 74
|
|
fetch(last)
|
|
if got := calls.Load(); got != 68 {
|
|
t.Fatalf("most recent cache entry was not retained: calls=%d, want 68", got)
|
|
}
|
|
first := newCodexModelsAPIKeyTestAccount("https://bounded.example")
|
|
first.ID = 10
|
|
fetch(first)
|
|
if got := calls.Load(); got != 69 {
|
|
t.Errorf("oldest cache entry was not evicted: calls=%d, want 69", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyServesStaleWhileRefreshing(t *testing.T) {
|
|
var calls atomic.Int32
|
|
refreshStarted := make(chan struct{})
|
|
releaseRefresh := make(chan struct{})
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
call := calls.Add(1)
|
|
body := `{"models":[{"slug":"old"}]}`
|
|
if call > 1 {
|
|
if call == 2 {
|
|
close(refreshStarted)
|
|
}
|
|
<-releaseRefresh
|
|
body = `{"models":[{"slug":"new"}]}`
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
}, nil
|
|
}}
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
|
|
t.Fatalf("initial fetch returned error: %v", err)
|
|
}
|
|
|
|
s.codexModelsManifestCache.mu.Lock()
|
|
for key, entry := range s.codexModelsManifestCache.entries {
|
|
entry.expiresAt = time.Now().Add(-time.Second)
|
|
s.codexModelsManifestCache.entries[key] = entry
|
|
}
|
|
s.codexModelsManifestCache.mu.Unlock()
|
|
|
|
resultCh := make(chan struct {
|
|
manifest *CodexModelsManifest
|
|
err error
|
|
}, 1)
|
|
go func() {
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
resultCh <- struct {
|
|
manifest *CodexModelsManifest
|
|
err error
|
|
}{manifest: manifest, err: err}
|
|
}()
|
|
select {
|
|
case <-refreshStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("background refresh did not start")
|
|
}
|
|
|
|
var staleResult struct {
|
|
manifest *CodexModelsManifest
|
|
err error
|
|
}
|
|
select {
|
|
case staleResult = <-resultCh:
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Error("stale manifest was not returned while refresh was blocked")
|
|
close(releaseRefresh)
|
|
staleResult = <-resultCh
|
|
}
|
|
if staleResult.err != nil {
|
|
t.Fatalf("stale fetch returned error: %v", staleResult.err)
|
|
}
|
|
if got := string(staleResult.manifest.Body); got != `{"models":[{"slug":"old"}]}` {
|
|
t.Errorf("stale body: got %q", got)
|
|
}
|
|
if got := calls.Load(); got != 2 {
|
|
t.Errorf("upstream calls during stale refresh: got %d, want 2", got)
|
|
}
|
|
|
|
select {
|
|
case <-releaseRefresh:
|
|
default:
|
|
close(releaseRefresh)
|
|
}
|
|
deadline := time.Now().Add(time.Second)
|
|
for {
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
if err == nil && string(manifest.Body) == `{"models":[{"slug":"new"}]}` {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("refreshed manifest was not cached: manifest=%v err=%v", manifest, err)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if got := calls.Load(); got != 2 {
|
|
t.Errorf("stale refresh was not deduplicated: calls=%d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) {
|
|
var calls atomic.Int32
|
|
refreshDone := make(chan struct{})
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
call := calls.Add(1)
|
|
if call == 1 {
|
|
header := make(http.Header)
|
|
header.Set("ETag", `"upstream-cached"`)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: header,
|
|
Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true}]}`)),
|
|
}, nil
|
|
}
|
|
if got := req.Header.Get("If-None-Match"); got != `"upstream-cached"` {
|
|
t.Errorf("background revalidation If-None-Match: got %q", got)
|
|
}
|
|
close(refreshDone)
|
|
header := make(http.Header)
|
|
header.Set("ETag", `"upstream-cached"`)
|
|
return &http.Response{StatusCode: http.StatusNotModified, Header: header, Body: http.NoBody}, nil
|
|
}}
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
|
|
t.Fatalf("initial fetch returned error: %v", err)
|
|
}
|
|
s.codexModelsManifestCache.mu.Lock()
|
|
for key, entry := range s.codexModelsManifestCache.entries {
|
|
entry.expiresAt = time.Now().Add(-time.Second)
|
|
s.codexModelsManifestCache.entries[key] = entry
|
|
}
|
|
s.codexModelsManifestCache.mu.Unlock()
|
|
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
if err != nil {
|
|
t.Fatalf("stale fetch returned error: %v", err)
|
|
}
|
|
if got := string(manifest.Body); got != `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false}]}` {
|
|
t.Fatalf("stale body: got %q", got)
|
|
}
|
|
select {
|
|
case <-refreshDone:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("ETag revalidation did not complete")
|
|
}
|
|
|
|
deadline := time.Now().Add(time.Second)
|
|
for {
|
|
s.codexModelsManifestCache.mu.Lock()
|
|
fresh := false
|
|
for _, entry := range s.codexModelsManifestCache.entries {
|
|
fresh = time.Now().Before(entry.expiresAt)
|
|
}
|
|
s.codexModelsManifestCache.mu.Unlock()
|
|
if fresh {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("304 revalidation did not renew the cached manifest")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
manifest, err = s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
if err != nil || string(manifest.Body) != `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false}]}` {
|
|
t.Fatalf("renewed cached manifest: body=%q err=%v", manifest.Body, err)
|
|
}
|
|
if got := calls.Load(); got != 2 {
|
|
t.Errorf("upstream calls: got %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyColdCacheHandlesNotModifiedLocally(t *testing.T) {
|
|
var gotIfNoneMatch string
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
gotIfNoneMatch = req.Header.Get("If-None-Match")
|
|
header := make(http.Header)
|
|
header.Set("ETag", `W/"api-key-manifest"`)
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: header,
|
|
Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
manifest, err := s.FetchCodexModelsManifest(
|
|
context.Background(),
|
|
newCodexModelsAPIKeyTestAccount("https://upstream.example"),
|
|
"0.144.0",
|
|
`W/"api-key-manifest"`,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
if !manifest.NotModified {
|
|
t.Error("expected NotModified to be true")
|
|
}
|
|
if manifest.ETag != `W/"api-key-manifest"` {
|
|
t.Errorf("etag not passed through: got %q", manifest.ETag)
|
|
}
|
|
if gotIfNoneMatch != "" {
|
|
t.Errorf("cold shared refresh must not inherit caller if-none-match: got %q", gotIfNoneMatch)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyDoesNotCacheUnexpectedColdNotModified(t *testing.T) {
|
|
var calls atomic.Int32
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
calls.Add(1)
|
|
if got := req.Header.Get("If-None-Match"); got != "" {
|
|
t.Errorf("cold shared refresh If-None-Match: got %q", got)
|
|
}
|
|
header := make(http.Header)
|
|
header.Set("ETag", `W/"unexpected"`)
|
|
return &http.Response{StatusCode: http.StatusNotModified, Header: header, Body: http.NoBody}, nil
|
|
}}
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
for i := 0; i < 2; i++ {
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
if err != nil {
|
|
t.Fatalf("fetch %d returned error: %v", i, err)
|
|
}
|
|
if !manifest.NotModified {
|
|
t.Fatalf("fetch %d: expected upstream NotModified response", i)
|
|
}
|
|
}
|
|
if got := calls.Load(); got != 2 {
|
|
t.Errorf("unexpected cold 304 was cached: upstream calls=%d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyPreservesBaseURLQuery(t *testing.T) {
|
|
var gotURL string
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
gotURL = req.URL.String()
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
_, err := s.FetchCodexModelsManifest(
|
|
context.Background(),
|
|
newCodexModelsAPIKeyTestAccount("https://upstream.example/v1?tenant=acme"),
|
|
"0.144.0",
|
|
"",
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
if gotURL != "https://upstream.example/v1/models?client_version=0.144.0&tenant=acme" {
|
|
t.Errorf("request URL: got %q", gotURL)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyRejectsBaseURLFragment(t *testing.T) {
|
|
called := false
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
called = true
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
_, err := s.FetchCodexModelsManifest(
|
|
context.Background(),
|
|
newCodexModelsAPIKeyTestAccount("https://upstream.example/v1#models"),
|
|
"0.144.0",
|
|
"",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected invalid upstream base URL error, got nil")
|
|
}
|
|
if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_INVALID" {
|
|
t.Errorf("error reason: got %q", infraerrors.Reason(err))
|
|
}
|
|
if called {
|
|
t.Fatal("fragment-bearing base URL must be rejected before the upstream request")
|
|
}
|
|
}
|
|
|
|
// codexModelsAccountStateRepo records account state transitions triggered by
|
|
// manifest upstream errors (#4544).
|
|
type codexModelsAccountStateRepo struct {
|
|
AccountRepository
|
|
mu sync.Mutex
|
|
setErrorCalls int
|
|
lastErrorMsg string
|
|
setTempUnschedCalls int
|
|
lastTempReason string
|
|
}
|
|
|
|
func (r *codexModelsAccountStateRepo) SetError(_ context.Context, _ int64, errorMsg string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.setErrorCalls++
|
|
r.lastErrorMsg = errorMsg
|
|
return nil
|
|
}
|
|
|
|
func (r *codexModelsAccountStateRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.setTempUnschedCalls++
|
|
r.lastTempReason = reason
|
|
return nil
|
|
}
|
|
|
|
func newCodexModels401TestService(repo AccountRepository) *OpenAIGatewayService {
|
|
rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
|
s := &OpenAIGatewayService{rateLimitService: rateLimitService}
|
|
rateLimitService.SetAccountRuntimeBlocker(s)
|
|
return s
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestOAuth401MarksAccountUnschedulable(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"detail":{"message":"invalid token"}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
repo := &codexModelsAccountStateRepo{}
|
|
s := newCodexModels401TestService(repo)
|
|
account := newCodexModelsTestAccount()
|
|
account.Credentials["refresh_token"] = "test-refresh-token"
|
|
|
|
_, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.Error(t, err)
|
|
require.True(t, IsRetryableCodexModelsManifestError(err), "manifest 401 should allow account failover")
|
|
require.Equal(t, 1, repo.setTempUnschedCalls, "OAuth 401 should temp-unschedule the account")
|
|
require.Equal(t, 0, repo.setErrorCalls)
|
|
require.True(t, s.isOpenAIAccountRuntimeBlocked(account), "account should be runtime-blocked after manifest 401")
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestOAuth401TokenRevokedDisablesAccount(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":{"code":"token_revoked","message":"token has been revoked"}}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
repo := &codexModelsAccountStateRepo{}
|
|
s := newCodexModels401TestService(repo)
|
|
account := newCodexModelsTestAccount()
|
|
account.Credentials["refresh_token"] = "test-refresh-token"
|
|
|
|
_, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.Error(t, err)
|
|
require.True(t, IsRetryableCodexModelsManifestError(err))
|
|
require.Equal(t, 1, repo.setErrorCalls, "revoked token should permanently disable the account")
|
|
require.Contains(t, repo.lastErrorMsg, "Token revoked")
|
|
require.Equal(t, 0, repo.setTempUnschedCalls)
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAgentIdentity401DoesNotDisableAccount(t *testing.T) {
|
|
key, privateKey := newTestAgentIdentityKey(t)
|
|
account := &Account{
|
|
ID: 6,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Credentials: map[string]any{
|
|
"auth_mode": OpenAIAuthModeAgentIdentity,
|
|
"agent_runtime_id": key.runtimeID,
|
|
"agent_private_key": privateKey,
|
|
"task_id": key.taskID,
|
|
"chatgpt_account_id": "acc-agent-401",
|
|
},
|
|
}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"detail":"some non-task 401"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
repo := &codexModelsAccountStateRepo{}
|
|
s := newCodexModels401TestService(repo)
|
|
|
|
_, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.Error(t, err)
|
|
require.Equal(t, 0, repo.setErrorCalls, "agent identity 401s must not disable the account")
|
|
require.Equal(t, 0, repo.setTempUnschedCalls)
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKey401KeepsNoFailoverAndNoDisable(t *testing.T) {
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusUnauthorized,
|
|
Status: "401 Unauthorized",
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"error":"invalid api key"}`)),
|
|
}, nil
|
|
}}
|
|
|
|
repo := &codexModelsAccountStateRepo{}
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
s.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
|
|
|
|
_, err := s.FetchCodexModelsManifest(
|
|
context.Background(),
|
|
newCodexModelsAPIKeyTestAccount("https://upstream.example"),
|
|
"0.144.0",
|
|
"",
|
|
)
|
|
require.Error(t, err)
|
|
require.False(t, IsRetryableCodexModelsManifestError(err), "custom upstream manifest 401 keeps the no-failover behavior")
|
|
require.Equal(t, 0, repo.setErrorCalls, "custom upstream manifest 401 must not disable the account")
|
|
require.Equal(t, 0, repo.setTempUnschedCalls)
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyUpstreamError(t *testing.T) {
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusTooManyRequests,
|
|
Status: "429 Too Many Requests",
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"error":"rate limited"}`)),
|
|
}, nil
|
|
}}
|
|
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
_, err := s.FetchCodexModelsManifest(
|
|
context.Background(),
|
|
newCodexModelsAPIKeyTestAccount("https://upstream.example"),
|
|
"0.144.0",
|
|
"",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error for upstream 429, got nil")
|
|
}
|
|
if infraerrors.Code(err) != http.StatusBadGateway {
|
|
t.Errorf("error status: got %d, want %d", infraerrors.Code(err), http.StatusBadGateway)
|
|
}
|
|
if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_UPSTREAM_FAILED" {
|
|
t.Errorf("error reason: got %q", infraerrors.Reason(err))
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestAPIKeyRejectsOfficialOpenAIBaseURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
baseURL string
|
|
}{
|
|
{name: "missing base URL"},
|
|
{name: "official host", baseURL: "https://api.openai.com"},
|
|
{name: "official versioned URL", baseURL: "https://API.OPENAI.COM:443/v1/"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
t.Fatal("official OpenAI API key must not be used as a Codex manifest upstream")
|
|
return nil, nil
|
|
}})
|
|
|
|
_, err := s.FetchCodexModelsManifest(
|
|
context.Background(),
|
|
newCodexModelsAPIKeyTestAccount(tt.baseURL),
|
|
"0.144.0",
|
|
"",
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected unsupported API key upstream error, got nil")
|
|
}
|
|
if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_UNSUPPORTED" {
|
|
t.Errorf("error reason: got %q", infraerrors.Reason(err))
|
|
}
|
|
})
|
|
}
|
|
}
|