Files
sub2api/backend/internal/service/openai_codex_models_service.go
T

2550 lines
85 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"golang.org/x/net/http2"
"golang.org/x/sync/singleflight"
)
// chatgptCodexModelsURL is the ChatGPT Codex models manifest endpoint.
// Package-level variable so tests can point it at a stub server.
var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models"
const (
openAIModelsCacheBodyLimit = 1 << 20
// openAIModelsCacheMaxEntries 上限按「账号数 × 客户端版本数 × 代理形态」
// 估算:缓存同时覆盖 OAuth 与 API Key 账号,且缓存键含 Authorization 与
// Version 头,不同客户端版本各自占一条;64 条在大规模部署下会被淘汰导致
// 额外上游请求。单条清单通常几十 KB,512 条最坏内存占用在几十 MB 量级。
openAIModelsCacheMaxEntries = 512
// 三段时效:≤TTL 为新鲜(直接返回缓存,零上游请求);TTL 到 StaleTTL 之间
// 乐观返回旧值并后台单飞刷新(携带上游 ETag,304 续期);超过 StaleTTL 丢弃
// 缓存同步等待刷新。TTL 取 1 分钟:manifest 变化低频,1 分钟内同账号重复
// 请求完全吸收;StaleTTL 取 5 分钟,控制旧内容最长可见时间在分钟级。
openAIModelsCacheTTL = 60 * time.Second
openAIModelsCacheStaleTTL = 5 * time.Minute
codexModelsManifestRequestTimeout = 15 * time.Second
codexAutoModelPrefix = "codex-auto-"
)
// FilterCodexModelIDsForGroup removes dedicated media-generation models,
// wildcard mapping keys, and Codex automatic modes from a client catalog.
// Automatic modes are retained only when the group's enabled custom model list
// explicitly selects the exact slug; account model mappings describe routing
// and are not feature opt-ins. Wildcard keys such as "foo-*" are routing
// patterns, not concrete Codex models.
func FilterCodexModelIDsForGroup(modelIDs []string, group *Group) []string {
explicitlyEnabled := make(map[string]struct{})
if group != nil && group.CustomModelsListEnabled() {
for _, modelID := range group.ModelsListConfig.Models {
modelID = strings.TrimSpace(modelID)
if strings.HasPrefix(modelID, codexAutoModelPrefix) {
explicitlyEnabled[modelID] = struct{}{}
}
}
}
filtered := make([]string, 0, len(modelIDs))
for _, modelID := range modelIDs {
modelID = strings.TrimSpace(modelID)
if modelID == "" {
continue
}
if isCodexDedicatedMediaModel(modelID) {
continue
}
if strings.Contains(modelID, "*") {
continue
}
if strings.HasPrefix(modelID, codexAutoModelPrefix) {
if _, ok := explicitlyEnabled[modelID]; !ok {
continue
}
}
filtered = append(filtered, modelID)
}
return filtered
}
func isCodexDedicatedMediaModel(modelID string) bool {
canonical := codexProviderQualifiedModelID(modelID)
return IsGPTImageGenerationModel(canonical) ||
isImageGenerationModel(canonical) ||
xai.IsGrokImagineModel(modelID)
}
func codexProviderQualifiedModelID(modelID string) string {
modelID = strings.TrimSpace(modelID)
if slash := strings.LastIndexByte(modelID, '/'); slash >= 0 {
modelID = strings.TrimSpace(modelID[slash+1:])
}
return strings.TrimPrefix(modelID, "models/")
}
// OpenAIModelsResponse carries either an OpenAI model list or a Codex manifest
// together with upstream and client caching metadata.
type OpenAIModelsResponse struct {
Body []byte
ETag string
upstreamETag string
upstreamSourceBody []byte
convertedFromOpenAIModelList bool
NotModified bool
}
// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog exclusively
// from the public model names configured on accounts in an OpenAI group. The
// boolean result distinguishes "no explicit configuration" from a configured
// catalog that becomes empty after group-level filtering.
func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest(
ctx context.Context,
group *Group,
ifNoneMatch string,
) (*OpenAIModelsResponse, bool, error) {
if s == nil || s.accountRepo == nil || group == nil || group.Platform != PlatformOpenAI {
return nil, false, nil
}
visible, catalog, err := loadCodexGroupCatalogAccounts(ctx, s.accountRepo, group.ID)
if err != nil {
return nil, false, fmt.Errorf("load group configured Codex models: %w", err)
}
configuredModels := openAIConfiguredCodexModelIDsForGroup(visible, group)
if len(configuredModels) == 0 {
return nil, false, nil
}
body, err := buildCodexModelsManifestForAccounts(
PlatformOpenAI,
configuredModels,
catalog,
nil,
true,
)
if err != nil {
return nil, false, fmt.Errorf("initialize group configured Codex models: %w", err)
}
body, _, err = mergeConfiguredCodexModelsManifest(
body,
nil,
group.ModelsListConfig.Models,
group.CustomModelsListEnabled(),
)
if err != nil {
return nil, false, fmt.Errorf("build group configured Codex models: %w", err)
}
manifest := &OpenAIModelsResponse{
Body: body,
ETag: codexModelsManifestBodyETag(body),
}
if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) {
manifest.Body = nil
manifest.NotModified = true
}
return manifest, true, nil
}
// MergeGroupConfiguredCodexModels adds account model aliases that are visible
// to the authenticated OpenAI group without discarding metadata from upstream
// Codex model entries. A group's custom models list also filters the picker,
// matching the standard /v1/models display policy.
func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels(
ctx context.Context,
group *Group,
manifest *OpenAIModelsResponse,
ifNoneMatch string,
) error {
if s == nil || s.accountRepo == nil || group == nil || manifest == nil || manifest.NotModified {
return nil
}
if group.Platform != PlatformOpenAI || len(manifest.Body) == 0 {
return nil
}
var configuredModels []string
if !group.CodexModelsManifestConfig.Enabled {
var err error
configuredModels, err = s.groupConfiguredCodexModelIDs(ctx, group)
if err != nil {
return fmt.Errorf("load group configured Codex models: %w", err)
}
}
body, changed, err := mergeConfiguredCodexModelsManifest(
manifest.Body,
configuredModels,
group.ModelsListConfig.Models,
group.CustomModelsListEnabled(),
)
if err != nil {
return fmt.Errorf("merge group configured Codex models: %w", err)
}
if group.CodexModelsManifestConfig.Enabled && group.CustomModelsListEnabled() {
body, err = orderPinnedCodexModelsBySelection(body, group.ModelsListConfig.Models)
if err != nil {
return fmt.Errorf("order pinned Codex models: %w", err)
}
changed = true
}
if changed {
manifest.Body = body
manifest.ETag = codexModelsManifestBodyETag(body)
}
if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) {
manifest.Body = nil
manifest.NotModified = true
}
return nil
}
func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context, group *Group) ([]string, error) {
if group == nil {
return nil, nil
}
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, group.ID)
if err != nil {
return nil, err
}
return openAIConfiguredCodexModelIDsForGroup(accounts, group), nil
}
// loadCodexGroupCatalogAccounts separates picker membership from capability
// intersection. visible accounts are currently schedulable and decide which
// public aliases appear. catalog accounts are persistently enabled group
// members; the availability query ignores transient rate-limit, overload, and
// temporary-unschedulable state so those conditions cannot widen advertised
// capabilities. Persistently disabled accounts are excluded because routing
// cannot select them. If the availability query fails, the catalog falls back
// to the schedulable set so a listing error does not fail the client request.
func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository, groupID int64) (visible []Account, catalog []Account, err error) {
if repo == nil {
return nil, nil, nil
}
visible, err = repo.ListSchedulableByGroupID(ctx, groupID)
if err != nil {
return nil, nil, err
}
catalog = visible
groupAccounts, listErr := repo.ListModelAvailabilityCandidates(
ctx,
&groupID,
[]string{
PlatformAnthropic,
PlatformOpenAI,
PlatformGemini,
PlatformAntigravity,
PlatformGrok,
PlatformKimi,
PlatformZhipu,
PlatformDeepseek,
},
false,
)
if listErr != nil {
return visible, catalog, nil
}
return visible, groupAccounts, nil
}
func openAIConfiguredCodexModelIDs(accounts []Account) []string {
seen := make(map[string]struct{})
models := make([]string, 0)
for i := range accounts {
account := &accounts[i]
if account.Platform != PlatformOpenAI {
continue
}
for modelID := range account.GetModelMapping() {
modelID = strings.TrimSpace(modelID)
if modelID == "" || strings.Contains(modelID, "*") {
continue
}
if _, exists := seen[modelID]; exists {
continue
}
seen[modelID] = struct{}{}
models = append(models, modelID)
}
}
sort.Strings(models)
return models
}
func openAIConfiguredCodexModelIDsForGroup(accounts []Account, group *Group) []string {
models := openAIConfiguredCodexModelIDs(accounts)
if group == nil || !group.CustomModelsListEnabled() {
return models
}
seen := make(map[string]struct{}, len(models)+len(group.ModelsListConfig.Models))
for _, modelID := range models {
seen[modelID] = struct{}{}
}
for _, selectedModel := range group.ModelsListConfig.Models {
selectedModel = strings.TrimSpace(selectedModel)
if selectedModel == "" || strings.Contains(selectedModel, "*") {
continue
}
for i := range accounts {
account := &accounts[i]
if account.Platform != PlatformOpenAI {
continue
}
mappedModel, matched := account.ResolveMappedModel(selectedModel)
if !matched || strings.TrimSpace(mappedModel) == "" {
continue
}
if _, exists := seen[selectedModel]; !exists {
seen[selectedModel] = struct{}{}
models = append(models, selectedModel)
}
break
}
}
sort.Strings(models)
return models
}
const (
configuredCodexModelPriority = 50
configuredCodexCustomDescription = "Custom model routed through Sub2API."
configuredCodexFallbackContext = 272_000
configuredCodexDeepSeekV4Context = 1_000_000
configuredCodexGrokContext = 500_000
configuredCodexGrokBuildContext = 256_000
configuredCodexGPT56MaxContext = 872_000
configuredCodexGPT6AstraContext = 1_050_000
configuredCodexToolOutputMaxTokens = 10_000
)
type configuredCodexReasoningLevel struct {
Effort string `json:"effort"`
Description string `json:"description"`
}
type configuredCodexTruncationPolicy struct {
Mode string `json:"mode"`
Limit int64 `json:"limit"`
}
type configuredCodexServiceTier struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
type configuredCodexModelMessages struct {
InstructionsTemplate string `json:"instructions_template"`
InstructionsVariables any `json:"instructions_variables"`
Approvals any `json:"approvals"`
CollaborationModes any `json:"collaboration_modes"`
AutoReview any `json:"auto_review"`
Permissions any `json:"permissions"`
MultiAgent any `json:"multi_agent"`
TokenBudget any `json:"token_budget"`
GuardianV2 any `json:"guardian_v2"`
}
// configuredCodexModelDescriptor is the minimum complete ModelInfo contract
// understood by current Codex clients. Several nullable fields are intentionally
// emitted: unlike ordinary OpenAI /v1/models entries, the Codex manifest parser
// requires them to be present.
type configuredCodexModelDescriptor struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
DefaultReasoningLevel *string `json:"default_reasoning_level,omitempty"`
SupportedReasoningLevels []configuredCodexReasoningLevel `json:"supported_reasoning_levels"`
ShellType string `json:"shell_type"`
Visibility string `json:"visibility"`
SupportedInAPI bool `json:"supported_in_api"`
Priority int `json:"priority"`
AdditionalSpeedTiers []string `json:"additional_speed_tiers"`
ServiceTiers []configuredCodexServiceTier `json:"service_tiers"`
DefaultServiceTier any `json:"default_service_tier"`
AvailabilityNUX any `json:"availability_nux"`
Upgrade any `json:"upgrade"`
ModelMessages configuredCodexModelMessages `json:"model_messages"`
IncludeSkillsUsageInstructions bool `json:"include_skills_usage_instructions"`
IncludePluginUsageInstructions bool `json:"include_plugin_usage_instructions"`
IncludeAppsUsageInstructions bool `json:"include_apps_usage_instructions"`
SupportsReasoningSummaryParameter bool `json:"supports_reasoning_summary_parameter"`
DefaultReasoningSummary string `json:"default_reasoning_summary"`
SupportVerbosity bool `json:"support_verbosity"`
DefaultVerbosity *string `json:"default_verbosity"`
ApplyPatchToolType *string `json:"apply_patch_tool_type"`
WebSearchToolType string `json:"web_search_tool_type"`
TruncationPolicy configuredCodexTruncationPolicy `json:"truncation_policy"`
SupportsImageDetailOriginal bool `json:"supports_image_detail_original"`
SupportsParallelToolCalls bool `json:"supports_parallel_tool_calls"`
ContextWindow int64 `json:"context_window"`
MaxContextWindow int64 `json:"max_context_window"`
AutoCompactTokenLimit any `json:"auto_compact_token_limit"`
CompHash any `json:"comp_hash"`
EffectiveContextWindowPercent int64 `json:"effective_context_window_percent"`
ExperimentalSupportedTools []string `json:"experimental_supported_tools"`
InputModalities []string `json:"input_modalities"`
SupportsSearchTool bool `json:"supports_search_tool"`
UseResponsesLite bool `json:"use_responses_lite"`
NodeREPLAutoReviewRequired bool `json:"node_repl_auto_review_required"`
NodeREPLDisabled bool `json:"node_repl_disabled"`
AutoReviewModelOverride any `json:"auto_review_model_override"`
ModelSpecialty any `json:"model_specialty"`
ToolMode any `json:"tool_mode"`
MultiAgentVersion any `json:"multi_agent_version"`
}
type codexModelMetadataOverride struct {
UpstreamModelMetadata
reasoningConflict bool
inputModalitiesConflict bool
}
func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescriptor {
modelID = strings.TrimSpace(modelID)
noReasoningLevel := "none"
descriptor := configuredCodexModelDescriptor{
Slug: modelID,
DisplayName: modelID,
Description: configuredCodexCustomDescription,
DefaultReasoningLevel: &noReasoningLevel,
SupportedReasoningLevels: []configuredCodexReasoningLevel{
{Effort: "none", Description: configuredCodexReasoningLevelDescription("none")},
},
ShellType: "unified_exec",
Visibility: "list",
SupportedInAPI: true,
Priority: configuredCodexModelPriority,
AdditionalSpeedTiers: []string{},
ServiceTiers: []configuredCodexServiceTier{},
ModelMessages: configuredCodexModelMessages{InstructionsTemplate: openai.CodexBaseInstructionsForModel(modelID)},
SupportsReasoningSummaryParameter: true,
DefaultReasoningSummary: "auto",
WebSearchToolType: "text",
TruncationPolicy: configuredCodexTruncationPolicy{Mode: "bytes", Limit: configuredCodexToolOutputMaxTokens},
ContextWindow: configuredCodexFallbackContext,
MaxContextWindow: configuredCodexFallbackContext,
EffectiveContextWindowPercent: 95,
ExperimentalSupportedTools: []string{},
InputModalities: []string{"text"},
}
if isDeepSeekCodexModel(modelID) {
defaultReasoningLevel := "high"
descriptor.DisplayName = deepSeekCodexDisplayName(modelID)
descriptor.Description = "DeepSeek coding and reasoning model routed through Sub2API."
descriptor.DefaultReasoningLevel = &defaultReasoningLevel
descriptor.SupportedReasoningLevels = []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"},
}
descriptor.SupportsParallelToolCalls = true
descriptor.ContextWindow = configuredCodexDeepSeekV4Context
descriptor.MaxContextWindow = configuredCodexDeepSeekV4Context
}
if isGrokCodexModel(modelID) {
descriptor.DisplayName = grokCodexDisplayName(modelID)
descriptor.Description = "Grok coding and reasoning model routed through Sub2API."
descriptor.SupportsParallelToolCalls = true
descriptor.ContextWindow = grokCodexContextWindow(modelID)
descriptor.MaxContextWindow = descriptor.ContextWindow
if grokCodexSupportsReasoningEffort(modelID) {
defaultReasoningLevel := "high"
descriptor.DefaultReasoningLevel = &defaultReasoningLevel
descriptor.SupportedReasoningLevels = configuredCodexGrokReasoningLevels(modelID)
}
}
if isClaudeCodexModel(modelID) {
descriptor.DisplayName = claudeCodexDisplayName(modelID)
descriptor.Description = "Claude coding and reasoning model routed through Sub2API."
descriptor.SupportsParallelToolCalls = true
if levels := configuredCodexClaudeReasoningLevels(modelID); len(levels) > 0 {
defaultReasoningLevel := claudeCodexDefaultReasoningLevel(levels)
descriptor.DefaultReasoningLevel = &defaultReasoningLevel
descriptor.SupportedReasoningLevels = levels
}
}
if isOpenAICodexGPTModel(modelID) {
descriptor.DisplayName = openaiCodexDisplayName(modelID)
descriptor.Description = "OpenAI GPT coding model routed through Sub2API."
descriptor.SupportsParallelToolCalls = true
descriptor.ServiceTiers = configuredCodexServiceTiersForModel(modelID)
if isOpenAICodexReasoningGPTModel(modelID) {
defaultReasoningLevel := "medium"
if getNormalizedCodexModel(modelID) == "gpt-5.6-sol" {
defaultReasoningLevel = "low"
}
descriptor.DefaultReasoningLevel = &defaultReasoningLevel
descriptor.SupportedReasoningLevels = configuredCodexGPTReasoningLevels(modelID)
descriptor.DefaultReasoningSummary = "none"
descriptor.TruncationPolicy = configuredCodexTruncationPolicy{Mode: "tokens", Limit: configuredCodexToolOutputMaxTokens}
if isOpenAIGPT56Model(modelID) {
descriptor.MaxContextWindow = configuredCodexGPT56MaxContext
}
if isOpenAIGPT6AstraModel(modelID) {
descriptor.ContextWindow = configuredCodexGPT6AstraContext
descriptor.MaxContextWindow = configuredCodexGPT6AstraContext
}
}
if SupportsVerbosity(modelID) {
defaultVerbosity := "low"
descriptor.SupportVerbosity = true
descriptor.DefaultVerbosity = &defaultVerbosity
}
}
return descriptor
}
func configuredCodexServiceTiersForModel(modelID string) []configuredCodexServiceTier {
tiers := make([]configuredCodexServiceTier, 0, 2)
if configuredCodexSupportsPriorityServiceTier(modelID) {
tiers = append(tiers, configuredCodexServiceTier{
ID: OpenAIFastTierPriority,
Name: "Fast",
Description: "Priority processing for lower latency.",
})
}
if configuredCodexSupportsUltrafastServiceTier(modelID) {
tiers = append(tiers, configuredCodexServiceTier{
ID: OpenAIFastTierUltrafast,
Name: "Ultrafast",
Description: "Ultra-low latency processing.",
})
}
return tiers
}
func configuredCodexSupportsPriorityServiceTier(modelID string) bool {
normalized := canonicalizeOpenAIModelAliasSpelling(modelID)
for _, family := range []string{"gpt-5.4", "gpt-5.5", "gpt-5.6"} {
if normalized == family || strings.HasPrefix(normalized, family+"-") {
return true
}
}
// GPT-6 Astra advertises Fast via service_tier=priority in public model metadata.
return isOpenAIGPT6AstraModel(modelID)
}
func configuredCodexSupportsUltrafastServiceTier(modelID string) bool {
return normalizeKnownOpenAICodexModel(modelID) == "gpt-5.6-sol"
}
func configuredCodexGrokReasoningLevels(modelID string) []configuredCodexReasoningLevel {
levels := []configuredCodexReasoningLevel{
{Effort: "low", Description: "Fast responses with lighter reasoning"},
{Effort: "medium", Description: "Balanced reasoning for most coding tasks"},
{Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"},
}
if GrokSupportsXHighReasoningEffort(modelID) {
levels = append(levels, configuredCodexReasoningLevel{
Effort: "xhigh",
Description: "Extra-high reasoning depth for difficult tasks",
})
}
return levels
}
func configuredCodexClaudeReasoningLevels(modelID string) []configuredCodexReasoningLevel {
descriptions := map[string]string{
"low": "Fast responses with lighter reasoning",
"medium": "Balanced reasoning for most coding tasks",
"high": "Greater reasoning depth for coding and agent tasks",
"xhigh": "Extra-high reasoning depth for difficult tasks",
"max": "Maximum reasoning depth for complex tasks",
}
levels := claude.EffortLevelsForModel(modelID)
out := make([]configuredCodexReasoningLevel, 0, len(levels))
for _, effort := range levels {
out = append(out, configuredCodexReasoningLevel{
Effort: effort,
Description: descriptions[effort],
})
}
return out
}
func claudeCodexDefaultReasoningLevel(levels []configuredCodexReasoningLevel) string {
for _, preferred := range []string{"medium", "high", "low"} {
for _, level := range levels {
if level.Effort == preferred {
return preferred
}
}
}
if len(levels) == 0 {
return ""
}
return levels[0].Effort
}
func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasoningLevel {
levels := []configuredCodexReasoningLevel{
{Effort: "low", Description: "Fast responses with lighter reasoning"},
{Effort: "medium", Description: "Balanced reasoning for most coding tasks"},
{Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"},
{Effort: "xhigh", Description: "Extra-high reasoning depth for difficult tasks"},
}
normalized := getNormalizedCodexModel(modelID)
if isOpenAIGPT56Model(modelID) || isOpenAIGPT6AstraModel(modelID) {
levels = append(levels, configuredCodexReasoningLevel{
Effort: "max",
Description: "Maximum reasoning depth for complex tasks",
})
}
if normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" {
levels = append(levels, configuredCodexReasoningLevel{
Effort: "ultra",
Description: "Maximum reasoning with automatic task delegation",
})
}
return levels
}
func isOpenAICodexGPTModel(modelID string) bool {
normalized := canonicalizeOpenAIModelAliasSpelling(modelID)
if normalized == "" || strings.HasPrefix(normalized, "gpt-image") {
return false
}
return strings.HasPrefix(normalized, "gpt-")
}
func isOpenAICodexReasoningGPTModel(modelID string) bool {
normalized := canonicalizeOpenAIModelAliasSpelling(modelID)
return isOpenAIGPT6AstraModel(normalized) || strings.HasPrefix(normalized, "gpt-5")
}
func isOpenAICodexImageInputModel(modelID string) bool {
normalized := canonicalizeOpenAIModelAliasSpelling(modelID)
return isOpenAIGPT6AstraModel(normalized) ||
strings.HasPrefix(normalized, "gpt-5") ||
strings.HasPrefix(normalized, "gpt-4o") ||
strings.HasPrefix(normalized, "gpt-4.1") ||
strings.HasPrefix(normalized, "gpt-4.5") ||
strings.HasPrefix(normalized, "gpt-4-turbo") ||
strings.HasPrefix(normalized, "gpt-4-vision")
}
func isOfficialOpenAICodexCatalogModel(modelID string) bool {
normalized := strings.ToLower(codexProviderQualifiedModelID(modelID))
if normalized == "" || isCodexDedicatedMediaModel(normalized) {
return false
}
if strings.HasPrefix(normalized, "codex-") {
return true
}
if strings.HasPrefix(normalized, "o1") || strings.HasPrefix(normalized, "o3") || strings.HasPrefix(normalized, "o4") {
return true
}
if !strings.HasPrefix(normalized, "gpt-") {
return false
}
for _, incompatibleFamily := range []string{"audio", "realtime", "transcribe", "tts"} {
if strings.Contains(normalized, incompatibleFamily) {
return false
}
}
return true
}
func openaiCodexDisplayName(modelID string) string {
normalized := canonicalizeOpenAIModelAliasSpelling(modelID)
if normalized == "" {
return modelID
}
for _, model := range openai.DefaultModels {
if strings.EqualFold(model.ID, normalized) && strings.TrimSpace(model.DisplayName) != "" {
return model.DisplayName
}
}
return modelID
}
func deepSeekCodexDisplayName(modelID string) string {
switch strings.ToLower(strings.TrimSpace(modelID)) {
case "deepseek-v4-pro", "deepseek-4-pro":
return "DeepSeek V4 Pro"
case "deepseek-v4-flash", "deepseek-4-flash":
return "DeepSeek V4 Flash"
default:
return modelID
}
}
func isDeepSeekCodexModel(modelID string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelID)), "deepseek-")
}
func isGrokCodexModel(modelID string) bool {
return xai.IsGrokModelID(modelID)
}
func grokCodexSupportsReasoningEffort(modelID string) bool {
if grokSupportsReasoningEffort(modelID) {
return true
}
canonical := xai.ResolveGrokTextResponsesModelID(modelID)
if canonical == "" || strings.EqualFold(canonical, modelID) {
return false
}
return grokSupportsReasoningEffort(canonical)
}
func grokCodexDisplayName(modelID string) string {
normalized := strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(modelID)))
if normalized == "" {
return modelID
}
if name := grokDefaultDisplayName(normalized); name != "" {
return name
}
canonical := strings.ToLower(xai.ResolveGrokTextResponsesModelID(normalized))
if canonical != "" && canonical != normalized {
if name := grokDefaultDisplayName(canonical); name != "" {
return name
}
}
return modelID
}
func grokDefaultDisplayName(modelID string) string {
for _, model := range xai.DefaultModels() {
if model.ID == modelID {
return strings.TrimSpace(model.DisplayName)
}
}
return ""
}
func grokCodexContextWindow(modelID string) int64 {
normalized := strings.ToLower(xai.StripGrokProviderPrefix(strings.TrimSpace(modelID)))
if strings.HasPrefix(normalized, "grok-build") {
return configuredCodexGrokBuildContext
}
return configuredCodexGrokContext
}
func isClaudeCodexModel(modelID string) bool {
platform, detected := DetectModelPlatform(modelID)
return detected && platform == PlatformAnthropic
}
func claudeCodexDisplayName(modelID string) string {
normalized := strings.ToLower(codexProviderQualifiedModelID(modelID))
normalized = strings.TrimPrefix(normalized, "anthropic.")
if normalized == "" {
return modelID
}
for _, model := range claude.DefaultModels {
if strings.EqualFold(model.ID, normalized) && strings.TrimSpace(model.DisplayName) != "" {
return model.DisplayName
}
}
if canonical, ok := claude.ModelIDOverrides[normalized]; ok {
for _, model := range claude.DefaultModels {
if model.ID == canonical && strings.TrimSpace(model.DisplayName) != "" {
return model.DisplayName
}
}
}
return modelID
}
// BuildCodexModelsManifest builds a standalone Codex model catalog for models
// routed through a custom provider. The response is also suitable for saving
// as model_catalog_json in clients that do not refresh custom-provider catalogs.
func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) {
return buildCodexModelsManifest(modelIDs, nil, nil, nil, nil)
}
// BuildCodexModelsManifestForGroup derives input capabilities from the
// concrete Responses route and group accounts behind a group. Unknown or mixed
// capabilities fail closed to the text-only descriptor used by the standalone
// builder. Caller-supplied model IDs still decide which slugs appear; advertised
// capabilities intersect all active group members that map the alias, including
// accounts that are not currently schedulable.
func (s *GatewayService) BuildCodexModelsManifestForGroup(
ctx context.Context,
group *Group,
platformOverride string,
modelIDs []string,
) ([]byte, error) {
if s == nil || s.accountRepo == nil || group == nil {
return BuildCodexModelsManifest(modelIDs)
}
effectivePlatform := strings.TrimSpace(platformOverride)
if effectivePlatform == "" {
effectivePlatform = group.Platform
}
if effectivePlatform != PlatformComposite && !isConcreteRequestPlatform(effectivePlatform) {
return BuildCodexModelsManifest(modelIDs)
}
_, catalog, err := loadCodexGroupCatalogAccounts(ctx, s.accountRepo, group.ID)
if err != nil {
return BuildCodexModelsManifest(modelIDs)
}
var compositeRoutes []CompositeModelRoute
compositeRoutesAvailable := true
if effectivePlatform == PlatformComposite && s.compositeResolver != nil && s.compositeResolver.repo != nil {
compositeRoutes, err = s.compositeResolver.repo.ListByGroup(ctx, group.ID, false)
if err != nil {
compositeRoutesAvailable = false
}
}
return buildCodexModelsManifestForAccounts(
effectivePlatform,
modelIDs,
catalog,
compositeRoutes,
compositeRoutesAvailable,
)
}
func buildCodexModelsManifestForAccounts(
effectivePlatform string,
modelIDs []string,
accounts []Account,
compositeRoutes []CompositeModelRoute,
compositeRoutesAvailable bool,
) ([]byte, error) {
imageInputModels := make(map[string]bool, len(modelIDs))
searchToolModels := make(map[string]bool, len(modelIDs))
metadataModels := codexCatalogMetadataModels(
effectivePlatform,
modelIDs,
accounts,
compositeRoutes,
compositeRoutesAvailable,
)
modelMetadata := make(map[string]codexModelMetadataOverride, len(modelIDs))
for _, modelID := range modelIDs {
modelID = strings.TrimSpace(modelID)
if groupCodexModelSupportsImageInput(
effectivePlatform,
modelID,
accounts,
compositeRoutes,
compositeRoutesAvailable,
) {
imageInputModels[modelID] = true
}
if groupCodexModelSupportsSearchTool(
effectivePlatform,
modelID,
accounts,
compositeRoutes,
compositeRoutesAvailable,
) {
searchToolModels[modelID] = true
}
if metadata, ok := groupCodexModelMetadata(
effectivePlatform,
modelID,
accounts,
compositeRoutes,
compositeRoutesAvailable,
); ok {
modelMetadata[modelID] = metadata
}
}
return buildCodexModelsManifest(modelIDs, imageInputModels, searchToolModels, metadataModels, modelMetadata)
}
func buildCodexModelsManifest(
modelIDs []string,
imageInputModels map[string]bool,
searchToolModels map[string]bool,
metadataModels map[string]string,
modelMetadata map[string]codexModelMetadataOverride,
) ([]byte, error) {
seen := make(map[string]struct{}, len(modelIDs))
models := make([]json.RawMessage, 0, len(modelIDs))
for _, modelID := range modelIDs {
modelID = strings.TrimSpace(modelID)
if modelID == "" {
continue
}
if _, exists := seen[modelID]; exists {
continue
}
metadataModelID := strings.TrimSpace(metadataModels[modelID])
if metadataModelID == "" {
metadataModelID = modelID
}
if isCodexDedicatedMediaModel(modelID) || isCodexDedicatedMediaModel(metadataModelID) {
continue
}
seen[modelID] = struct{}{}
descriptor := newConfiguredCodexModelDescriptor(metadataModelID)
descriptor.Slug = modelID
if imageInputModels[modelID] {
descriptor.InputModalities = []string{"text", "image"}
}
descriptor.SupportsSearchTool = searchToolModels[modelID]
if metadata, ok := modelMetadata[modelID]; ok {
applyUpstreamModelMetadataToCodexDescriptor(&descriptor, metadata)
}
if metadataModelID != modelID {
descriptor.DisplayName = modelID
descriptor.Description = configuredCodexCustomDescription
}
encoded, err := json.Marshal(descriptor)
if err != nil {
return nil, err
}
if capabilities := modelMetadata[modelID].CodexToolCapabilities; len(capabilities) > 0 {
var fields map[string]json.RawMessage
if err := json.Unmarshal(encoded, &fields); err != nil {
return nil, err
}
applyCodexToolCapabilities(fields, capabilities, true)
encoded, err = json.Marshal(fields)
if err != nil {
return nil, err
}
}
models = append(models, encoded)
}
return json.Marshal(struct {
Models []json.RawMessage `json:"models"`
}{Models: models})
}
func codexCatalogMetadataModels(
platform string,
modelIDs []string,
accounts []Account,
compositeRoutes []CompositeModelRoute,
compositeRoutesAvailable bool,
) map[string]string {
metadataModels := make(map[string]string, len(modelIDs))
for _, modelID := range modelIDs {
modelID = strings.TrimSpace(modelID)
if modelID == "" {
continue
}
metadataModelID := resolveCodexCatalogMetadataModel(
platform,
modelID,
accounts,
compositeRoutes,
compositeRoutesAvailable,
)
if metadataModelID != "" && metadataModelID != modelID {
metadataModels[modelID] = metadataModelID
}
}
return metadataModels
}
func resolveCodexCatalogMetadataModel(
platform string,
modelID string,
accounts []Account,
compositeRoutes []CompositeModelRoute,
compositeRoutesAvailable bool,
) string {
modelID = strings.TrimSpace(modelID)
if modelID == "" {
return ""
}
if platform == PlatformComposite {
if !compositeRoutesAvailable {
return modelID
}
if route, matched := matchCompositeRoute(compositeRoutes, modelID, CompositeRouteEndpointResponses); matched {
if upstreamModel := strings.TrimSpace(route.UpstreamModel); upstreamModel != "" {
return upstreamModel
}
return modelID
}
if codexCompositeRouteMatchesModel(compositeRoutes, modelID) {
return modelID
}
claimedPlatforms := make(map[string]struct{})
for _, account := range accounts {
accountPlatform := strings.TrimSpace(account.Platform)
if !isConcreteRequestPlatform(accountPlatform) || !codexExplicitModelMappingClaims(account, modelID) {
continue
}
claimedPlatforms[accountPlatform] = struct{}{}
}
if len(claimedPlatforms) > 1 {
return modelID
}
for accountPlatform := range claimedPlatforms {
return uniqueCodexMappedModel(accounts, accountPlatform, modelID)
}
detectedPlatform, detected := DetectModelPlatform(modelID)
if !detected {
return modelID
}
platform = detectedPlatform
}
return uniqueCodexMappedModel(accounts, platform, modelID)
}
func uniqueCodexMappedModel(accounts []Account, platform string, modelID string) string {
targets := make(map[string]struct{})
for i := range accounts {
account := &accounts[i]
if account.Platform != platform {
continue
}
mappedModel, matched := account.ResolveMappedModel(modelID)
mappedModel = strings.TrimSpace(mappedModel)
if !matched || mappedModel == "" {
continue
}
targets[mappedModel] = struct{}{}
}
if len(targets) != 1 {
return modelID
}
for target := range targets {
return target
}
return modelID
}
func groupCodexModelSupportsImageInput(
platform string,
modelID string,
accounts []Account,
compositeRoutes []CompositeModelRoute,
compositeRoutesAvailable bool,
) bool {
modelID = strings.TrimSpace(modelID)
if modelID == "" {
return false
}
upstreamModel := modelID
if platform == PlatformComposite {
var resolved bool
platform, upstreamModel, resolved = resolveCodexCompositeModelTarget(
modelID,
accounts,
compositeRoutes,
compositeRoutesAvailable,
)
if !resolved {
return false
}
}
if platform != PlatformOpenAI && platform != PlatformGrok {
return false
}
candidates := 0
for i := range accounts {
account := &accounts[i]
if account.Platform != platform || !account.IsModelSupported(upstreamModel) {
continue
}
candidates++
if !accountCodexModelSupportsImageInput(account, account.GetMappedModel(upstreamModel)) {
return false
}
}
return candidates > 0
}
// groupCodexModelSupportsSearchTool advertises client-side tool discovery only
// when every account that may serve the model uses the gateway's Responses to
// Chat Completions bridge. Native Responses routes must declare the capability
// in their upstream Codex manifest instead of having it inferred here.
func groupCodexModelSupportsSearchTool(
platform string,
modelID string,
accounts []Account,
compositeRoutes []CompositeModelRoute,
compositeRoutesAvailable bool,
) bool {
modelID = strings.TrimSpace(modelID)
if modelID == "" {
return false
}
upstreamModel := modelID
if platform == PlatformComposite {
var resolved bool
platform, upstreamModel, resolved = resolveCodexCompositeModelTarget(
modelID,
accounts,
compositeRoutes,
compositeRoutesAvailable,
)
if !resolved {
return false
}
}
if platform != PlatformOpenAI {
return false
}
candidates := 0
for i := range accounts {
account := &accounts[i]
if account.Platform != platform || !account.IsModelSupported(upstreamModel) {
continue
}
candidates++
if !shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
return false
}
}
return candidates > 0
}
func resolveCodexCompositeModelTarget(
modelID string,
accounts []Account,
routes []CompositeModelRoute,
routesAvailable bool,
) (string, string, bool) {
if !routesAvailable {
return "", "", false
}
if route, matched := matchCompositeRoute(routes, modelID, CompositeRouteEndpointResponses); matched {
upstreamModel := strings.TrimSpace(route.UpstreamModel)
if upstreamModel == "" {
upstreamModel = modelID
}
return route.TargetPlatform, upstreamModel, true
}
if codexCompositeRouteMatchesModel(routes, modelID) {
return "", "", false
}
claimedPlatforms := make(map[string]struct{})
for _, account := range accounts {
platform := strings.TrimSpace(account.Platform)
if !isConcreteRequestPlatform(platform) || !codexExplicitModelMappingClaims(account, modelID) {
continue
}
claimedPlatforms[platform] = struct{}{}
}
if len(claimedPlatforms) > 1 {
return "", "", false
}
for platform := range claimedPlatforms {
return platform, modelID, true
}
platform, detected := DetectModelPlatform(modelID)
if !detected {
return "", "", false
}
return platform, modelID, true
}
func codexCompositeRouteMatchesModel(routes []CompositeModelRoute, modelID string) bool {
for _, route := range routes {
publicModel := strings.TrimSpace(route.PublicModel)
if publicModel == "" {
continue
}
switch normalizeCompositeRouteMatchType(route.MatchType) {
case CompositeRouteMatchPrefix:
if strings.HasPrefix(modelID, publicModel) {
return true
}
default:
if modelID == publicModel {
return true
}
}
}
return false
}
func codexExplicitModelMappingClaims(account Account, modelID string) bool {
if account.Credentials == nil || strings.TrimSpace(modelID) == "" {
return false
}
mapped := strings.TrimSpace(account.GetModelMapping()[modelID])
return mapped != ""
}
func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) bool {
if account == nil {
return false
}
switch account.Platform {
case PlatformOpenAI:
if metadata, ok := account.GetUpstreamModelMetadata(upstreamModel); ok {
if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 {
return stringSliceContains(modalities, "image")
}
}
if !isOpenAICodexImageInputModel(upstreamModel) {
return false
}
if account.IsOpenAIOAuth() {
return true
}
if !account.IsOpenAIApiKey() {
return false
}
// Compatible model lists often omit modalities. Preserve the known GPT
// fallback unless a synced snapshot above explicitly narrows it.
return true
case PlatformGrok:
if !isOfficialGrokCodexBaseURL(account.GetGrokBaseURL()) {
return false
}
canonical := xai.ResolveGrokTextResponsesModelID(upstreamModel)
return isGrokCodexImageInputModel(canonical)
default:
return false
}
}
func isGrokCodexImageInputModel(model string) bool {
switch strings.ToLower(strings.TrimSpace(model)) {
case "grok-4.3",
"grok-4.5",
"grok-4.6",
"grok-build-0.1",
"grok-4.20-0309-reasoning",
"grok-4.20-0309-non-reasoning",
"grok-4.20-multi-agent-0309":
return true
default:
return false
}
}
func isOfficialGrokCodexBaseURL(raw string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" {
return false
}
return xai.IsOfficialBaseURLHost(strings.TrimSuffix(parsed.Hostname(), "."))
}
// BuildDeepSeekCodexModelsManifest preserves the historical entry point for
// callers that still use the provider-specific function name.
func BuildDeepSeekCodexModelsManifest(modelIDs []string) ([]byte, error) {
return BuildCodexModelsManifest(modelIDs)
}
func mergeConfiguredCodexModelsManifest(
body []byte,
configuredModels []string,
selectedModels []string,
filterBySelection bool,
) ([]byte, bool, error) {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, false, err
}
var upstreamModels []json.RawMessage
if err := json.Unmarshal(envelope["models"], &upstreamModels); err != nil {
return nil, false, err
}
selected := make(map[string]struct{}, len(selectedModels))
for _, modelID := range selectedModels {
modelID = strings.TrimSpace(modelID)
if modelID != "" {
selected[modelID] = struct{}{}
}
}
seen := make(map[string]struct{}, len(upstreamModels)+len(configuredModels))
merged := make([]json.RawMessage, 0, len(upstreamModels)+len(configuredModels))
changed := false
for _, rawModel := range upstreamModels {
var descriptor struct {
Slug string `json:"slug"`
}
if err := json.Unmarshal(rawModel, &descriptor); err != nil || strings.TrimSpace(descriptor.Slug) == "" {
if filterBySelection {
changed = true
continue
}
merged = append(merged, rawModel)
continue
}
descriptor.Slug = strings.TrimSpace(descriptor.Slug)
if isCodexDedicatedMediaModel(descriptor.Slug) {
changed = true
continue
}
if filterBySelection {
if _, allowed := selected[descriptor.Slug]; !allowed {
changed = true
continue
}
}
if strings.HasPrefix(descriptor.Slug, codexAutoModelPrefix) {
_, explicitlyEnabled := selected[descriptor.Slug]
explicitlyEnabled = filterBySelection && explicitlyEnabled
if !explicitlyEnabled {
changed = true
continue
}
visibleModel, visibilityChanged, err := codexModelWithVisibility(rawModel, "list")
if err != nil {
return nil, false, err
}
rawModel = visibleModel
changed = changed || visibilityChanged
}
seen[descriptor.Slug] = struct{}{}
merged = append(merged, rawModel)
}
for _, modelID := range configuredModels {
if isCodexDedicatedMediaModel(modelID) {
continue
}
if filterBySelection {
if _, allowed := selected[modelID]; !allowed {
continue
}
}
if strings.HasPrefix(modelID, codexAutoModelPrefix) {
if _, explicitlyEnabled := selected[modelID]; !filterBySelection || !explicitlyEnabled {
continue
}
}
if _, exists := seen[modelID]; exists {
continue
}
rawModel, err := json.Marshal(newConfiguredCodexModelDescriptor(modelID))
if err != nil {
return nil, false, err
}
merged = append(merged, rawModel)
seen[modelID] = struct{}{}
changed = true
}
if !changed {
return body, false, nil
}
rawModels, err := json.Marshal(merged)
if err != nil {
return nil, false, err
}
envelope["models"] = rawModels
mergedBody, err := json.Marshal(envelope)
if err != nil {
return nil, false, err
}
return mergedBody, true, nil
}
func codexModelWithVisibility(rawModel json.RawMessage, visibility string) (json.RawMessage, bool, error) {
var fields map[string]json.RawMessage
if err := json.Unmarshal(rawModel, &fields); err != nil {
return nil, false, err
}
var current string
if rawVisibility, ok := fields["visibility"]; ok {
if err := json.Unmarshal(rawVisibility, &current); err == nil && current == visibility {
return rawModel, false, nil
}
}
rawVisibility, err := json.Marshal(visibility)
if err != nil {
return nil, false, err
}
fields["visibility"] = rawVisibility
updated, err := json.Marshal(fields)
if err != nil {
return nil, false, err
}
return updated, true, nil
}
type codexModelsManifestUpstreamError struct {
err error
retryable bool
statusCode int
headers http.Header
body []byte
}
func (e *codexModelsManifestUpstreamError) Error() string { return e.err.Error() }
func (e *codexModelsManifestUpstreamError) Unwrap() error { return e.err }
// IsRetryableCodexModelsManifestError reports whether another selected account
// may succeed without changing the request. API key upstream 404/405 responses
// mean that the selected account does not expose a model-discovery endpoint, so
// another account may still serve the manifest. Other upstream 4xx responses,
// except 429 and ChatGPT-backend 401, are intentionally not retried. A manifest
// 401 from the ChatGPT Codex backend reflects the selected OAuth account's
// upstream token rather than the client request (the client's own API key was
// already validated locally). Custom API key upstreams keep the no-failover 401
// behavior because their /models auth semantics are not authoritative for the
// account.
func IsRetryableCodexModelsManifestError(err error) bool {
var upstreamErr *codexModelsManifestUpstreamError
return errors.As(err, &upstreamErr) && upstreamErr.retryable
}
func isRetryableCodexModelsManifestStatus(statusCode int, useAPIKeyUpstream bool) bool {
return (useAPIKeyUpstream &&
(statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed)) ||
(statusCode == http.StatusUnauthorized && !useAPIKeyUpstream) ||
statusCode == http.StatusTooManyRequests ||
(statusCode >= http.StatusInternalServerError && statusCode < 600)
}
func isRetryableCodexModelsManifestTransportError(err error) bool {
if err == nil || errors.Is(err, context.Canceled) {
return false
}
if errors.Is(err, context.DeadlineExceeded) ||
errors.Is(err, io.EOF) ||
errors.Is(err, io.ErrUnexpectedEOF) ||
errors.Is(err, net.ErrClosed) {
return true
}
var opErr *net.OpError
if errors.As(err, &opErr) {
return true
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
var goAwayErr http2.GoAwayError
if errors.As(err, &goAwayErr) {
return true
}
var streamErr http2.StreamError
if errors.As(err, &streamErr) {
return true
}
var connectionErr http2.ConnectionError
if errors.As(err, &connectionErr) {
return true
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
// net/http uses unexported HTTP/2 error types, so typed matching is not
// possible for errors produced by the standard library transport.
message := strings.ToLower(err.Error())
if strings.Contains(message, "http2:") &&
(strings.Contains(message, "goaway") ||
strings.Contains(message, "refused_stream") ||
strings.Contains(message, "frame too large")) {
return true
}
if strings.Contains(message, "stream error: stream id ") {
return true
}
for _, code := range []http2.ErrCode{
http2.ErrCodeNo,
http2.ErrCodeProtocol,
http2.ErrCodeInternal,
http2.ErrCodeFlowControl,
http2.ErrCodeSettingsTimeout,
http2.ErrCodeStreamClosed,
http2.ErrCodeFrameSize,
http2.ErrCodeRefusedStream,
http2.ErrCodeCancel,
http2.ErrCodeCompression,
http2.ErrCodeConnect,
http2.ErrCodeEnhanceYourCalm,
http2.ErrCodeInadequateSecurity,
http2.ErrCodeHTTP11Required,
} {
if strings.Contains(message, "connection error: "+strings.ToLower(code.String())) {
return true
}
}
return false
}
type openAIModelsRequest struct {
url string
headers http.Header
proxyURL string
accountID int64
credentialAccountID int64
credentialAccount *Account
accountConcurrency int
useAPIKeyUpstream bool
// Cached bodies have already been converted to their requested format.
standardModelsList bool
}
type openAIModelsCacheEntry struct {
manifest *OpenAIModelsResponse
order uint64
expiresAt time.Time
staleUntil time.Time
}
type openAIModelsCacheState uint8
const (
openAIModelsCacheMiss openAIModelsCacheState = iota
openAIModelsCacheFresh
openAIModelsCacheStale
)
type openAIModelsCache struct {
mu sync.Mutex
entries map[string]openAIModelsCacheEntry
nextOrder uint64
refresh singleflight.Group
}
func (c *openAIModelsCache) get(key string, now time.Time) (*OpenAIModelsResponse, openAIModelsCacheState) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[key]
if !ok {
return nil, openAIModelsCacheMiss
}
if !now.Before(entry.staleUntil) {
delete(c.entries, key)
return nil, openAIModelsCacheMiss
}
if now.Before(entry.expiresAt) {
return entry.manifest, openAIModelsCacheFresh
}
return entry.manifest, openAIModelsCacheStale
}
func (c *openAIModelsCache) set(key string, manifest *OpenAIModelsResponse, now time.Time) {
if manifest == nil || len(manifest.Body) > openAIModelsCacheBodyLimit {
return
}
remainingBodyBudget := openAIModelsCacheBodyLimit - len(manifest.Body)
if len(manifest.upstreamSourceBody) > remainingBodyBudget {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil {
c.entries = make(map[string]openAIModelsCacheEntry)
}
if _, exists := c.entries[key]; !exists && len(c.entries) >= openAIModelsCacheMaxEntries {
oldestKey := ""
var oldestOrder uint64
for candidateKey, entry := range c.entries {
if !now.Before(entry.staleUntil) {
delete(c.entries, candidateKey)
continue
}
if oldestKey == "" || entry.order < oldestOrder {
oldestKey = candidateKey
oldestOrder = entry.order
}
}
if len(c.entries) >= openAIModelsCacheMaxEntries && oldestKey != "" {
delete(c.entries, oldestKey)
}
}
c.nextOrder++
c.entries[key] = openAIModelsCacheEntry{
manifest: manifest,
order: c.nextOrder,
expiresAt: now.Add(openAIModelsCacheTTL),
staleUntil: now.Add(openAIModelsCacheStaleTTL),
}
}
// FetchCodexModelsManifest fetches the live Codex models manifest from either
// the ChatGPT backend for OAuth accounts or a custom upstream for API key accounts.
//
// After validating the stable top-level envelope, OAuth response bodies are
// passed through verbatim. Custom API key manifests receive only the narrowly
// scoped compatibility adjustments required by custom-provider Codex clients.
func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*OpenAIModelsResponse, error) {
if account == nil {
return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_ACCOUNT_REQUIRED", "account is required")
}
credAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account)
if err != nil {
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_CREDENTIALS_FAILED", "resolve credential account: %v", err)
}
clientVersion = strings.TrimSpace(clientVersion)
if clientVersion == "" {
clientVersion = CodexCanonicalClientVersion()
}
requestEndpoint := chatgptCodexModelsURL
authToken := ""
useAPIKeyUpstream := false
appendModelsPath := false
switch {
case credAccount.IsOpenAIOAuth():
authToken = strings.TrimSpace(credAccount.GetOpenAIAccessToken())
if authToken == "" && !credAccount.IsOpenAIAgentIdentity() {
return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_TOKEN_MISSING", "account has no Codex backend access token")
}
case credAccount.IsOpenAIApiKey():
baseURL := strings.TrimSpace(credAccount.GetOpenAIBaseURL())
authToken = strings.TrimSpace(credAccount.GetOpenAIApiKey())
if authToken == "" {
return nil, infraerrors.New(http.StatusBadGateway, "OPENAI_CODEX_MODELS_API_KEY_MISSING", "account has no API key for the Codex models upstream")
}
normalizedBaseURL, validateErr := s.validateUpstreamBaseURL(baseURL)
if validateErr != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_INVALID", "invalid Codex models upstream base URL: %v", validateErr)
}
requestEndpoint = normalizedBaseURL
useAPIKeyUpstream = true
appendModelsPath = true
default:
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_ACCOUNT_TYPE_UNSUPPORTED", "account type %q cannot fetch the Codex models manifest", credAccount.Type)
}
requestURL, err := buildCodexModelsManifestURL(requestEndpoint, appendModelsPath, clientVersion)
if err != nil {
if useAPIKeyUpstream {
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_INVALID", "invalid Codex models upstream base URL: %v", err)
}
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "parse codex models request URL: %v", err)
}
headers := make(http.Header)
if useAPIKeyUpstream {
headers.Set("Authorization", "Bearer "+authToken)
credAccount.ApplyHeaderOverrides(headers)
} else {
authHeaders, authErr := s.buildOpenAIAuthenticationHeaders(ctx, credAccount, authToken)
if authErr != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "build Codex models authentication: %v", authErr)
}
for key, values := range authHeaders {
for _, value := range values {
headers.Add(key, value)
}
}
setOpenAIChatGPTAccountHeaders(headers, credAccount)
}
headers.Set("Accept", "application/json")
overrideUA := ""
if !useAPIKeyUpstream {
overrideUA = credAccount.GetOpenAIUserAgent()
}
identity := resolveCodexOutboundIdentity(overrideUA)
headers.Set("Originator", identity.originator)
headers.Set("User-Agent", identity.userAgent)
// Version 头优先与 client_version 查询参数同源:客户端自报版本合法且不低于上游
// 门槛时原样使用;否则回退规范版本,避免陈旧 version 触发上游 404(issue #3901)。
// client_version 查询参数本身始终按客户端原值透传(内容协商语义,契约见
// TestFetchCodexModelsManifestPassthrough)。
headerVersion := NormalizeCodexClientVersion(clientVersion)
if headerVersion == "" || CompareVersions(headerVersion, codexUpstreamMinVersion) < 0 {
headerVersion = identity.version
}
headers.Set("Version", headerVersion)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
request := openAIModelsRequest{
url: requestURL.String(),
headers: headers,
proxyURL: proxyURL,
accountID: account.ID,
credentialAccountID: credAccount.ID,
credentialAccount: credAccount,
accountConcurrency: account.Concurrency,
useAPIKeyUpstream: useAPIKeyUpstream,
}
if useAPIKeyUpstream {
return s.fetchCachedOpenAIModels(ctx, request, s.fetchCodexModelsManifestUpstreamForRequest(request), ifNoneMatch)
}
// OAuth 账号同样经过账号级缓存;闭包保留 agent identity 任务恢复逻辑,
// 错误时仍交给 handleCodexModelsManifestAccountAuthError 处理账号状态。
oauthFetch := func(fetchCtx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) {
manifest, fetchErr := s.fetchCodexModelsManifestUpstream(fetchCtx, request, ifNoneMatch)
if !credAccount.IsOpenAIAgentIdentity() || !isAgentIdentityTaskInvalidCodexModelsError(fetchErr) {
s.handleCodexModelsManifestAccountAuthError(fetchCtx, account, credAccount, fetchErr)
return manifest, fetchErr
}
expectedTaskID := strings.TrimSpace(credAccount.GetCredential("task_id"))
if recoverErr := s.recoverAgentIdentityTask(fetchCtx, credAccount, expectedTaskID); recoverErr != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "agent identity task recovery failed: %v", recoverErr)
}
authHeaders, authErr := s.buildOpenAIAuthenticationHeaders(fetchCtx, credAccount, "")
if authErr != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "build Codex models authentication after task recovery: %v", authErr)
}
request.headers.Del("Authorization")
request.headers.Del("ChatGPT-Account-ID")
for key, values := range authHeaders {
for _, value := range values {
request.headers.Add(key, value)
}
}
setOpenAIChatGPTAccountHeaders(request.headers, credAccount)
return s.fetchCodexModelsManifestUpstream(fetchCtx, request, ifNoneMatch)
}
return s.fetchCachedOpenAIModels(ctx, request, oauthFetch, ifNoneMatch)
}
func isAgentIdentityTaskInvalidCodexModelsError(err error) bool {
var upstreamErr *codexModelsManifestUpstreamError
return errors.As(err, &upstreamErr) &&
isAgentIdentityTaskInvalidHTTPResponse(upstreamErr.statusCode, upstreamErr.body)
}
// handleCodexModelsManifestAccountAuthError feeds manifest 401s from the
// ChatGPT Codex backend into the shared upstream-error state machinery
// (token cache invalidation, temp-unschedulable cooldown, or permanent
// disable for token_revoked/token_invalidated). Without this, an account
// whose OAuth token was revoked upstream stays active and schedulable and
// keeps being selected for every subsequent /models request (#4544).
//
// Scope is deliberately limited to plain OAuth accounts: the manifest
// endpoint authenticates with the same token as /responses forwarding, so a
// 401 is authoritative for the account. Agent Identity accounts are excluded
// because their 401s can be task-scoped and have a dedicated recovery flow,
// and API key manifests come from custom upstreams whose /models auth may
// diverge from their chat endpoints.
func (s *OpenAIGatewayService) handleCodexModelsManifestAccountAuthError(ctx context.Context, account, credAccount *Account, err error) {
if s == nil || account == nil || err == nil {
return
}
if credAccount == nil || !credAccount.IsOpenAIOAuth() || credAccount.IsOpenAIAgentIdentity() {
return
}
var upstreamErr *codexModelsManifestUpstreamError
if !errors.As(err, &upstreamErr) || upstreamErr.statusCode != http.StatusUnauthorized {
return
}
headers := upstreamErr.headers
if headers == nil {
headers = http.Header{}
}
s.handleOpenAIAccountUpstreamError(ctx, account, upstreamErr.statusCode, headers, upstreamErr.body)
}
func (s *OpenAIGatewayService) fetchCachedOpenAIModels(ctx context.Context, request openAIModelsRequest, fetch func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error), ifNoneMatch string) (*OpenAIModelsResponse, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
cacheKey := buildOpenAIModelsCacheKey(request)
manifest, state := s.openAIModelsCache.get(cacheKey, time.Now())
if state == openAIModelsCacheFresh {
return openAIModelsResponseForClient(manifest, ifNoneMatch), nil
}
resultCh := s.refreshCachedOpenAIModels(cacheKey, request, fetch)
if state == openAIModelsCacheStale {
return openAIModelsResponseForClient(manifest, ifNoneMatch), nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case result := <-resultCh:
if result.Err != nil {
return nil, result.Err
}
manifest, ok := result.Val.(*OpenAIModelsResponse)
if !ok || manifest == nil {
return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "invalid shared Codex models manifest result")
}
return openAIModelsResponseForClient(manifest, ifNoneMatch), nil
}
}
func (s *OpenAIGatewayService) refreshCachedOpenAIModels(cacheKey string, request openAIModelsRequest, fetch func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error)) <-chan singleflight.Result {
return s.openAIModelsCache.refresh.DoChan(cacheKey, func() (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), codexModelsManifestRequestTimeout)
defer cancel()
cached, _ := s.openAIModelsCache.get(cacheKey, time.Now())
ifNoneMatch := ""
if cached != nil {
ifNoneMatch = cached.upstreamETag
}
manifest, err := fetch(ctx, ifNoneMatch)
if err != nil {
return nil, err
}
if manifest.NotModified && cached != nil {
s.openAIModelsCache.set(cacheKey, cached, time.Now())
return cached, nil
}
if !manifest.NotModified {
s.openAIModelsCache.set(cacheKey, manifest, time.Now())
}
return manifest, nil
})
}
func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstreamForRequest(request openAIModelsRequest) func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) {
return func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) {
return s.fetchCodexModelsManifestUpstream(ctx, request, ifNoneMatch)
}
}
func (s *OpenAIGatewayService) fetchOpenAIModelsUpstream(ctx context.Context, request openAIModelsRequest, ifNoneMatch string) (*OpenAIModelsResponse, error) {
reqCtx, cancel := context.WithTimeout(ctx, codexModelsManifestRequestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, request.url, nil)
if err != nil {
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "create codex models request: %v", err)
}
req.Header = request.headers.Clone()
if ifNoneMatch = strings.TrimSpace(ifNoneMatch); ifNoneMatch != "" {
req.Header.Set("If-None-Match", ifNoneMatch)
}
var resp *http.Response
if request.useAPIKeyUpstream {
if s.httpUpstream == nil {
return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_UPSTREAM_NOT_CONFIGURED", "Codex models upstream HTTP client is not configured")
}
req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
resp, err = s.httpUpstream.Do(req, request.proxyURL, request.accountID, request.accountConcurrency)
} else {
handled := false
if s.pluginManager != nil {
resp, handled, err = s.pluginManager.RoundTripOpenAIOAuth(reqCtx, req, request.proxyURL, request.credentialAccount)
}
if !handled {
client, clientErr := httpclient.GetClient(httpclient.Options{
ProxyURL: request.proxyURL,
Timeout: codexModelsManifestRequestTimeout,
ResponseHeaderTimeout: 10 * time.Second,
})
if clientErr != nil {
return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_PROXY_INVALID", "invalid proxy configuration: %v", clientErr)
}
resp, err = client.Do(req)
}
}
if err != nil {
return nil, &codexModelsManifestUpstreamError{
err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest request failed: %v", err),
retryable: isRetryableCodexModelsManifestTransportError(err),
}
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotModified {
return &OpenAIModelsResponse{ETag: resp.Header.Get("ETag"), NotModified: true}, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
body = s.redactAgentIdentitySensitiveBody(reqCtx, request.credentialAccount, body)
message := strings.TrimSpace(string(body))
if message == "" {
message = resp.Status
}
return nil, &codexModelsManifestUpstreamError{
err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest upstream error %d: %s", resp.StatusCode, message),
statusCode: resp.StatusCode,
headers: resp.Header.Clone(),
body: body,
retryable: isRetryableCodexModelsManifestStatus(resp.StatusCode, request.useAPIKeyUpstream),
}
}
bodyLimit := resolveModelsListReadLimit(s.cfg)
body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1))
if err != nil {
return nil, &codexModelsManifestUpstreamError{
err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "read codex models manifest response: %v", err),
retryable: isRetryableCodexModelsManifestTransportError(err),
}
}
if int64(len(body)) > bodyLimit {
return nil, &codexModelsManifestUpstreamError{
err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", "codex models manifest response exceeds %d bytes", bodyLimit),
retryable: true,
}
}
etag := resp.Header.Get("ETag")
return &OpenAIModelsResponse{Body: body, ETag: etag, upstreamETag: etag}, nil
}
func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Context, request openAIModelsRequest, ifNoneMatch string) (*OpenAIModelsResponse, error) {
response, err := s.fetchOpenAIModelsUpstream(ctx, request, ifNoneMatch)
if err != nil || response.NotModified {
return response, err
}
body := response.Body
upstreamBody := body
convertedFromOpenAIModelList := false
if request.useAPIKeyUpstream {
convertedBody := convertOpenAIModelListToCodexManifestForAccount(body, request.credentialAccount)
convertedFromOpenAIModelList = !bytes.Equal(convertedBody, body)
body = convertedBody
}
if err := validateCodexModelsManifestEnvelope(body); err != nil {
return nil, &codexModelsManifestUpstreamError{
err: infraerrors.Newf(
http.StatusBadGateway,
"OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST",
"codex models manifest upstream returned an invalid envelope: %v",
err,
),
retryable: true,
}
}
if request.useAPIKeyUpstream {
body, err = completeAPIKeyCodexModelsManifestMetadata(
body,
false,
request.credentialAccount,
)
if err != nil {
return nil, &codexModelsManifestUpstreamError{
err: infraerrors.Newf(
http.StatusBadGateway,
"OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST",
"codex models manifest upstream metadata could not be completed: %v",
err,
),
retryable: true,
}
}
body, err = adjustAPIKeyCodexModelsManifest(body, request.credentialAccount)
if err != nil {
return nil, &codexModelsManifestUpstreamError{
err: infraerrors.Newf(
http.StatusBadGateway,
"OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST",
"codex models manifest upstream could not be adjusted: %v",
err,
),
retryable: true,
}
}
}
etag := response.upstreamETag
manifest := &OpenAIModelsResponse{
Body: body,
ETag: etag,
upstreamETag: etag,
upstreamSourceBody: append([]byte(nil), upstreamBody...),
convertedFromOpenAIModelList: convertedFromOpenAIModelList,
}
if request.useAPIKeyUpstream && !bytes.Equal(body, upstreamBody) {
manifest.ETag = codexModelsManifestBodyETag(body)
}
return manifest, nil
}
func codexModelsManifestBodyETag(body []byte) string {
sum := sha256.Sum256(body)
return fmt.Sprintf(`"%x"`, sum)
}
// CodexModelsManifestETag returns the strong ETag for a generated client
// catalog. It is based on the final JSON body after local filtering.
func CodexModelsManifestETag(body []byte) string {
return codexModelsManifestBodyETag(body)
}
var apiKeyCodexModelsWithoutResponsesLite = map[string]struct{}{
"gpt-6-astra": {},
"gpt-5.6-sol": {},
"gpt-5.6-terra": {},
"gpt-5.6-luna": {},
}
// adjustAPIKeyCodexModelsManifest prevents Codex from selecting Responses
// Lite for custom API key providers. Those clients do not install web.run in
// Lite mode, so the affected model manifests must advertise the full Responses
// path. Return the original body when no targeted true value is present.
func adjustAPIKeyCodexModelsManifest(body []byte, account *Account) ([]byte, error) {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("decode JSON object: %w", err)
}
var models []json.RawMessage
if err := json.Unmarshal(envelope["models"], &models); err != nil {
return nil, fmt.Errorf("decode top-level models array: %w", err)
}
changed := false
for i, rawModel := range models {
var model map[string]json.RawMessage
if err := json.Unmarshal(rawModel, &model); err != nil || model == nil {
continue
}
var slug string
if err := json.Unmarshal(model["slug"], &slug); err != nil {
continue
}
target := slug
if account != nil {
target = account.GetMappedModel(slug)
}
if isOpenAIGPT6AstraModel(target) {
target = "gpt-6-astra"
}
if _, targeted := apiKeyCodexModelsWithoutResponsesLite[target]; !targeted {
continue
}
var useResponsesLite bool
if err := json.Unmarshal(model["use_responses_lite"], &useResponsesLite); err != nil || !useResponsesLite {
continue
}
model["use_responses_lite"] = json.RawMessage("false")
adjusted, err := json.Marshal(model)
if err != nil {
return nil, fmt.Errorf("encode model %q: %w", slug, err)
}
models[i] = adjusted
changed = true
}
if !changed {
return body, nil
}
adjustedModels, err := json.Marshal(models)
if err != nil {
return nil, fmt.Errorf("encode top-level models array: %w", err)
}
envelope["models"] = adjustedModels
adjusted, err := json.Marshal(envelope)
if err != nil {
return nil, fmt.Errorf("encode JSON object: %w", err)
}
return adjusted, nil
}
// convertOpenAIModelListToCodexManifest rewrites a standard OpenAI
// GET /v1/models response ({"object":"list","data":[{"id":...},...]}) into the
// same complete Codex manifest used by locally generated custom-provider
// catalogs. Bodies that already carry a top-level models field, are not the
// standard list shape, or yield no usable model IDs are returned unchanged so
// envelope validation reports the original payload.
func convertOpenAIModelListToCodexManifest(body []byte) []byte {
return convertOpenAIModelListToCodexManifestForAccount(body, nil)
}
func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Account) []byte {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil || envelope == nil {
return body
}
if _, ok := envelope["models"]; ok {
return body
}
data, ok := envelope["data"]
if !ok {
return body
}
var entries []map[string]json.RawMessage
if err := json.Unmarshal(data, &entries); err != nil {
return body
}
modelIDs := make([]string, 0, len(entries))
modelMetadata := make(map[string]codexModelMetadataOverride, len(entries))
metadataModels := make(map[string]string, len(entries))
for _, entry := range entries {
var id string
if err := json.Unmarshal(entry["id"], &id); err != nil {
continue
}
id = strings.TrimSpace(id)
if id == "" {
continue
}
modelIDs = append(modelIDs, id)
capabilityModel := id
if account != nil {
capabilityModel = account.GetMappedModel(id)
}
metadataModels[id] = capabilityModel
capabilities := accountCodexToolCapabilities(account, capabilityModel)
applyCodexToolCapabilities(capabilities, entry, true)
modelMetadata[id] = codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{
CodexToolCapabilities: capabilities,
}}
}
if len(modelIDs) == 0 {
return body
}
imageInputModels := make(map[string]bool, len(modelIDs))
for _, modelID := range modelIDs {
if accountCodexModelSupportsImageInput(account, modelID) {
imageInputModels[modelID] = true
}
}
searchToolModels := make(map[string]bool, len(modelIDs))
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
for _, modelID := range modelIDs {
searchToolModels[modelID] = true
}
}
converted, err := buildCodexModelsManifest(modelIDs, imageInputModels, searchToolModels, metadataModels, modelMetadata)
if err != nil {
return body
}
return converted
}
// completeAPIKeyCodexModelsManifestMetadata fills fields omitted by standard
// OpenAI-compatible /models endpoints. Existing provider metadata always wins;
// only absent or null values are synthesized.
// CompleteAPIKeyCodexModelsManifestForClient fills the complete ModelInfo
// contract immediately before a group-specific API key manifest is returned.
// The shared upstream cache remains independent from local group policy.
func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manifest *OpenAIModelsResponse, account *Account) error {
if manifest == nil || account == nil || !account.IsOpenAIApiKey() || manifest.NotModified || len(manifest.Body) == 0 {
return nil
}
body := manifest.Body
if len(manifest.upstreamSourceBody) > 0 {
body = append([]byte(nil), manifest.upstreamSourceBody...)
if manifest.convertedFromOpenAIModelList {
body = convertOpenAIModelListToCodexManifestForAccount(body, account)
}
}
var err error
body, err = applySyncedAPIKeyCodexModelMetadata(body, account, manifest.convertedFromOpenAIModelList)
if err != nil {
return err
}
body, err = completeAPIKeyCodexModelsManifestMetadata(
body,
true,
account,
)
if err != nil {
return err
}
body, err = adjustAPIKeyCodexModelsManifest(body, account)
if err != nil {
return err
}
manifest.Body = body
manifest.ETag = codexModelsManifestBodyETag(manifest.Body)
return nil
}
func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwriteLocalDefaults bool) ([]byte, error) {
snapshot := account.GetUpstreamModelMetadataSnapshot()
if snapshot == nil || len(snapshot.Models) == 0 {
return body, nil
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("decode JSON object: %w", err)
}
var models []json.RawMessage
if err := json.Unmarshal(envelope["models"], &models); err != nil {
return nil, fmt.Errorf("decode top-level models array: %w", err)
}
changed := false
for i, rawModel := range models {
var model map[string]json.RawMessage
if err := json.Unmarshal(rawModel, &model); err != nil || model == nil {
continue
}
var slug string
if err := json.Unmarshal(model["slug"], &slug); err != nil {
continue
}
slug = strings.TrimSpace(slug)
lookupModel := account.GetMappedModel(slug)
metadata, ok := snapshot.Models[lookupModel]
if !ok {
continue
}
if lookupModel != slug {
metadata.DisplayName = ""
metadata.Description = ""
}
descriptor := newConfiguredCodexModelDescriptor(slug)
applyUpstreamModelMetadataToCodexDescriptor(
&descriptor,
codexModelMetadataOverride{UpstreamModelMetadata: metadata},
)
descriptorBody, err := json.Marshal(descriptor)
if err != nil {
return nil, fmt.Errorf("encode synced model %q: %w", slug, err)
}
var syncedFields map[string]json.RawMessage
if err := json.Unmarshal(descriptorBody, &syncedFields); err != nil {
return nil, fmt.Errorf("decode synced model %q: %w", slug, err)
}
fields := make([]string, 0, 7)
if strings.TrimSpace(metadata.DisplayName) != "" {
fields = append(fields, "display_name")
}
if strings.TrimSpace(metadata.Description) != "" {
fields = append(fields, "description")
}
if metadata.Reasoning != nil {
fields = append(fields, "default_reasoning_level", "supported_reasoning_levels")
}
if len(normalizeCodexInputModalities(metadata.InputModalities)) > 0 {
fields = append(fields, "input_modalities")
}
if metadata.ContextWindow > 0 {
fields = append(fields, "context_window", "max_context_window")
}
// List conversion has already applied live fields over account capabilities.
modelChanged := applyCodexToolCapabilities(model, metadata.CodexToolCapabilities, false)
for _, field := range fields {
value, exists := syncedFields[field]
if !exists {
continue
}
current, currentExists := model[field]
current = bytes.TrimSpace(current)
if !overwriteLocalDefaults && currentExists && len(current) > 0 && !bytes.Equal(current, []byte("null")) {
continue
}
if bytes.Equal(current, bytes.TrimSpace(value)) {
continue
}
model[field] = value
modelChanged = true
}
if !modelChanged {
continue
}
encoded, err := json.Marshal(model)
if err != nil {
return nil, fmt.Errorf("encode model %q with synced metadata: %w", slug, err)
}
models[i] = encoded
changed = true
}
if !changed {
return body, nil
}
encodedModels, err := json.Marshal(models)
if err != nil {
return nil, fmt.Errorf("encode models with synced metadata: %w", err)
}
envelope["models"] = encodedModels
updated, err := json.Marshal(envelope)
if err != nil {
return nil, fmt.Errorf("encode manifest with synced metadata: %w", err)
}
return updated, nil
}
func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll bool, account *Account) ([]byte, error) {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("decode JSON object: %w", err)
}
var models []json.RawMessage
if err := json.Unmarshal(envelope["models"], &models); err != nil {
return nil, fmt.Errorf("decode top-level models array: %w", err)
}
officialOpenAI := account != nil && isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL())
changed := false
if officialOpenAI {
filtered := make([]json.RawMessage, 0, len(models))
for _, rawModel := range models {
var model struct {
Slug string `json:"slug"`
}
if err := json.Unmarshal(rawModel, &model); err != nil || strings.TrimSpace(model.Slug) == "" {
filtered = append(filtered, rawModel)
continue
}
if !isOfficialOpenAICodexCatalogModel(model.Slug) {
changed = true
continue
}
filtered = append(filtered, rawModel)
}
models = filtered
}
for i, rawModel := range models {
var model map[string]json.RawMessage
if err := json.Unmarshal(rawModel, &model); err != nil || model == nil {
continue
}
var slug string
if err := json.Unmarshal(model["slug"], &slug); err != nil {
continue
}
slug = strings.TrimSpace(slug)
if slug == "" {
continue
}
completeDescriptor := completeAll || isDeepSeekCodexModel(slug)
forceOfficialImage := officialOpenAI && isOpenAICodexImageInputModel(slug)
if !completeDescriptor && !forceOfficialImage {
continue
}
descriptor := newConfiguredCodexModelDescriptor(slug)
descriptor.SupportsSearchTool = shouldForwardOpenAIResponsesViaRawChatCompletions(account)
if accountCodexModelSupportsImageInput(account, slug) {
descriptor.InputModalities = []string{"text", "image"}
}
if forceOfficialImage {
descriptor.InputModalities = []string{"text", "image"}
descriptor.SupportsImageDetailOriginal = true
}
defaultBody, err := json.Marshal(descriptor)
if err != nil {
return nil, fmt.Errorf("encode default model %q: %w", slug, err)
}
var defaults map[string]json.RawMessage
if err := json.Unmarshal(defaultBody, &defaults); err != nil {
return nil, fmt.Errorf("decode default model %q: %w", slug, err)
}
capabilityModel := slug
if account != nil {
capabilityModel = account.GetMappedModel(slug)
}
capabilities := accountCodexToolCapabilities(account, capabilityModel)
modelChanged := applyCodexToolCapabilities(model, capabilities, false)
if completeDescriptor {
merged, err := mergeMissingCodexModelFields(model, defaults)
if err != nil {
return nil, fmt.Errorf("complete model %q: %w", slug, err)
}
modelChanged = merged || modelChanged
}
if forceOfficialImage {
modalities, err := json.Marshal([]string{"text", "image"})
if err != nil {
return nil, fmt.Errorf("encode input modalities for model %q: %w", slug, err)
}
if !bytes.Equal(bytes.TrimSpace(model["input_modalities"]), modalities) {
model["input_modalities"] = modalities
modelChanged = true
}
imageDetailOriginal := json.RawMessage("true")
if !bytes.Equal(bytes.TrimSpace(model["supports_image_detail_original"]), imageDetailOriginal) {
model["supports_image_detail_original"] = imageDetailOriginal
modelChanged = true
}
}
if !modelChanged {
continue
}
encoded, err := json.Marshal(model)
if err != nil {
return nil, fmt.Errorf("encode completed model %q: %w", slug, err)
}
models[i] = encoded
changed = true
}
if !changed {
return body, nil
}
encodedModels, err := json.Marshal(models)
if err != nil {
return nil, fmt.Errorf("encode top-level models array: %w", err)
}
envelope["models"] = encodedModels
completed, err := json.Marshal(envelope)
if err != nil {
return nil, fmt.Errorf("encode JSON object: %w", err)
}
return completed, nil
}
func mergeMissingCodexModelFields(current, defaults map[string]json.RawMessage) (bool, error) {
changed := false
for key, defaultValue := range defaults {
currentValue, exists := current[key]
if exists && stringSliceContains(codexToolCapabilityFields, key) {
continue
}
if !exists || (bytes.Equal(bytes.TrimSpace(currentValue), []byte("null")) &&
!bytes.Equal(bytes.TrimSpace(defaultValue), []byte("null"))) {
current[key] = defaultValue
changed = true
continue
}
var currentObject map[string]json.RawMessage
var defaultObject map[string]json.RawMessage
if err := json.Unmarshal(currentValue, &currentObject); err != nil || currentObject == nil {
continue
}
if err := json.Unmarshal(defaultValue, &defaultObject); err != nil || defaultObject == nil {
continue
}
nestedChanged, err := mergeMissingCodexModelFields(currentObject, defaultObject)
if err != nil {
return false, err
}
if !nestedChanged {
continue
}
mergedValue, err := json.Marshal(currentObject)
if err != nil {
return false, fmt.Errorf("encode field %q: %w", key, err)
}
current[key] = mergedValue
changed = true
}
return changed, nil
}
func validateCodexModelsManifestEnvelope(body []byte) error {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil {
return fmt.Errorf("decode JSON object: %w", err)
}
if envelope == nil {
return errors.New("expected a JSON object")
}
models, ok := envelope["models"]
if !ok {
return errors.New("missing top-level models array")
}
models = bytes.TrimSpace(models)
var entries []json.RawMessage
if len(models) == 0 || models[0] != '[' {
return errors.New("top-level models field is not an array")
}
if err := json.Unmarshal(models, &entries); err != nil {
return fmt.Errorf("decode top-level models array: %w", err)
}
return nil
}
func buildOpenAIModelsCacheKey(request openAIModelsRequest) string {
hasher := sha256.New()
_, _ = fmt.Fprintf(hasher, "%d\n%d\n%t\n%s\n%s\n", request.accountID, request.credentialAccountID, request.standardModelsList, request.proxyURL, request.url)
headerNames := make([]string, 0, len(request.headers))
for name := range request.headers {
headerNames = append(headerNames, name)
}
sort.Strings(headerNames)
for _, name := range headerNames {
_, _ = fmt.Fprintf(hasher, "%s\n", strings.ToLower(name))
for _, value := range request.headers[name] {
_, _ = fmt.Fprintf(hasher, "%s\n", value)
}
}
return fmt.Sprintf("%x", hasher.Sum(nil))
}
func cloneOpenAIModelsResponse(manifest *OpenAIModelsResponse) *OpenAIModelsResponse {
if manifest == nil {
return nil
}
cloned := *manifest
if manifest.Body != nil {
cloned.Body = append([]byte(nil), manifest.Body...)
}
if manifest.upstreamSourceBody != nil {
cloned.upstreamSourceBody = append([]byte(nil), manifest.upstreamSourceBody...)
}
return &cloned
}
func openAIModelsResponseForClient(manifest *OpenAIModelsResponse, ifNoneMatch string) *OpenAIModelsResponse {
if manifest == nil {
return nil
}
if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) {
return &OpenAIModelsResponse{ETag: manifest.ETag, NotModified: true}
}
return cloneOpenAIModelsResponse(manifest)
}
func codexModelsManifestETagMatches(ifNoneMatch, etag string) bool {
etag = strings.TrimSpace(etag)
if etag == "" {
return false
}
normalize := func(value string) string {
value = strings.TrimSpace(value)
if len(value) >= 2 && strings.EqualFold(value[:2], "W/") {
value = strings.TrimSpace(value[2:])
}
return value
}
want := normalize(etag)
for _, candidate := range strings.Split(ifNoneMatch, ",") {
candidate = strings.TrimSpace(candidate)
if candidate == "*" || normalize(candidate) == want {
return true
}
}
return false
}
// CodexModelsManifestETagMatches applies If-None-Match semantics to a Codex
// catalog ETag, including weak and comma-separated validators.
func CodexModelsManifestETagMatches(ifNoneMatch, etag string) bool {
return codexModelsManifestETagMatches(ifNoneMatch, etag)
}
func isOfficialOpenAIModelsBaseURL(raw string) bool {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
hostname := strings.TrimSuffix(parsed.Hostname(), ".")
return strings.EqualFold(hostname, "api.openai.com")
}
func buildCodexModelsManifestURL(endpoint string, appendModelsPath bool, clientVersion string) (*url.URL, error) {
requestURL, err := url.Parse(endpoint)
if err != nil {
return nil, err
}
if requestURL.Fragment != "" {
return nil, fmt.Errorf("URL fragments are not supported")
}
query := requestURL.Query()
requestURL.RawQuery = ""
requestURL.ForceQuery = false
if appendModelsPath {
requestURL, err = url.Parse(buildOpenAIModelsURL(requestURL.String()))
if err != nil {
return nil, err
}
}
query.Set("client_version", clientVersion)
requestURL.RawQuery = query.Encode()
return requestURL, nil
}