mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-10 09:01:22 +00:00
Close the existing catalog identity test before the new capability test and assert that an explicit null capability remains present. Update the quota snapshot fixture to the pointer parameter already used by the current base. Verified six full backend unit packages, scoped lint and CGO-disabled build.
3783 lines
134 KiB
Go
3783 lines
134 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"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 codexModelsVisibilityAccountRepo struct {
|
|
AccountRepository
|
|
byGroup map[int64][]Account
|
|
}
|
|
|
|
func (r codexModelsVisibilityAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) {
|
|
accounts := r.byGroup[groupID]
|
|
return append([]Account(nil), accounts...), nil
|
|
}
|
|
|
|
func (r codexModelsVisibilityAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) {
|
|
if groupID == nil {
|
|
return nil, nil
|
|
}
|
|
accounts := r.byGroup[*groupID]
|
|
return append([]Account(nil), accounts...), nil
|
|
}
|
|
|
|
type countingCodexModelsAccountRepo struct {
|
|
AccountRepository
|
|
accounts []Account
|
|
err error
|
|
availabilityErr error
|
|
groupID *int64
|
|
platforms []string
|
|
includeGrouped bool
|
|
calls atomic.Int32
|
|
}
|
|
|
|
func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) {
|
|
r.calls.Add(1)
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
return append([]Account(nil), r.accounts...), nil
|
|
}
|
|
|
|
func (r *countingCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, platforms []string, includeGrouped bool) ([]Account, error) {
|
|
if groupID != nil {
|
|
value := *groupID
|
|
r.groupID = &value
|
|
}
|
|
r.platforms = append([]string(nil), platforms...)
|
|
r.includeGrouped = includeGrouped
|
|
if r.availabilityErr != nil {
|
|
return nil, r.availabilityErr
|
|
}
|
|
return append([]Account(nil), r.accounts...), nil
|
|
}
|
|
|
|
type splitCodexModelsAccountRepo struct {
|
|
AccountRepository
|
|
schedulable map[int64][]Account
|
|
catalog map[int64][]Account
|
|
all map[int64][]Account
|
|
}
|
|
|
|
func (r splitCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) {
|
|
return append([]Account(nil), r.schedulable[groupID]...), nil
|
|
}
|
|
|
|
func (r splitCodexModelsAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) {
|
|
accounts := r.all[groupID]
|
|
if accounts == nil {
|
|
accounts = r.catalog[groupID]
|
|
}
|
|
return append([]Account(nil), accounts...), nil
|
|
}
|
|
|
|
func (r splitCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) {
|
|
if groupID == nil {
|
|
return nil, nil
|
|
}
|
|
return append([]Account(nil), r.catalog[*groupID]...), nil
|
|
}
|
|
|
|
func newCodexCatalogMappedAccount(
|
|
id int64,
|
|
target string,
|
|
displayName string,
|
|
levels []string,
|
|
modalities []string,
|
|
contextWindow int64,
|
|
schedulable bool,
|
|
extraMapping map[string]any,
|
|
) Account {
|
|
reasoning := true
|
|
mapping := map[string]any{"my-coder": target}
|
|
for key, value := range extraMapping {
|
|
mapping[key] = value
|
|
}
|
|
account := Account{
|
|
ID: id,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeAPIKey,
|
|
Status: StatusActive,
|
|
Schedulable: schedulable,
|
|
Credentials: map[string]any{
|
|
"base_url": fmt.Sprintf("https://provider-%d.example/v1", id),
|
|
"model_mapping": mapping,
|
|
},
|
|
}
|
|
models := map[string]UpstreamModelMetadata{
|
|
target: {
|
|
ID: target,
|
|
DisplayName: displayName,
|
|
Description: displayName + " upstream",
|
|
Reasoning: &reasoning,
|
|
SupportedReasoningLevels: levels,
|
|
InputModalities: modalities,
|
|
ContextWindow: contextWindow,
|
|
},
|
|
}
|
|
for _, value := range extraMapping {
|
|
exclusive, _ := value.(string)
|
|
if exclusive == "" || exclusive == target {
|
|
continue
|
|
}
|
|
models[exclusive] = UpstreamModelMetadata{
|
|
ID: exclusive,
|
|
DisplayName: "Exclusive Model",
|
|
Description: "Only mapped on the unschedulable account",
|
|
Reasoning: &reasoning,
|
|
SupportedReasoningLevels: []string{"high"},
|
|
InputModalities: []string{"text", "image"},
|
|
ContextWindow: 1_000_000,
|
|
}
|
|
}
|
|
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: models})
|
|
return account
|
|
}
|
|
|
|
func TestFilterCodexModelIDsForGroupOmitsWildcardKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := FilterCodexModelIDsForGroup(
|
|
[]string{"deepseek-v4-pro", "foo-*", " bar-* ", "gpt-5.5"},
|
|
&Group{Platform: PlatformDeepseek},
|
|
)
|
|
require.Equal(t, []string{"deepseek-v4-pro", "gpt-5.5"}, got)
|
|
}
|
|
|
|
func decodeCodexManifestModels(t *testing.T, body []byte) []map[string]any {
|
|
t.Helper()
|
|
|
|
var envelope struct {
|
|
Models []map[string]any `json:"models"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(body, &envelope))
|
|
return envelope.Models
|
|
}
|
|
|
|
func codexManifestModelSlugs(t *testing.T, body []byte) []string {
|
|
t.Helper()
|
|
|
|
models := decodeCodexManifestModels(t, body)
|
|
slugs := make([]string, 0, len(models))
|
|
for _, model := range models {
|
|
slug, ok := model["slug"].(string)
|
|
require.True(t, ok)
|
|
slugs = append(slugs, slug)
|
|
}
|
|
return slugs
|
|
}
|
|
|
|
func requireCompleteConfiguredCodexModel(t *testing.T, model map[string]any, slug string) {
|
|
t.Helper()
|
|
|
|
require.Equal(t, slug, model["slug"])
|
|
require.NotEmpty(t, model["display_name"])
|
|
require.NotEmpty(t, model["description"])
|
|
require.Equal(t, "unified_exec", model["shell_type"])
|
|
require.Equal(t, "list", model["visibility"])
|
|
require.Equal(t, true, model["supported_in_api"])
|
|
require.NotNil(t, model["priority"])
|
|
require.Equal(t, []any{}, model["additional_speed_tiers"])
|
|
require.IsType(t, []any{}, model["service_tiers"])
|
|
require.Contains(t, model, "default_service_tier")
|
|
require.Contains(t, model, "availability_nux")
|
|
require.Contains(t, model, "upgrade")
|
|
require.Contains(t, model, "default_verbosity")
|
|
require.Contains(t, model, "apply_patch_tool_type")
|
|
require.Contains(t, model, "auto_compact_token_limit")
|
|
require.Contains(t, model, "comp_hash")
|
|
require.Contains(t, model, "auto_review_model_override")
|
|
require.Contains(t, model, "model_specialty")
|
|
require.Contains(t, model, "tool_mode")
|
|
require.Contains(t, model, "multi_agent_version")
|
|
require.Equal(t, true, model["supports_reasoning_summary_parameter"])
|
|
require.Contains(t, model, "include_skills_usage_instructions")
|
|
require.Contains(t, model, "include_plugin_usage_instructions")
|
|
require.Contains(t, model, "include_apps_usage_instructions")
|
|
require.Contains(t, model, "supports_image_detail_original")
|
|
require.Contains(t, model, "node_repl_auto_review_required")
|
|
require.Contains(t, model, "node_repl_disabled")
|
|
require.Contains(t, model, "truncation_policy")
|
|
require.Contains(t, model, "supports_parallel_tool_calls")
|
|
require.Contains(t, model, "experimental_supported_tools")
|
|
modelMessages, ok := model["model_messages"].(map[string]any)
|
|
require.True(t, ok)
|
|
require.NotEmpty(t, modelMessages["instructions_template"])
|
|
for _, key := range []string{
|
|
"instructions_variables",
|
|
"approvals",
|
|
"collaboration_modes",
|
|
"auto_review",
|
|
"permissions",
|
|
"multi_agent",
|
|
"token_budget",
|
|
"guardian_v2",
|
|
} {
|
|
require.Contains(t, modelMessages, key)
|
|
}
|
|
}
|
|
|
|
func effortsFromManifestModel(t *testing.T, model map[string]any) []string {
|
|
t.Helper()
|
|
|
|
levels, ok := model["supported_reasoning_levels"].([]any)
|
|
require.True(t, ok)
|
|
efforts := make([]string, 0, len(levels))
|
|
for _, rawLevel := range levels {
|
|
level, ok := rawLevel.(map[string]any)
|
|
require.True(t, ok)
|
|
effort, ok := level["effort"].(string)
|
|
require.True(t, ok)
|
|
efforts = append(efforts, effort)
|
|
}
|
|
return efforts
|
|
}
|
|
|
|
func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
deepSeek := newConfiguredCodexModelDescriptor("deepseek-v4-pro")
|
|
require.Equal(t, "DeepSeek V4 Pro", deepSeek.DisplayName)
|
|
require.Equal(t, int64(1_000_000), deepSeek.ContextWindow)
|
|
require.Equal(t, int64(1_000_000), deepSeek.MaxContextWindow)
|
|
require.NotNil(t, deepSeek.DefaultReasoningLevel)
|
|
require.Equal(t, "high", *deepSeek.DefaultReasoningLevel)
|
|
require.Equal(t, []configuredCodexReasoningLevel{
|
|
{Effort: "low", Description: "Fast responses with lighter reasoning"},
|
|
{Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"},
|
|
{Effort: "max", Description: "Maximum reasoning depth for complex tasks"},
|
|
}, deepSeek.SupportedReasoningLevels)
|
|
require.True(t, deepSeek.SupportsParallelToolCalls)
|
|
require.Equal(t, []string{"text"}, deepSeek.InputModalities)
|
|
|
|
grok := newConfiguredCodexModelDescriptor("grok-4.6")
|
|
require.Equal(t, "Grok 4.6", grok.DisplayName)
|
|
require.Equal(t, int64(500_000), grok.ContextWindow)
|
|
require.Equal(t, int64(500_000), grok.MaxContextWindow)
|
|
require.NotNil(t, grok.DefaultReasoningLevel)
|
|
require.Equal(t, "high", *grok.DefaultReasoningLevel)
|
|
require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grok.SupportedReasoningLevels))
|
|
require.True(t, grok.SupportsParallelToolCalls)
|
|
require.Equal(t, []string{"text"}, grok.InputModalities)
|
|
require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
|
|
require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"})
|
|
|
|
grokAlias := newConfiguredCodexModelDescriptor("xai/grok-4.6-latest")
|
|
require.Equal(t, "Grok 4.6", grokAlias.DisplayName)
|
|
require.NotNil(t, grokAlias.DefaultReasoningLevel)
|
|
require.Equal(t, "high", *grokAlias.DefaultReasoningLevel)
|
|
require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grokAlias.SupportedReasoningLevels))
|
|
|
|
grok45 := newConfiguredCodexModelDescriptor("grok-4.5")
|
|
require.Equal(t, []string{"low", "medium", "high"}, effortsFromConfiguredCodexLevels(grok45.SupportedReasoningLevels))
|
|
|
|
grokNonReasoning := newConfiguredCodexModelDescriptor("grok-4.20-0309-non-reasoning")
|
|
require.Equal(t, "Grok 4.20 Non Reasoning", grokNonReasoning.DisplayName)
|
|
require.NotNil(t, grokNonReasoning.DefaultReasoningLevel)
|
|
require.Equal(t, "none", *grokNonReasoning.DefaultReasoningLevel)
|
|
require.Equal(t, []configuredCodexReasoningLevel{
|
|
{Effort: "none", Description: configuredCodexReasoningLevelDescription("none")},
|
|
}, grokNonReasoning.SupportedReasoningLevels)
|
|
|
|
claude := newConfiguredCodexModelDescriptor("claude-opus-4-6")
|
|
require.Equal(t, "Claude Opus 4.6", claude.DisplayName)
|
|
require.NotNil(t, claude.DefaultReasoningLevel)
|
|
require.Equal(t, "medium", *claude.DefaultReasoningLevel)
|
|
require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(claude.SupportedReasoningLevels))
|
|
require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "xhigh"})
|
|
require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
|
|
require.True(t, claude.SupportsParallelToolCalls)
|
|
|
|
claudeOpus5 := newConfiguredCodexModelDescriptor("claude-opus-5")
|
|
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeOpus5.SupportedReasoningLevels))
|
|
|
|
providerQualifiedClaude := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-4-6")
|
|
require.Equal(t, "Claude Sonnet 4.6", providerQualifiedClaude.DisplayName)
|
|
require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(providerQualifiedClaude.SupportedReasoningLevels))
|
|
|
|
claudeHaiku := newConfiguredCodexModelDescriptor("claude-haiku-4-5-20251001")
|
|
require.Equal(t, "Claude Haiku 4.5", claudeHaiku.DisplayName)
|
|
require.NotNil(t, claudeHaiku.DefaultReasoningLevel)
|
|
require.Equal(t, "none", *claudeHaiku.DefaultReasoningLevel)
|
|
require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(claudeHaiku.SupportedReasoningLevels))
|
|
|
|
gpt56 := newConfiguredCodexModelDescriptor("gpt-5.6-sol")
|
|
require.Equal(t, "GPT-5.6 Sol", gpt56.DisplayName)
|
|
require.Equal(t, "OpenAI GPT coding model routed through Sub2API.", gpt56.Description)
|
|
require.NotNil(t, gpt56.DefaultReasoningLevel)
|
|
require.Equal(t, "low", *gpt56.DefaultReasoningLevel)
|
|
require.Equal(t, configuredCodexGPTReasoningLevels("gpt-5.6-sol"), gpt56.SupportedReasoningLevels)
|
|
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt56.SupportedReasoningLevels))
|
|
require.True(t, gpt56.SupportsParallelToolCalls)
|
|
require.True(t, gpt56.SupportVerbosity)
|
|
require.Equal(t, []string{"text"}, gpt56.InputModalities)
|
|
require.Equal(t, int64(872_000), gpt56.MaxContextWindow)
|
|
require.Equal(t, configuredCodexTruncationPolicy{Mode: "tokens", Limit: 10_000}, gpt56.TruncationPolicy)
|
|
require.NotNil(t, gpt56.DefaultVerbosity)
|
|
require.Equal(t, "low", *gpt56.DefaultVerbosity)
|
|
require.True(t, gpt56.SupportsReasoningSummaryParameter)
|
|
require.Equal(t, "none", gpt56.DefaultReasoningSummary)
|
|
|
|
gpt56Luna := newConfiguredCodexModelDescriptor("gpt-5.6-luna")
|
|
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt56Luna.SupportedReasoningLevels))
|
|
require.Equal(t, "medium", *gpt56Luna.DefaultReasoningLevel)
|
|
|
|
gpt6Astra := newConfiguredCodexModelDescriptor("gpt-6-astra")
|
|
require.Equal(t, "GPT-6 Astra", gpt6Astra.DisplayName)
|
|
require.True(t, strings.HasPrefix(strings.TrimSpace(gpt6Astra.ModelMessages.InstructionsTemplate), "You are Codex, an agent based on GPT-6."))
|
|
require.NotNil(t, gpt6Astra.DefaultReasoningLevel)
|
|
require.Equal(t, "medium", *gpt6Astra.DefaultReasoningLevel)
|
|
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels))
|
|
require.NotNil(t, gpt6Astra.MultiAgentReasoningEffort)
|
|
require.Equal(t, "xhigh", *gpt6Astra.MultiAgentReasoningEffort)
|
|
require.Equal(t, "v2", gpt6Astra.MultiAgentVersion)
|
|
require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
|
|
require.True(t, configuredCodexSupportsPriorityServiceTier("gpt-6-astra"))
|
|
require.Equal(t, []configuredCodexServiceTier{{
|
|
ID: "priority",
|
|
Name: "Fast",
|
|
Description: "Priority processing for lower latency.",
|
|
}}, gpt6Astra.ServiceTiers)
|
|
require.True(t, isOpenAICodexImageInputModel("gpt-6-astra"))
|
|
require.True(t, isOpenAICodexReasoningGPTModel("openai/gpt-6-astra"))
|
|
require.True(t, isOpenAIGPT6AstraModel("gpt-6-astra-2026-09-01"))
|
|
require.False(t, isOpenAIGPT6AstraModel("gpt-6-other"))
|
|
require.Equal(t, int64(1_050_000), gpt6Astra.ContextWindow)
|
|
require.Equal(t, int64(1_050_000), gpt6Astra.MaxContextWindow)
|
|
gpt6 := newConfiguredCodexModelDescriptor("gpt-6")
|
|
require.Equal(t, "GPT-6 (Astra)", gpt6.DisplayName)
|
|
require.True(t, strings.HasPrefix(strings.TrimSpace(gpt6.ModelMessages.InstructionsTemplate), "You are Codex, an agent based on GPT-6."))
|
|
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6.SupportedReasoningLevels))
|
|
require.NotNil(t, gpt6.MultiAgentReasoningEffort)
|
|
require.Equal(t, "xhigh", *gpt6.MultiAgentReasoningEffort)
|
|
require.Equal(t, int64(1_050_000), gpt6.ContextWindow)
|
|
|
|
gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5")
|
|
require.Equal(t, "GPT-5.5", gpt55.DisplayName)
|
|
require.NotNil(t, gpt55.DefaultReasoningLevel)
|
|
require.Equal(t, "medium", *gpt55.DefaultReasoningLevel)
|
|
require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(gpt55.SupportedReasoningLevels))
|
|
require.NotContains(t, gpt55.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"})
|
|
require.NotNil(t, gpt55.DefaultVerbosity)
|
|
require.Equal(t, "low", *gpt55.DefaultVerbosity)
|
|
|
|
gpt4o := newConfiguredCodexModelDescriptor("gpt-4o")
|
|
require.Equal(t, "gpt-4o", gpt4o.DisplayName)
|
|
require.NotNil(t, gpt4o.DefaultReasoningLevel)
|
|
require.Equal(t, "none", *gpt4o.DefaultReasoningLevel)
|
|
require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(gpt4o.SupportedReasoningLevels))
|
|
require.True(t, gpt4o.SupportsParallelToolCalls)
|
|
|
|
image := newConfiguredCodexModelDescriptor("gpt-image-2")
|
|
require.Equal(t, "gpt-image-2", image.DisplayName)
|
|
require.NotNil(t, image.DefaultReasoningLevel)
|
|
require.Equal(t, "none", *image.DefaultReasoningLevel)
|
|
require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(image.SupportedReasoningLevels))
|
|
|
|
custom := newConfiguredCodexModelDescriptor("company-coding-model")
|
|
require.Equal(t, "company-coding-model", custom.DisplayName)
|
|
require.Equal(t, int64(272_000), custom.ContextWindow)
|
|
require.NotNil(t, custom.DefaultReasoningLevel)
|
|
require.Equal(t, "none", *custom.DefaultReasoningLevel)
|
|
require.Equal(t, []configuredCodexReasoningLevel{
|
|
{Effort: "none", Description: configuredCodexReasoningLevelDescription("none")},
|
|
}, custom.SupportedReasoningLevels)
|
|
require.False(t, custom.SupportsParallelToolCalls)
|
|
require.NotEmpty(t, custom.ModelMessages.InstructionsTemplate)
|
|
require.Equal(t, "auto", custom.DefaultReasoningSummary)
|
|
require.Equal(t, configuredCodexTruncationPolicy{Mode: "bytes", Limit: 10_000}, custom.TruncationPolicy)
|
|
}
|
|
|
|
func TestBuildCodexModelsManifestUsesGPT6AstraInstructions(t *testing.T) {
|
|
body, err := BuildCodexModelsManifest([]string{"gpt-6-astra"})
|
|
require.NoError(t, err)
|
|
|
|
var manifest struct {
|
|
Models []struct {
|
|
ModelMessages struct {
|
|
InstructionsTemplate string `json:"instructions_template"`
|
|
} `json:"model_messages"`
|
|
} `json:"models"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(body, &manifest))
|
|
require.Len(t, manifest.Models, 1)
|
|
require.True(t, strings.HasPrefix(
|
|
strings.TrimSpace(manifest.Models[0].ModelMessages.InstructionsTemplate),
|
|
"You are Codex, an agent based on GPT-6.",
|
|
))
|
|
}
|
|
|
|
func effortsFromConfiguredCodexLevels(levels []configuredCodexReasoningLevel) []string {
|
|
efforts := make([]string, 0, len(levels))
|
|
for _, level := range levels {
|
|
efforts = append(efforts, level.Effort)
|
|
}
|
|
return efforts
|
|
}
|
|
|
|
// Scenario: 无推理模型可直接选中。
|
|
func TestBuildCodexModelsManifestUsesSingleNoneReasoningChoiceForCustomModel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body, err := BuildCodexModelsManifest([]string{"company-coding-model"})
|
|
require.NoError(t, err)
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, "none", models[0]["default_reasoning_level"])
|
|
levels, ok := models[0]["supported_reasoning_levels"].([]any)
|
|
require.True(t, ok)
|
|
require.Len(t, levels, 1)
|
|
firstLevel, ok := levels[0].(map[string]any)
|
|
require.True(t, ok)
|
|
require.Equal(t, "none", firstLevel["effort"])
|
|
}
|
|
|
|
// Scenario: 已知推理模型保留真实档位。
|
|
func TestBuildCodexModelsManifestKeepsKnownReasoningChoices(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body, err := BuildCodexModelsManifest([]string{"gpt-5.6-sol"})
|
|
require.NoError(t, err)
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, "low", models[0]["default_reasoning_level"])
|
|
levels, ok := models[0]["supported_reasoning_levels"].([]any)
|
|
require.True(t, ok)
|
|
require.Len(t, levels, 6)
|
|
firstLevel, ok := levels[0].(map[string]any)
|
|
require.True(t, ok)
|
|
require.NotEqual(t, "none", firstLevel["effort"])
|
|
}
|
|
|
|
// Scenario: 支持 Fast 的 GPT 型号在目录中声明 priority service tier。
|
|
func TestBuildCodexModelsManifestAdvertisesPriorityServiceTierForFastGPTModels(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body, err := BuildCodexModelsManifest([]string{
|
|
"gpt-5.4-mini",
|
|
"gpt-5.5",
|
|
"gpt-5.6-terra",
|
|
})
|
|
require.NoError(t, err)
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 3)
|
|
|
|
for _, model := range models {
|
|
require.Equal(t, []any{
|
|
map[string]any{
|
|
"id": "priority",
|
|
"name": "Fast",
|
|
"description": "Priority processing for lower latency.",
|
|
},
|
|
}, model["service_tiers"])
|
|
require.Nil(t, model["default_service_tier"])
|
|
}
|
|
}
|
|
|
|
// Scenario: GPT-5.6 Sol 在 Fast 之外额外声明 ultrafast service tier。
|
|
func TestBuildCodexModelsManifestAdvertisesUltrafastServiceTierForSol(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body, err := BuildCodexModelsManifest([]string{
|
|
"gpt-5.6-sol",
|
|
"gpt-5.6",
|
|
})
|
|
require.NoError(t, err)
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 2)
|
|
|
|
wantTiers := []any{
|
|
map[string]any{
|
|
"id": "priority",
|
|
"name": "Fast",
|
|
"description": "Priority processing for lower latency.",
|
|
},
|
|
map[string]any{
|
|
"id": "ultrafast",
|
|
"name": "Ultrafast",
|
|
"description": "Ultra-low latency processing.",
|
|
},
|
|
}
|
|
for _, model := range models {
|
|
require.Equal(t, wantTiers, model["service_tiers"])
|
|
require.Nil(t, model["default_service_tier"])
|
|
}
|
|
}
|
|
|
|
// Scenario: 未明确支持 Fast 的型号不推测 service tier。
|
|
func TestBuildCodexModelsManifestLeavesServiceTiersEmptyForOtherModels(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body, err := BuildCodexModelsManifest([]string{
|
|
"gpt-4o",
|
|
"claude-opus-4-6",
|
|
"deepseek-v4-pro",
|
|
"company-coding-model",
|
|
})
|
|
require.NoError(t, err)
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 4)
|
|
|
|
for _, model := range models {
|
|
require.Equal(t, []any{}, model["service_tiers"])
|
|
require.Nil(t, model["default_service_tier"])
|
|
}
|
|
}
|
|
|
|
// Scenario: 专用图片生成模型不进入 Codex 主模型目录。
|
|
func TestBuildCodexModelsManifestOmitsDedicatedImageModels(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body, err := BuildCodexModelsManifest([]string{
|
|
"grok-4.6",
|
|
"gpt-image-1",
|
|
"gpt-image-1.5",
|
|
"gpt-image-2",
|
|
"openai/gpt-image-2",
|
|
"gemini-2.5-flash-image",
|
|
"gemini-3.1-flash-image-preview",
|
|
"gemini-3-pro-image",
|
|
"google/gemini-3-pro-image",
|
|
"google/models/gemini-2.5-flash-image-preview",
|
|
"grok-imagine-image",
|
|
"grok-imagine-video",
|
|
"xai/grok-imagine-image-quality",
|
|
"grok-4.5",
|
|
})
|
|
require.NoError(t, err)
|
|
models := decodeCodexManifestModels(t, body)
|
|
slugs := make([]string, 0, len(models))
|
|
for _, model := range models {
|
|
slug, _ := model["slug"].(string)
|
|
slugs = append(slugs, slug)
|
|
}
|
|
require.Equal(t, []string{"grok-4.6", "grok-4.5"}, slugs)
|
|
}
|
|
|
|
func TestBuildCodexModelsManifestForGroupAdvertisesOfficialGrokResponsesImageInput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 701
|
|
svc := &GatewayService{
|
|
accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
|
groupID: {{
|
|
ID: 1,
|
|
Platform: PlatformGrok,
|
|
Type: AccountTypeOAuth,
|
|
Credentials: map[string]any{
|
|
"access_token": "token",
|
|
},
|
|
}},
|
|
}},
|
|
}
|
|
|
|
body, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformComposite},
|
|
"",
|
|
[]string{"grok-4.5"},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
|
require.Equal(t, "Grok 4.5", models[0]["display_name"])
|
|
require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
|
|
}
|
|
|
|
func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageInput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 702
|
|
svc := &GatewayService{
|
|
accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
|
groupID: {{
|
|
ID: 2,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
}},
|
|
}},
|
|
}
|
|
|
|
body, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformComposite},
|
|
"",
|
|
[]string{"gpt-5.6-sol"},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
|
}
|
|
|
|
func TestBuildCodexModelsManifestForGroupUsesProviderImageCapabilities(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
model string
|
|
accounts []Account
|
|
modalities []any
|
|
}{
|
|
{
|
|
name: "official Grok 4.6",
|
|
model: "grok-4.6",
|
|
accounts: []Account{{
|
|
ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
|
}},
|
|
modalities: []any{"text", "image"},
|
|
},
|
|
{
|
|
name: "official Grok Build vision host",
|
|
model: "grok-build-0.1",
|
|
accounts: []Account{{
|
|
ID: 11, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
|
}},
|
|
modalities: []any{"text", "image"},
|
|
},
|
|
{
|
|
name: "official Grok 4.20 vision model",
|
|
model: "grok-4.20-0309-reasoning",
|
|
accounts: []Account{{
|
|
ID: 23, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
|
}},
|
|
modalities: []any{"text", "image"},
|
|
},
|
|
{
|
|
name: "Grok 3 Mini is text only",
|
|
model: "grok-3-mini",
|
|
accounts: []Account{{
|
|
ID: 24, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
|
}},
|
|
modalities: []any{"text"},
|
|
},
|
|
{
|
|
name: "Grok Composer has only Chat image bridge",
|
|
model: "grok-composer-2.5-fast",
|
|
accounts: []Account{{
|
|
ID: 12, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
|
}},
|
|
modalities: []any{"text"},
|
|
},
|
|
{
|
|
name: "custom Grok host",
|
|
model: "grok-4.5",
|
|
accounts: []Account{{
|
|
ID: 13, Platform: PlatformGrok, Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{"base_url": "https://relay.example.test/v1"},
|
|
}},
|
|
modalities: []any{"text"},
|
|
},
|
|
{
|
|
name: "malformed Grok host",
|
|
model: "grok-4.5",
|
|
accounts: []Account{{
|
|
ID: 19, Platform: PlatformGrok, Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{"base_url": "::invalid::url"},
|
|
}},
|
|
modalities: []any{"text"},
|
|
},
|
|
{
|
|
name: "mixed official and custom Grok candidates",
|
|
model: "grok-4.5",
|
|
accounts: []Account{
|
|
{ID: 14, Platform: PlatformGrok, Type: AccountTypeOAuth},
|
|
{
|
|
ID: 15, Platform: PlatformGrok, Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{"base_url": "https://relay.example.test/v1"},
|
|
},
|
|
},
|
|
modalities: []any{"text"},
|
|
},
|
|
{
|
|
name: "DeepSeek V4",
|
|
model: "deepseek-v4-pro",
|
|
accounts: []Account{{
|
|
ID: 16, Platform: PlatformDeepseek, Type: AccountTypeAPIKey,
|
|
}},
|
|
modalities: []any{"text"},
|
|
},
|
|
{
|
|
name: "official OpenAI API key",
|
|
model: "gpt-5.6-sol",
|
|
accounts: []Account{{
|
|
ID: 17, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
|
}},
|
|
modalities: []any{"text", "image"},
|
|
},
|
|
{
|
|
name: "official OpenAI legacy text model",
|
|
model: "gpt-3.5-turbo",
|
|
accounts: []Account{{
|
|
ID: 20, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
|
}},
|
|
modalities: []any{"text"},
|
|
},
|
|
{
|
|
name: "custom OpenAI-compatible host",
|
|
model: "gpt-5.6-sol",
|
|
accounts: []Account{{
|
|
ID: 18, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
|
|
}},
|
|
modalities: []any{"text", "image"},
|
|
},
|
|
{
|
|
name: "custom OpenAI-compatible host with unknown model",
|
|
model: "company-coding-model",
|
|
accounts: []Account{{
|
|
ID: 29, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
|
|
}},
|
|
modalities: []any{"text"},
|
|
},
|
|
}
|
|
|
|
for i, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
groupID := int64(710 + i)
|
|
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
|
groupID: tt.accounts,
|
|
}}}
|
|
body, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformComposite},
|
|
"",
|
|
[]string{tt.model},
|
|
)
|
|
require.NoError(t, err)
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, tt.modalities, models[0]["input_modalities"])
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildCodexModelsManifestForGroupUsesDeepSeekVisionCapabilities(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const visionModel = "deepseek-v4-flash-vision-exp"
|
|
newAccount := func(id int64, platform, model string, modalities []string) Account {
|
|
account := Account{
|
|
ID: id, Platform: platform, Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{"model_mapping": map[string]any{"vision-alias": model}},
|
|
}
|
|
if modalities != nil {
|
|
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
|
model: {ID: model, InputModalities: modalities},
|
|
}})
|
|
}
|
|
return account
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
platform string
|
|
accounts []Account
|
|
modalities []any
|
|
}{
|
|
{
|
|
name: "native DeepSeek", platform: PlatformDeepseek,
|
|
accounts: []Account{newAccount(1, PlatformDeepseek, visionModel, nil)},
|
|
modalities: []any{"text", "image"},
|
|
},
|
|
{
|
|
name: "OpenAI-compatible DeepSeek", platform: PlatformOpenAI,
|
|
accounts: []Account{newAccount(1, PlatformOpenAI, visionModel, nil)},
|
|
modalities: []any{"text", "image"},
|
|
},
|
|
{
|
|
name: "Composite DeepSeek alias", platform: PlatformComposite,
|
|
accounts: []Account{newAccount(1, PlatformDeepseek, visionModel, nil)},
|
|
modalities: []any{"text", "image"},
|
|
},
|
|
{
|
|
name: "text-only DeepSeek Flash", platform: PlatformDeepseek,
|
|
accounts: []Account{newAccount(1, PlatformDeepseek, "deepseek-v4-flash", nil)},
|
|
modalities: []any{"text"},
|
|
},
|
|
{
|
|
name: "explicit text-only metadata", platform: PlatformDeepseek,
|
|
accounts: []Account{newAccount(1, PlatformDeepseek, visionModel, []string{"text"})},
|
|
modalities: []any{"text"},
|
|
},
|
|
{
|
|
name: "mixed vision and text-only alias", platform: PlatformDeepseek,
|
|
accounts: []Account{
|
|
newAccount(1, PlatformDeepseek, visionModel, nil),
|
|
newAccount(2, PlatformDeepseek, "deepseek-v4-flash", nil),
|
|
},
|
|
modalities: []any{"text"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
const groupID int64 = 790
|
|
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
|
groupID: tt.accounts,
|
|
}}}
|
|
body, err := svc.BuildCodexModelsManifestForGroup(context.Background(),
|
|
&Group{ID: groupID, Platform: tt.platform}, "", []string{"vision-alias"})
|
|
require.NoError(t, err)
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, tt.modalities, models[0]["input_modalities"])
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildCodexModelsManifestForGroupPrefersSyncedOpenAIImageCapabilities(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
newAccount := func(id int64, modalities []string) Account {
|
|
account := Account{
|
|
ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
|
|
}
|
|
if modalities != nil {
|
|
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
|
"gpt-5.6-sol": {ID: "gpt-5.6-sol", InputModalities: modalities},
|
|
}})
|
|
}
|
|
return account
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
accounts []Account
|
|
modalities []any
|
|
}{
|
|
{
|
|
name: "explicit text-only snapshot narrows local fallback",
|
|
accounts: []Account{
|
|
newAccount(30, nil),
|
|
newAccount(31, []string{"text"}),
|
|
},
|
|
modalities: []any{"text"},
|
|
},
|
|
{
|
|
name: "explicit multimodal snapshot preserves local fallback",
|
|
accounts: []Account{
|
|
newAccount(32, nil),
|
|
newAccount(33, []string{"text", "image"}),
|
|
},
|
|
modalities: []any{"text", "image"},
|
|
},
|
|
}
|
|
|
|
for i, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
groupID := int64(760 + i)
|
|
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
|
groupID: tt.accounts,
|
|
}}}
|
|
body, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformOpenAI},
|
|
"",
|
|
[]string{"gpt-5.6-sol"},
|
|
)
|
|
require.NoError(t, err)
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, tt.modalities, models[0]["input_modalities"])
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildCodexModelsManifestForGroupUsesExplicitCompositeResponsesRouteModel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 730
|
|
routeRepo := compositeRouteRepoStub{routes: []CompositeModelRoute{{
|
|
ID: 1,
|
|
GroupID: groupID,
|
|
PublicModel: "vision-alias",
|
|
MatchType: CompositeRouteMatchExact,
|
|
TargetPlatform: PlatformGrok,
|
|
UpstreamModel: "grok-4.5",
|
|
Endpoint: CompositeRouteEndpointResponses,
|
|
Enabled: true,
|
|
}}}
|
|
svc := &GatewayService{
|
|
accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
|
groupID: {{ID: 20, Platform: PlatformGrok, Type: AccountTypeOAuth}},
|
|
}},
|
|
compositeResolver: NewCompositeRouteResolver(routeRepo),
|
|
}
|
|
|
|
body, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformComposite},
|
|
"",
|
|
[]string{"vision-alias"},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
|
}
|
|
|
|
func TestBuildCodexModelsManifestForGroupUsesAccountMappingOwnershipAndMappedModel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 731
|
|
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
|
groupID: {{
|
|
ID: 21,
|
|
Platform: PlatformGrok,
|
|
Type: AccountTypeOAuth,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{"vision-alias": "grok-4.5"},
|
|
},
|
|
}},
|
|
}}}
|
|
|
|
body, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformComposite},
|
|
"",
|
|
[]string{"vision-alias"},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
|
}
|
|
|
|
// Scenario: a Composite exact alias inherits metadata from its unique mapped target model.
|
|
func TestBuildCodexModelsManifestForGroupUsesMappedTargetMetadataForCompositeAlias(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 733
|
|
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
|
groupID: {{
|
|
ID: 23,
|
|
Platform: PlatformAnthropic,
|
|
Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{"reasoning-alias": "claude-opus-4-8"},
|
|
},
|
|
}},
|
|
}}}
|
|
|
|
body, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformComposite},
|
|
"",
|
|
[]string{"reasoning-alias"},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, "reasoning-alias", models[0]["slug"])
|
|
require.Equal(t, "reasoning-alias", models[0]["display_name"])
|
|
require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"])
|
|
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromManifestModel(t, models[0]))
|
|
}
|
|
|
|
// Scenario: conflicting targets on the same platform keep the public alias but do not guess capabilities.
|
|
func TestBuildCodexModelsManifestForGroupUsesSafeFallbackForConflictingAliasTargets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 734
|
|
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
|
groupID: {
|
|
{
|
|
ID: 24,
|
|
Platform: PlatformAnthropic,
|
|
Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{"shared-alias": "claude-opus-4-8"},
|
|
},
|
|
},
|
|
{
|
|
ID: 25,
|
|
Platform: PlatformAnthropic,
|
|
Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{"shared-alias": "claude-haiku-4-5-20251001"},
|
|
},
|
|
},
|
|
},
|
|
}}}
|
|
|
|
body, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformComposite},
|
|
"",
|
|
[]string{"shared-alias"},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, "shared-alias", models[0]["slug"])
|
|
require.Equal(t, "shared-alias", models[0]["display_name"])
|
|
require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"])
|
|
require.Empty(t, effortsFromManifestModel(t, models[0]))
|
|
}
|
|
|
|
// Scenario: a media-only target remains hidden even when exposed through an ordinary alias.
|
|
func TestBuildCodexModelsManifestForGroupOmitsDedicatedMediaTargetAlias(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 735
|
|
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
|
groupID: {{
|
|
ID: 26,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{"creative-alias": "gpt-image-2"},
|
|
},
|
|
}},
|
|
}}}
|
|
|
|
body, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformComposite},
|
|
"",
|
|
[]string{"creative-alias"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Empty(t, decodeCodexManifestModels(t, body))
|
|
}
|
|
|
|
func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 732
|
|
repo := &countingCodexModelsAccountRepo{accounts: []Account{{
|
|
ID: 22,
|
|
Platform: PlatformGrok,
|
|
Type: AccountTypeOAuth,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{
|
|
"vision-alias-a": "grok-4.5",
|
|
"vision-alias-b": "grok-4.6",
|
|
},
|
|
},
|
|
}}}
|
|
svc := &GatewayService{accountRepo: repo}
|
|
_, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformComposite},
|
|
"",
|
|
[]string{"vision-alias-a", "vision-alias-b", "deepseek-v4-pro"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int32(1), repo.calls.Load())
|
|
require.NotNil(t, repo.groupID)
|
|
require.Equal(t, groupID, *repo.groupID)
|
|
require.False(t, repo.includeGrouped)
|
|
require.Contains(t, repo.platforms, PlatformOpenAI)
|
|
require.Contains(t, repo.platforms, PlatformGrok)
|
|
require.Contains(t, repo.platforms, PlatformDeepseek)
|
|
require.Contains(t, repo.platforms, PlatformMiniMax)
|
|
require.Contains(t, repo.platforms, PlatformOpenCodeGo)
|
|
require.NotContains(t, repo.platforms, PlatformComposite)
|
|
}
|
|
|
|
func TestBuildCodexModelsManifestForGroupUsesFallbackWhenTextOnlyPlatformHasNoSnapshot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
repo := &countingCodexModelsAccountRepo{}
|
|
svc := &GatewayService{accountRepo: repo}
|
|
body, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: 733, Platform: PlatformDeepseek},
|
|
"",
|
|
[]string{"deepseek-v4-pro"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int32(1), repo.calls.Load())
|
|
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
|
}
|
|
|
|
func TestBuildCodexModelsManifestForGroupFallsBackWhenCapabilityLookupFails(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
repo := &countingCodexModelsAccountRepo{err: errors.New("account repository unavailable")}
|
|
svc := &GatewayService{accountRepo: repo}
|
|
body, err := svc.BuildCodexModelsManifestForGroup(
|
|
context.Background(),
|
|
&Group{ID: 734, Platform: PlatformComposite},
|
|
"",
|
|
[]string{"gpt-5.6-sol", "grok-4.5"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int32(1), repo.calls.Load())
|
|
|
|
models := decodeCodexManifestModels(t, body)
|
|
require.Len(t, models, 2)
|
|
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
|
require.Equal(t, []any{"text"}, models[1]["input_modalities"])
|
|
}
|
|
|
|
func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 71
|
|
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
|
|
byGroup: map[int64][]Account{
|
|
groupID: {
|
|
{
|
|
Platform: PlatformOpenAI,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{
|
|
"deepseek-4-pro": "deepseek-v4-pro",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
72: {
|
|
{
|
|
Platform: PlatformOpenAI,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{"other-group-model": "upstream-model"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
manifest := &OpenAIModelsResponse{
|
|
Body: []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6","unknown":{"kept":true}}],"metadata":{"version":1}}`),
|
|
}
|
|
|
|
err := svc.MergeGroupConfiguredCodexModels(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformOpenAI},
|
|
manifest,
|
|
"",
|
|
)
|
|
require.NoError(t, err)
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 2)
|
|
require.Equal(t, "gpt-5.6", models[0]["slug"])
|
|
require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"])
|
|
requireCompleteConfiguredCodexModel(t, models[1], "deepseek-4-pro")
|
|
require.EqualValues(t, 1_000_000, models[1]["context_window"])
|
|
require.EqualValues(t, 1_000_000, models[1]["max_context_window"])
|
|
require.Equal(t, "high", models[1]["default_reasoning_level"])
|
|
require.Len(t, models[1]["supported_reasoning_levels"], 3)
|
|
require.NotContains(t, string(manifest.Body), "other-group-model")
|
|
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
|
}
|
|
|
|
// Mixed groups retain configured metadata alongside defaults for unmapped accounts.
|
|
func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 77
|
|
reasoning := true
|
|
arkAccount := Account{
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{
|
|
"glm-5.3": "glm-5.3",
|
|
"gpt-image-2": "gpt-image-2",
|
|
},
|
|
},
|
|
}
|
|
arkAccount.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
|
"glm-5.3": {
|
|
ID: "glm-5.3",
|
|
DisplayName: "GLM 5.3",
|
|
Description: "Ark coding model",
|
|
Reasoning: &reasoning,
|
|
DefaultReasoningLevel: "medium",
|
|
SupportedReasoningLevels: []string{"low", "medium", "high"},
|
|
InputModalities: []string{"text"},
|
|
ContextWindow: 1_000_000,
|
|
},
|
|
}})
|
|
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
|
|
byGroup: map[int64][]Account{
|
|
groupID: {
|
|
{
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
},
|
|
arkAccount,
|
|
},
|
|
},
|
|
}}
|
|
group := &Group{ID: groupID, Platform: PlatformOpenAI}
|
|
|
|
manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "")
|
|
require.NoError(t, err)
|
|
require.True(t, configured)
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Equal(t, "glm-5.3", models[0]["slug"])
|
|
require.Contains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-5.6-sol")
|
|
require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-image-2")
|
|
require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "codex-auto-review")
|
|
require.Equal(t, "GLM 5.3", models[0]["display_name"])
|
|
require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
|
|
require.Equal(t, "medium", models[0]["default_reasoning_level"])
|
|
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
|
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
|
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
|
|
|
notModified, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
|
|
context.Background(),
|
|
group,
|
|
"W/"+manifest.ETag,
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, configured)
|
|
require.True(t, notModified.NotModified)
|
|
require.Empty(t, notModified.Body)
|
|
require.Equal(t, manifest.ETag, notModified.ETag)
|
|
}
|
|
|
|
// Scenario: OpenAI 通配映射展开组内精确选择,但不发布通配符 slug。
|
|
func TestBuildGroupConfiguredCodexModelsManifestExpandsSelectedModelCoveredByWildcardMapping(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 80
|
|
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
|
|
byGroup: map[int64][]Account{
|
|
groupID: {{
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeAPIKey,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{"gpt-*": "gpt-5.6-sol"},
|
|
},
|
|
}},
|
|
},
|
|
}}
|
|
group := &Group{
|
|
ID: groupID,
|
|
Platform: PlatformOpenAI,
|
|
ModelAllowlist: GroupModelAllowlist{
|
|
Enabled: true,
|
|
Models: []string{"gpt-5.6"},
|
|
},
|
|
}
|
|
|
|
manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "")
|
|
require.NoError(t, err)
|
|
require.True(t, configured)
|
|
require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body))
|
|
require.NotContains(t, string(manifest.Body), "gpt-*")
|
|
}
|
|
|
|
// Scenario: OpenAI 配置目录对仅因瞬态状态退出当前调度池的账号取能力交集,
|
|
// 且不发布其独有模型。持久 schedulable 仍为 true。
|
|
func TestBuildGroupConfiguredCodexModelsManifestIntersectsTransientlyUnschedulableMappedAccounts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 79
|
|
schedulable := newCodexCatalogMappedAccount(
|
|
41,
|
|
"gpt-5.6-sol",
|
|
"GPT-5.6 Sol",
|
|
[]string{"low", "medium", "high", "xhigh"},
|
|
[]string{"text", "image"},
|
|
1_000_000,
|
|
true,
|
|
nil,
|
|
)
|
|
transientlyUnschedulable := newCodexCatalogMappedAccount(
|
|
42,
|
|
"glm-5.3",
|
|
"GLM 5.3",
|
|
[]string{"low", "medium", "high"},
|
|
[]string{"text"},
|
|
272_000,
|
|
true,
|
|
map[string]any{"exclusive-model": "exclusive-upstream"},
|
|
)
|
|
svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{
|
|
schedulable: map[int64][]Account{groupID: {schedulable}},
|
|
catalog: map[int64][]Account{groupID: {schedulable, transientlyUnschedulable}},
|
|
}}
|
|
|
|
manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformOpenAI},
|
|
"",
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, configured)
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, "my-coder", models[0]["slug"])
|
|
require.Equal(t, "my-coder", models[0]["display_name"])
|
|
require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
|
|
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
|
require.EqualValues(t, 272_000, models[0]["context_window"])
|
|
}
|
|
|
|
// Scenario: 管理员持久禁用的账号不能继续收窄 Codex 能力目录。
|
|
func TestBuildGroupConfiguredCodexModelsManifestIgnoresPersistentlyDisabledMappedAccounts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 80
|
|
enabled := newCodexCatalogMappedAccount(
|
|
51,
|
|
"gpt-5.6-sol",
|
|
"GPT-5.6 Sol",
|
|
[]string{"low", "medium", "high", "xhigh"},
|
|
[]string{"text", "image"},
|
|
1_000_000,
|
|
true,
|
|
nil,
|
|
)
|
|
disabled := newCodexCatalogMappedAccount(
|
|
52,
|
|
"glm-5.3",
|
|
"GLM 5.3",
|
|
[]string{"low", "medium", "high"},
|
|
[]string{"text"},
|
|
272_000,
|
|
false,
|
|
nil,
|
|
)
|
|
svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{
|
|
schedulable: map[int64][]Account{groupID: {enabled}},
|
|
catalog: map[int64][]Account{groupID: {enabled}},
|
|
all: map[int64][]Account{groupID: {enabled, disabled}},
|
|
}}
|
|
|
|
manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformOpenAI},
|
|
"",
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, configured)
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, "my-coder", models[0]["slug"])
|
|
require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0]))
|
|
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
|
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
|
}
|
|
|
|
// Scenario: 没有管理员模型配置时保留现有上游发现路径。
|
|
func TestBuildGroupConfiguredCodexModelsManifestFallsThroughWithoutConfiguration(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 78
|
|
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
|
|
byGroup: map[int64][]Account{
|
|
groupID: {{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}},
|
|
},
|
|
}}
|
|
|
|
manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformOpenAI},
|
|
"",
|
|
)
|
|
require.NoError(t, err)
|
|
require.False(t, configured)
|
|
require.Nil(t, manifest)
|
|
}
|
|
|
|
func TestMergeGroupConfiguredCodexModelsFiltersAutoReviewByDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 74
|
|
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}}
|
|
manifest := &OpenAIModelsResponse{
|
|
Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"codex-auto-future","visibility":"list"},{"slug":"gpt-image-2","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`),
|
|
}
|
|
|
|
require.NoError(t, svc.MergeGroupConfiguredCodexModels(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformOpenAI},
|
|
manifest,
|
|
"",
|
|
))
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, "gpt-5.6", models[0]["slug"])
|
|
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
|
}
|
|
|
|
// Scenario: OpenAI 账号映射不启用 Auto Review。
|
|
func TestMergeGroupConfiguredCodexModelsFiltersAccountMappedAutoReviewByDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 75
|
|
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
|
|
byGroup: map[int64][]Account{
|
|
groupID: {
|
|
{
|
|
Platform: PlatformOpenAI,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{
|
|
openai.CodexUsageProbeModel: openai.CodexUsageProbeModel,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
manifest := &OpenAIModelsResponse{
|
|
Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"hide","model_messages":{"auto_review":{"enabled":true}}},{"slug":"gpt-5.6","visibility":"list"}]}`),
|
|
}
|
|
|
|
require.NoError(t, svc.MergeGroupConfiguredCodexModels(
|
|
context.Background(),
|
|
&Group{ID: groupID, Platform: PlatformOpenAI},
|
|
manifest,
|
|
"",
|
|
))
|
|
require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body))
|
|
}
|
|
|
|
// Scenario: 启用的分组自定义列表允许 Auto Review。
|
|
func TestMergeGroupConfiguredCodexModelsKeepsExplicitAutoReviewSelection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 76
|
|
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}}
|
|
manifest := &OpenAIModelsResponse{
|
|
Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`),
|
|
}
|
|
group := &Group{
|
|
ID: groupID,
|
|
Platform: PlatformOpenAI,
|
|
ModelAllowlist: GroupModelAllowlist{
|
|
Enabled: true,
|
|
Models: []string{openai.CodexUsageProbeModel},
|
|
},
|
|
}
|
|
|
|
require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, ""))
|
|
require.Equal(t, []string{"codex-auto-review"}, codexManifestModelSlugs(t, manifest.Body))
|
|
}
|
|
|
|
func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const groupID int64 = 73
|
|
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
|
|
byGroup: map[int64][]Account{
|
|
groupID: {
|
|
{
|
|
Platform: PlatformOpenAI,
|
|
Credentials: map[string]any{
|
|
"model_mapping": map[string]any{
|
|
"deepseek-4-pro": "deepseek-v4-pro",
|
|
"hidden-alias": "hidden-upstream",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
group := &Group{
|
|
ID: groupID,
|
|
Platform: PlatformOpenAI,
|
|
ModelAllowlist: GroupModelAllowlist{
|
|
Enabled: true,
|
|
Models: []string{"deepseek-4-pro"},
|
|
},
|
|
}
|
|
upstreamBody := []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6"}]}`)
|
|
manifest := &OpenAIModelsResponse{Body: upstreamBody}
|
|
|
|
require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, ""))
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 1)
|
|
requireCompleteConfiguredCodexModel(t, models[0], "deepseek-4-pro")
|
|
|
|
finalETag := manifest.ETag
|
|
second := &OpenAIModelsResponse{Body: upstreamBody}
|
|
require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, finalETag))
|
|
require.True(t, second.NotModified)
|
|
require.Empty(t, second.Body)
|
|
require.Equal(t, finalETag, second.ETag)
|
|
}
|
|
|
|
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 TestIsRetryableCodexModelsManifestStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
statusCode int
|
|
useAPIKeyUpstream bool
|
|
retryable bool
|
|
}{
|
|
{name: "api key 404", statusCode: http.StatusNotFound, useAPIKeyUpstream: true, retryable: true},
|
|
{name: "api key 405", statusCode: http.StatusMethodNotAllowed, useAPIKeyUpstream: true, retryable: true},
|
|
{name: "oauth 404", statusCode: http.StatusNotFound},
|
|
{name: "oauth 405", statusCode: http.StatusMethodNotAllowed},
|
|
{name: "api key 401", statusCode: http.StatusUnauthorized, useAPIKeyUpstream: true},
|
|
{name: "oauth 401", statusCode: http.StatusUnauthorized, retryable: true},
|
|
{name: "api key 400", statusCode: http.StatusBadRequest, useAPIKeyUpstream: true},
|
|
{name: "api key 403", statusCode: http.StatusForbidden, useAPIKeyUpstream: true},
|
|
{name: "rate limited", statusCode: http.StatusTooManyRequests, retryable: true},
|
|
{name: "server error", statusCode: http.StatusServiceUnavailable, retryable: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, tt.retryable, isRetryableCodexModelsManifestStatus(tt.statusCode, tt.useAPIKeyUpstream))
|
|
})
|
|
}
|
|
}
|
|
|
|
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) {
|
|
manifestBody := `{"models":[{"slug":"gpt-5.5"}]}`
|
|
var calls atomic.Int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
calls.Add(1)
|
|
w.Header().Set("ETag", `W/"abc123"`)
|
|
_, _ = w.Write([]byte(manifestBody))
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{}
|
|
account := newCodexModelsTestAccount()
|
|
first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
if err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
if first.NotModified {
|
|
t.Fatal("cold fetch must return the manifest body")
|
|
}
|
|
if first.ETag != `W/"abc123"` {
|
|
t.Errorf("etag not passed through: got %q", first.ETag)
|
|
}
|
|
|
|
// OAuth 路径接入缓存后,客户端 If-None-Match 与缓存内容 ETag 比较,不再透传上游:
|
|
// 新鲜期内命中缓存且匹配直接 304,零上游请求。
|
|
second, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", `W/"abc123"`)
|
|
if err != nil {
|
|
t.Fatalf("cached fetch returned error: %v", err)
|
|
}
|
|
if !second.NotModified {
|
|
t.Fatal("matching cached ETag must return NotModified")
|
|
}
|
|
if second.ETag != `W/"abc123"` {
|
|
t.Errorf("NotModified etag: got %q", second.ETag)
|
|
}
|
|
if got := calls.Load(); got != 1 {
|
|
t.Errorf("upstream calls: got %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
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":"deepseek-v4-pro"}]}`
|
|
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)
|
|
}
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 1)
|
|
requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-pro")
|
|
require.Equal(t, "DeepSeek V4 Pro", models[0]["display_name"])
|
|
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
|
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
|
require.Equal(t, `W/"api-key-manifest"`, manifest.upstreamETag)
|
|
}
|
|
|
|
// Scenario: 完整上游清单没有 ETag 时,最终正文仍生成强 ETag 并支持 304。
|
|
func TestFetchCodexModelsManifestAPIKeyCompleteBodyWithoutUpstreamETagUsesFinalBodyETag(t *testing.T) {
|
|
completeBody, err := BuildCodexModelsManifest([]string{"custom-complete-model"})
|
|
require.NoError(t, err)
|
|
|
|
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(bytes.NewReader(completeBody)),
|
|
}, nil
|
|
}}
|
|
svc := newCodexModelsAPIKeyTestService(upstream)
|
|
svc.accountRepo = codexModelsVisibilityAccountRepo{}
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
|
|
group := &Group{ID: 82, Platform: PlatformOpenAI}
|
|
|
|
first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
|
|
require.NoError(t, err)
|
|
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account))
|
|
require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, first, ""))
|
|
require.Equal(t, codexModelsManifestBodyETag(first.Body), first.ETag)
|
|
require.NotEmpty(t, first.ETag)
|
|
|
|
second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
|
|
require.NoError(t, err)
|
|
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account))
|
|
require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, first.ETag))
|
|
require.True(t, second.NotModified)
|
|
require.Empty(t, second.Body)
|
|
require.Equal(t, int32(1), calls.Load())
|
|
}
|
|
|
|
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)
|
|
}
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 2)
|
|
requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6")
|
|
requireCompleteConfiguredCodexModel(t, models[1], "gpt-5.6-codex")
|
|
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
|
require.Equal(t, []any{"text", "image"}, models[1]["input_modalities"])
|
|
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
|
require.Equal(t, `W/"openai-list"`, manifest.upstreamETag)
|
|
}
|
|
|
|
func TestConvertOpenAIModelListToCodexManifestUsesCompleteDescriptors(t *testing.T) {
|
|
upstreamBody := `{"object":"list","data":[{"id":"gpt-5.5","object":"model"}]}`
|
|
|
|
converted := convertOpenAIModelListToCodexManifest([]byte(upstreamBody))
|
|
models := decodeCodexManifestModels(t, converted)
|
|
|
|
require.Len(t, models, 1)
|
|
requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.5")
|
|
require.Equal(t, "GPT-5.5", models[0]["display_name"])
|
|
require.Equal(t, "medium", models[0]["default_reasoning_level"])
|
|
require.Len(t, models[0]["supported_reasoning_levels"], 4)
|
|
}
|
|
|
|
func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc := &OpenAIGatewayService{}
|
|
manifest := &OpenAIModelsResponse{
|
|
Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","service_tiers":[{"id":"provider-priority","name":"Provider Fast","description":"Provider supplied tier."}],"model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`),
|
|
}
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
|
|
|
|
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 1)
|
|
requireCompleteConfiguredCodexModel(t, models[0], "grok-4.6")
|
|
require.Equal(t, "Provider supplied", models[0]["description"])
|
|
require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"])
|
|
require.Equal(t, []any{
|
|
map[string]any{
|
|
"id": "provider-priority",
|
|
"name": "Provider Fast",
|
|
"description": "Provider supplied tier.",
|
|
},
|
|
}, models[0]["service_tiers"])
|
|
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
|
require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0]))
|
|
modelMessages, ok := models[0]["model_messages"].(map[string]any)
|
|
require.True(t, ok)
|
|
require.NotEmpty(t, modelMessages["instructions_template"])
|
|
require.Equal(t, map[string]any{"enabled": true}, modelMessages["auto_review"])
|
|
require.Equal(t, map[string]any{"mode": "tokens", "limit": float64(10_000)}, models[0]["truncation_policy"])
|
|
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
|
|
|
var envelope map[string]any
|
|
require.NoError(t, json.Unmarshal(manifest.Body, &envelope))
|
|
require.Equal(t, map[string]any{"source": "upstream"}, envelope["metadata"])
|
|
}
|
|
|
|
func TestCompleteAPIKeyCodexModelsManifestForClientUsesKnownGPTImageFallback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc := &OpenAIGatewayService{}
|
|
manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[
|
|
{"slug":"gpt-5.6-sol"},
|
|
{"slug":"company-coding-model"},
|
|
{"slug":"gpt-4o","input_modalities":["text"]}
|
|
]}`)}
|
|
account := newCodexModelsAPIKeyTestAccount("https://openai-compatible.example.test/v1")
|
|
|
|
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 3)
|
|
|
|
bySlug := make(map[string]map[string]any, len(models))
|
|
for _, model := range models {
|
|
slug, ok := model["slug"].(string)
|
|
require.True(t, ok)
|
|
bySlug[slug] = model
|
|
}
|
|
require.Equal(t, []any{"text", "image"}, bySlug["gpt-5.6-sol"]["input_modalities"])
|
|
require.Equal(t, []any{"text"}, bySlug["company-coding-model"]["input_modalities"])
|
|
require.Equal(t, []any{"text"}, bySlug["gpt-4o"]["input_modalities"])
|
|
}
|
|
|
|
// Scenario: 标准 /models 型号列表优先使用已同步账号能力,再使用本地 descriptor 兜底。
|
|
func TestCompleteAPIKeyCodexModelsManifestForClientUsesSyncedMetadataForConvertedModelList(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reasoning := true
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
|
|
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
|
"future-reasoner": {
|
|
ID: "future-reasoner", DisplayName: "Future Reasoner", Description: "Synced upstream capability",
|
|
Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
|
|
SupportedReasoningLevels: []string{"low", "high", "ultra"},
|
|
InputModalities: []string{"text", "image"},
|
|
ContextWindow: 999_000,
|
|
},
|
|
}})
|
|
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"future-reasoner","object":"model"}]}`)),
|
|
}, nil
|
|
}}
|
|
svc := newCodexModelsAPIKeyTestService(upstream)
|
|
|
|
manifest, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
|
|
require.NoError(t, err)
|
|
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
|
|
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, "Future Reasoner", models[0]["display_name"])
|
|
require.Equal(t, "Synced upstream capability", models[0]["description"])
|
|
require.Equal(t, "ultra", models[0]["default_reasoning_level"])
|
|
require.Equal(t, []string{"low", "high", "ultra"}, effortsFromManifestModel(t, models[0]))
|
|
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
|
require.EqualValues(t, 999_000, models[0]["context_window"])
|
|
require.EqualValues(t, 999_000, models[0]["max_context_window"])
|
|
}
|
|
|
|
func TestCompleteAPIKeyCodexModelsManifestForClientFillsMissingProviderFieldsWithoutOverwritingExplicitMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reasoning := true
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
|
|
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
|
"provider-model": {
|
|
ID: "provider-model", DisplayName: "Synced Display", Description: "Synced description",
|
|
Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
|
|
SupportedReasoningLevels: []string{"high", "ultra"},
|
|
InputModalities: []string{"text", "image"},
|
|
ContextWindow: 999_000,
|
|
},
|
|
}})
|
|
manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{
|
|
"slug":"provider-model",
|
|
"description":"Provider supplied",
|
|
"context_window":64000,
|
|
"max_context_window":64000
|
|
}]}`)}
|
|
svc := &OpenAIGatewayService{}
|
|
|
|
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, "Synced Display", models[0]["display_name"])
|
|
require.Equal(t, "Provider supplied", models[0]["description"])
|
|
require.Equal(t, "ultra", models[0]["default_reasoning_level"])
|
|
require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, models[0]))
|
|
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
|
require.EqualValues(t, 64_000, models[0]["context_window"])
|
|
require.EqualValues(t, 64_000, models[0]["max_context_window"])
|
|
}
|
|
|
|
// Scenario: 原生 manifest 的缺失字段在命中缓存后仍使用账号当前同步快照,而不是缓存中的本地默认值。
|
|
func TestCompleteAPIKeyCodexModelsManifestForClientUsesCurrentSnapshotForCachedNativeManifest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reasoning := true
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
|
|
setSnapshot := func(displayName string, contextWindow int64) {
|
|
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
|
"deepseek-v4-pro": {
|
|
ID: "deepseek-v4-pro", DisplayName: displayName,
|
|
Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
|
|
SupportedReasoningLevels: []string{"high", "ultra"},
|
|
InputModalities: []string{"text", "image"},
|
|
ContextWindow: contextWindow,
|
|
},
|
|
}})
|
|
}
|
|
setSnapshot("Synced DeepSeek", 256_000)
|
|
|
|
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: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(`{"models":[{
|
|
"slug":"deepseek-v4-pro",
|
|
"description":"Provider supplied"
|
|
}]}`)),
|
|
}, nil
|
|
}}
|
|
svc := newCodexModelsAPIKeyTestService(upstream)
|
|
|
|
first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
|
|
require.NoError(t, err)
|
|
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account))
|
|
firstModel := decodeCodexManifestModels(t, first.Body)[0]
|
|
require.Equal(t, "Synced DeepSeek", firstModel["display_name"])
|
|
require.Equal(t, "Provider supplied", firstModel["description"])
|
|
require.Equal(t, "ultra", firstModel["default_reasoning_level"])
|
|
require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, firstModel))
|
|
require.Equal(t, []any{"text", "image"}, firstModel["input_modalities"])
|
|
require.EqualValues(t, 256_000, firstModel["context_window"])
|
|
|
|
setSnapshot("Refreshed DeepSeek", 512_000)
|
|
second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
|
|
require.NoError(t, err)
|
|
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account))
|
|
secondModel := decodeCodexManifestModels(t, second.Body)[0]
|
|
require.Equal(t, "Refreshed DeepSeek", secondModel["display_name"])
|
|
require.Equal(t, "Provider supplied", secondModel["description"])
|
|
require.EqualValues(t, 512_000, secondModel["context_window"])
|
|
require.Equal(t, int32(1), calls.Load(), "second response should use the cached upstream source body")
|
|
}
|
|
|
|
func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTImageInput(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc := &OpenAIGatewayService{}
|
|
manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"gpt-6-astra"},{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o"},{"slug":"gpt-3.5-turbo"},{"slug":"gpt-4"}]}`)}
|
|
account := newCodexModelsAPIKeyTestAccount("")
|
|
|
|
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 5)
|
|
|
|
bySlug := make(map[string]map[string]any, len(models))
|
|
for _, model := range models {
|
|
slug, ok := model["slug"].(string)
|
|
require.True(t, ok)
|
|
bySlug[slug] = model
|
|
}
|
|
for _, slug := range []string{"gpt-6-astra", "gpt-5.6-sol", "gpt-4o"} {
|
|
require.Equal(t, []any{"text", "image"}, bySlug[slug]["input_modalities"])
|
|
require.Equal(t, true, bySlug[slug]["supports_image_detail_original"])
|
|
}
|
|
for _, slug := range []string{"gpt-3.5-turbo", "gpt-4"} {
|
|
require.Equal(t, []any{"text"}, bySlug[slug]["input_modalities"])
|
|
require.Equal(t, false, bySlug[slug]["supports_image_detail_original"])
|
|
}
|
|
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
|
}
|
|
|
|
func TestCompleteAPIKeyCodexModelsManifestForClientFiltersOfficialNonAgentModels(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
svc := &OpenAIGatewayService{}
|
|
manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o-realtime-preview"},{"slug":"gpt-4o-mini-tts"},{"slug":"text-embedding-3-large"},{"slug":"omni-moderation-latest"},{"slug":"o4-mini"},{"slug":"codex-mini-latest"}]}`)}
|
|
account := newCodexModelsAPIKeyTestAccount("")
|
|
|
|
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
|
|
require.Equal(t, []string{"gpt-5.6-sol", "o4-mini", "codex-mini-latest"}, codexManifestModelSlugs(t, manifest.Body))
|
|
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
|
}
|
|
|
|
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-6-astra","use_responses_lite":true},{"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-6-astra","use_responses_lite":false},{"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), nil)
|
|
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 TestConvertOpenAIModelListToCompleteCodexManifest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body := []byte(`{"object":"list","data":[{"id":"deepseek-v4-flash"},{"id":"deepseek-v4-pro"}]}`)
|
|
models := decodeCodexManifestModels(t, convertOpenAIModelListToCodexManifest(body))
|
|
|
|
require.Len(t, models, 2)
|
|
requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-flash")
|
|
requireCompleteConfiguredCodexModel(t, models[1], "deepseek-v4-pro")
|
|
require.Equal(t, "DeepSeek V4 Flash", models[0]["display_name"])
|
|
require.Equal(t, "DeepSeek V4 Pro", models[1]["display_name"])
|
|
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
|
require.EqualValues(t, 1_000_000, models[1]["context_window"])
|
|
}
|
|
|
|
func TestConvertOpenAIModelListToCodexManifestLeavesUnsupportedBodiesUnchanged(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
want string
|
|
}{
|
|
{
|
|
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 TestValidateCodexModelsManifestEnvelopeRequiresExactModelsKey(t *testing.T) {
|
|
require.NoError(t, validateCodexModelsManifestEnvelope([]byte(`{"models":[]}`)))
|
|
require.Error(t, validateCodexModelsManifestEnvelope([]byte(`{"Models":[]}`)))
|
|
}
|
|
|
|
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 *OpenAIModelsResponse
|
|
err error
|
|
}, 1)
|
|
go func() {
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
secondResult <- struct {
|
|
manifest *OpenAIModelsResponse
|
|
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 TestFetchCodexModelsManifestAPIKeyCacheSurvivesClientMutation(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(`{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`)),
|
|
}, nil
|
|
}}
|
|
s := newCodexModelsAPIKeyTestService(upstream)
|
|
s.accountRepo = codexModelsVisibilityAccountRepo{}
|
|
account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
|
|
|
|
first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(first.Body), "model-a")
|
|
require.Contains(t, string(first.Body), "model-b")
|
|
|
|
require.NoError(t, s.CompleteAPIKeyCodexModelsManifestForClient(first, account))
|
|
require.NoError(t, s.MergeGroupConfiguredCodexModels(
|
|
context.Background(),
|
|
&Group{
|
|
ID: 81,
|
|
Platform: PlatformOpenAI,
|
|
ModelAllowlist: GroupModelAllowlist{
|
|
Enabled: true,
|
|
Models: []string{"model-a"},
|
|
},
|
|
},
|
|
first,
|
|
"",
|
|
))
|
|
require.Equal(t, []string{"model-a"}, codexManifestModelSlugs(t, first.Body))
|
|
|
|
second, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, second.Body))
|
|
require.Equal(t, int32(1), calls.Load())
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
require.NoError(t, fetchErr)
|
|
require.NoError(t, s.MergeGroupConfiguredCodexModels(
|
|
context.Background(),
|
|
&Group{
|
|
ID: 82,
|
|
Platform: PlatformOpenAI,
|
|
ModelAllowlist: GroupModelAllowlist{
|
|
Enabled: true,
|
|
Models: []string{"model-b"},
|
|
},
|
|
},
|
|
manifest,
|
|
"",
|
|
))
|
|
require.Equal(t, []string{"model-b"}, codexManifestModelSlugs(t, manifest.Body))
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
third, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, third.Body))
|
|
require.Equal(t, int32(1), calls.Load())
|
|
}
|
|
|
|
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":[]}`
|
|
switch {
|
|
case strings.Contains(req.URL.Host, "large-source"):
|
|
body = `{"object":"list","data":[{"id":"model-a","padding":"` + strings.Repeat("x", 1<<20) + `"}]}`
|
|
case 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)
|
|
largeSource := newCodexModelsAPIKeyTestAccount("https://large-source.example")
|
|
largeSource.ID = 4
|
|
fetch(largeSource)
|
|
fetch(largeSource)
|
|
if got := calls.Load(); got != 5 {
|
|
t.Fatalf("body-size bounded cache calls: got %d, want 5", got)
|
|
}
|
|
|
|
for i := int64(10); i < 523; i++ {
|
|
account := newCodexModelsAPIKeyTestAccount("https://bounded.example")
|
|
account.ID = i
|
|
fetch(account)
|
|
}
|
|
last := newCodexModelsAPIKeyTestAccount("https://bounded.example")
|
|
last.ID = 522
|
|
fetch(last)
|
|
if got := calls.Load(); got != 518 {
|
|
t.Fatalf("most recent cache entry was not retained: calls=%d, want 518", got)
|
|
}
|
|
first := newCodexModelsAPIKeyTestAccount("https://bounded.example")
|
|
first.ID = 10
|
|
fetch(first)
|
|
if got := calls.Load(); got != 519 {
|
|
t.Errorf("oldest cache entry was not evicted: calls=%d, want 519", 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.openAIModelsCache.mu.Lock()
|
|
for key, entry := range s.openAIModelsCache.entries {
|
|
entry.expiresAt = time.Now().Add(-time.Second)
|
|
s.openAIModelsCache.entries[key] = entry
|
|
}
|
|
s.openAIModelsCache.mu.Unlock()
|
|
|
|
resultCh := make(chan struct {
|
|
manifest *OpenAIModelsResponse
|
|
err error
|
|
}, 1)
|
|
go func() {
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
|
|
resultCh <- struct {
|
|
manifest *OpenAIModelsResponse
|
|
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 *OpenAIModelsResponse
|
|
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.openAIModelsCache.mu.Lock()
|
|
for key, entry := range s.openAIModelsCache.entries {
|
|
entry.expiresAt = time.Now().Add(-time.Second)
|
|
s.openAIModelsCache.entries[key] = entry
|
|
}
|
|
s.openAIModelsCache.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.openAIModelsCache.mu.Lock()
|
|
fresh := false
|
|
for _, entry := range s.openAIModelsCache.entries {
|
|
fresh = time.Now().Before(entry.expiresAt)
|
|
}
|
|
s.openAIModelsCache.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 TestFetchCodexModelsManifestAPIKeyUsesOfficialOpenAIModelsEndpoint(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) {
|
|
var gotURL string
|
|
s := newCodexModelsAPIKeyTestService(&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(`{"object":"list","data":[{"id":"gpt-5.6-sol"}]}`)),
|
|
}, nil
|
|
}})
|
|
|
|
manifest, err := s.FetchCodexModelsManifest(
|
|
context.Background(),
|
|
newCodexModelsAPIKeyTestAccount(tt.baseURL),
|
|
"0.144.0",
|
|
"",
|
|
)
|
|
require.NoError(t, err)
|
|
parsedURL, parseErr := url.Parse(gotURL)
|
|
require.NoError(t, parseErr)
|
|
require.Equal(t, "api.openai.com", strings.ToLower(parsedURL.Hostname()))
|
|
require.Equal(t, "/v1/models", parsedURL.Path)
|
|
require.Equal(t, "0.144.0", parsedURL.Query().Get("client_version"))
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 1)
|
|
requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6-sol")
|
|
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- OAuth manifest 账号级缓存(统一缓存策略) ---
|
|
|
|
// newCodexModelsOAuthCacheServer 返回一个记录调用次数、返回给定 manifest 的
|
|
// ChatGPT Codex models 测试上游。
|
|
func newCodexModelsOAuthCacheServer(t *testing.T, body string) (*httptest.Server, *atomic.Int32) {
|
|
t.Helper()
|
|
calls := &atomic.Int32{}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
calls.Add(1)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
t.Cleanup(func() { chatgptCodexModelsURL = original })
|
|
return server, calls
|
|
}
|
|
|
|
func expireCodexModelsManifestCache(s *OpenAIGatewayService, age time.Duration) {
|
|
s.openAIModelsCache.mu.Lock()
|
|
for key, entry := range s.openAIModelsCache.entries {
|
|
entry.expiresAt = time.Now().Add(-age)
|
|
entry.staleUntil = time.Now().Add(openAIModelsCacheStaleTTL - age)
|
|
s.openAIModelsCache.entries[key] = entry
|
|
}
|
|
s.openAIModelsCache.mu.Unlock()
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestOAuthFreshWindowZeroUpstreamRequests(t *testing.T) {
|
|
_, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-5.5"}]}`)
|
|
s := &OpenAIGatewayService{}
|
|
account := newCodexModelsTestAccount()
|
|
|
|
for i := 0; i < 10; i++ {
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.NoError(t, err)
|
|
require.False(t, manifest.NotModified)
|
|
require.JSONEq(t, `{"models":[{"slug":"gpt-5.5"}]}`, string(manifest.Body))
|
|
}
|
|
require.EqualValues(t, 1, calls.Load(), "新鲜期内重复请求不得打到 chatgpt.com")
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestOAuthStaleServesOldValueAndRefreshesInBackground(t *testing.T) {
|
|
calls := &atomic.Int32{}
|
|
refreshStarted := make(chan struct{})
|
|
var refreshOnce sync.Once
|
|
release := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
if calls.Add(1) == 2 {
|
|
refreshOnce.Do(func() { close(refreshStarted) })
|
|
<-release
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"models":[{"slug":"new"}]}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
t.Cleanup(func() { chatgptCodexModelsURL = original })
|
|
|
|
s := &OpenAIGatewayService{}
|
|
account := newCodexModelsTestAccount()
|
|
first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(first.Body), `"new"`)
|
|
|
|
expireCodexModelsManifestCache(s, 2*time.Minute)
|
|
|
|
resultCh := make(chan *OpenAIModelsResponse, 1)
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
resultCh <- manifest
|
|
errCh <- fetchErr
|
|
}()
|
|
select {
|
|
case <-refreshStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("乐观期后台刷新未触发")
|
|
}
|
|
|
|
// 乐观期:立即返回旧值,不同步等待刷新。
|
|
select {
|
|
case manifest := <-resultCh:
|
|
require.NoError(t, <-errCh)
|
|
require.Contains(t, string(manifest.Body), `"new"`)
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("乐观期必须立即返回缓存旧值")
|
|
}
|
|
require.EqualValues(t, 2, calls.Load())
|
|
|
|
close(release)
|
|
deadline := time.Now().Add(time.Second)
|
|
for time.Now().Before(deadline) {
|
|
manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
if fetchErr == nil && manifest != nil && !manifest.NotModified {
|
|
require.Contains(t, string(manifest.Body), `"new"`)
|
|
}
|
|
s.openAIModelsCache.mu.Lock()
|
|
fresh := len(s.openAIModelsCache.entries) > 0
|
|
s.openAIModelsCache.mu.Unlock()
|
|
if fresh {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
require.EqualValues(t, 2, calls.Load(), "后台刷新必须单飞")
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestOAuthOverdueSynchronousRefreshAndFailure(t *testing.T) {
|
|
t.Run("overdue refresh waits and re-caches", func(t *testing.T) {
|
|
calls := &atomic.Int32{}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
if calls.Add(1) == 2 {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"models":[{"slug":"refreshed"}]}`))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"models":[{"slug":"stale"}]}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
t.Cleanup(func() { chatgptCodexModelsURL = original })
|
|
|
|
s := &OpenAIGatewayService{}
|
|
account := newCodexModelsTestAccount()
|
|
_, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.NoError(t, err)
|
|
|
|
// 超过 5 分钟:缓存条目被丢弃,同步等待上游刷新。
|
|
expireCodexModelsManifestCache(s, 6*time.Minute)
|
|
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(manifest.Body), `"refreshed"`)
|
|
require.EqualValues(t, 2, calls.Load())
|
|
|
|
// 新内容写回缓存并重新开始新鲜期。
|
|
again, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(again.Body), `"refreshed"`)
|
|
require.EqualValues(t, 2, calls.Load())
|
|
})
|
|
|
|
t.Run("overdue upstream failure returns error not stale cache", func(t *testing.T) {
|
|
fail := &atomic.Bool{}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
if fail.Load() {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"detail":"boom"}`))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"models":[{"slug":"stale"}]}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
t.Cleanup(func() { chatgptCodexModelsURL = original })
|
|
|
|
s := &OpenAIGatewayService{}
|
|
account := newCodexModelsTestAccount()
|
|
_, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.NoError(t, err)
|
|
|
|
fail.Store(true)
|
|
expireCodexModelsManifestCache(s, 6*time.Minute)
|
|
|
|
_, err = s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.Error(t, err, "超期后上游失败必须返回错误而非旧缓存")
|
|
require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err))
|
|
})
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestOAuthTokenChangeCacheMiss(t *testing.T) {
|
|
_, calls := newCodexModelsOAuthCacheServer(t, `{"models":[]}`)
|
|
s := &OpenAIGatewayService{}
|
|
account := newCodexModelsTestAccount()
|
|
|
|
_, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 1, calls.Load())
|
|
|
|
// 令牌刷新后缓存键变化,下一次拉取视为未命中并同步请求上游。
|
|
account.Credentials["access_token"] = "rotated-access-token"
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(manifest.Body), `"models"`)
|
|
require.EqualValues(t, 2, calls.Load())
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestOAuthSharedAcrossGroupsWithIndependentFiltering(t *testing.T) {
|
|
started := make(chan struct{})
|
|
var startedOnce sync.Once
|
|
release := make(chan struct{})
|
|
calls := &atomic.Int32{}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
calls.Add(1)
|
|
startedOnce.Do(func() { close(started) })
|
|
<-release
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"models":[{"slug":"model-a"},{"slug":"model-b"}]}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
t.Cleanup(func() { chatgptCodexModelsURL = original })
|
|
|
|
s := &OpenAIGatewayService{
|
|
accountRepo: codexModelsVisibilityAccountRepo{
|
|
byGroup: map[int64][]Account{
|
|
91: {},
|
|
92: {},
|
|
},
|
|
},
|
|
}
|
|
account := newCodexModelsTestAccount()
|
|
groupA := &Group{ID: 91, Platform: PlatformOpenAI, ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"model-a"}}}
|
|
groupB := &Group{ID: 92, Platform: PlatformOpenAI, ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"model-b"}}}
|
|
|
|
begin := make(chan struct{})
|
|
type result struct {
|
|
slugs []string
|
|
err error
|
|
}
|
|
results := make(chan result, 2)
|
|
for _, group := range []*Group{groupA, groupB} {
|
|
go func(g *Group) {
|
|
<-begin
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
|
|
if err == nil {
|
|
err = s.MergeGroupConfiguredCodexModels(context.Background(), g, manifest, "")
|
|
}
|
|
slugs := []string{}
|
|
if err == nil {
|
|
slugs = codexManifestModelSlugs(t, manifest.Body)
|
|
}
|
|
results <- result{slugs: slugs, err: err}
|
|
}(group)
|
|
}
|
|
close(begin)
|
|
select {
|
|
case <-started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("upstream request did not start")
|
|
}
|
|
close(release)
|
|
|
|
got := map[int64][]string{91: nil, 92: nil}
|
|
for i := 0; i < 2; i++ {
|
|
r := <-results
|
|
require.NoError(t, r.err)
|
|
if len(r.slugs) == 1 && r.slugs[0] == "model-a" {
|
|
got[91] = r.slugs
|
|
} else if len(r.slugs) == 1 && r.slugs[0] == "model-b" {
|
|
got[92] = r.slugs
|
|
} else {
|
|
t.Fatalf("unexpected filtered slugs: %v", r.slugs)
|
|
}
|
|
}
|
|
require.Equal(t, []string{"model-a"}, got[91])
|
|
require.Equal(t, []string{"model-b"}, got[92])
|
|
require.EqualValues(t, 1, calls.Load(), "同一账号两个分组同时请求时只发一次上游请求")
|
|
}
|
|
|
|
// Scenario: 非 OpenAI GPT 模型的 Codex 提示词不声称自己是 GPT。
|
|
// Antigravity(Google) 对「Codex 提示词 + GPT-5 身份」直接回 429 RESOURCE_EXHAUSTED。
|
|
func TestBuildCodexModelsManifestStripsGPTIdentityForNonGPTModels(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
nonGPT := []string{
|
|
"gemini-3-flash", "claude-sonnet-4-6", "deepseek-v4-flash", "grok-4.3",
|
|
"gpt-oss-120b-medium", "company-coding-model", "company-codex-alias",
|
|
}
|
|
body, err := BuildCodexModelsManifest(nonGPT)
|
|
require.NoError(t, err)
|
|
|
|
var manifest struct {
|
|
Models []struct {
|
|
Slug string `json:"slug"`
|
|
ModelMessages struct {
|
|
InstructionsTemplate string `json:"instructions_template"`
|
|
} `json:"model_messages"`
|
|
} `json:"models"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(body, &manifest))
|
|
require.Len(t, manifest.Models, len(nonGPT))
|
|
for _, m := range manifest.Models {
|
|
tmpl := m.ModelMessages.InstructionsTemplate
|
|
require.NotContains(t, tmpl, "GPT-", m.Slug)
|
|
require.True(t, strings.HasPrefix(tmpl, "You are Codex"), "%s: %.60q", m.Slug, tmpl)
|
|
// Only the identity clause is removed; the rest of the bundled prompt is kept.
|
|
base := openai.CodexBaseInstructionsForModel(m.Slug)
|
|
require.Less(t, len(base)-len(tmpl), 32, m.Slug)
|
|
}
|
|
}
|
|
|
|
// Scenario: OpenAI GPT 模型的 Codex 提示词保持原样。
|
|
func TestBuildCodexModelsManifestKeepsGPTIdentityForGPTModels(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, model := range []string{"gpt-5.5", "gpt-6-astra", "gpt-5.2", "gpt-5.3-codex-spark"} {
|
|
require.Equal(t, openai.CodexBaseInstructionsForModel(model), codexInstructionsTemplateForModel(model), model)
|
|
}
|
|
}
|
|
|
|
// Scenario: 每份内置 Codex 提示词的身份声明都能被识别并去掉。
|
|
func TestCodexGPTIdentityPatternsCoverBundledPrompts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, model := range []string{"gpt-5.1", "gpt-5.2", "gpt-5.5", "gpt-6-astra", "gpt-5-codex"} {
|
|
tmpl := openai.CodexBaseInstructionsForModel(model)
|
|
require.Contains(t, tmpl, "GPT-", "bundled prompt for %s changed; update this test", model)
|
|
for _, p := range codexGPTIdentityPatterns {
|
|
tmpl = p.re.ReplaceAllString(tmpl, p.repl)
|
|
}
|
|
require.NotContains(t, tmpl, "GPT-", model)
|
|
require.True(t, strings.HasPrefix(tmpl, "You are Codex"), "%s: %.60q", model, tmpl)
|
|
}
|
|
}
|
|
|
|
func TestGPT6SolLunaCatalogKeepsAuthoritativeCapabilities(t *testing.T) {
|
|
for _, id := range []string{"gpt-6-sol", "gpt-6-luna"} {
|
|
svc := &OpenAIGatewayService{}
|
|
manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"` + id + `","supported_reasoning_levels":[{"effort":"ultra"}],"default_reasoning_level":"ultra","multi_agent_reasoning_effort":"xhigh","service_tiers":[{"id":"ultrafast"}],"context_window":300000,"max_context_window":900000,"supports_search_tool":false,"apply_patch_tool_type":null}]}`)}
|
|
account := newCodexModelsAPIKeyTestAccount("https://api.openai.com/v1")
|
|
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
|
|
models := decodeCodexManifestModels(t, manifest.Body)
|
|
require.Len(t, models, 1)
|
|
require.Equal(t, []string{"ultra"}, effortsFromManifestModel(t, models[0]))
|
|
require.Equal(t, "ultra", models[0]["default_reasoning_level"])
|
|
require.Equal(t, "xhigh", models[0]["multi_agent_reasoning_effort"])
|
|
require.Equal(t, float64(300000), models[0]["context_window"])
|
|
require.Equal(t, float64(900000), models[0]["max_context_window"])
|
|
require.Equal(t, []any{map[string]any{"id": "ultrafast"}}, models[0]["service_tiers"])
|
|
require.Equal(t, false, models[0]["supports_search_tool"])
|
|
require.Contains(t, models[0], "apply_patch_tool_type")
|
|
require.Nil(t, models[0]["apply_patch_tool_type"])
|
|
}
|
|
}
|