mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
fix(codex): preserve known image input capabilities
This commit is contained in:
@@ -620,8 +620,8 @@ func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.
|
||||
t.Fatalf("truncation_policy must be populated: %v", model["truncation_policy"])
|
||||
}
|
||||
modalities, ok := model["input_modalities"].([]any)
|
||||
if !ok || len(modalities) != 1 || modalities[0] != "text" {
|
||||
t.Fatalf("custom OpenAI-compatible endpoint modalities: got %v, want [text]", model["input_modalities"])
|
||||
if !ok || len(modalities) != 2 || modalities[0] != "text" || modalities[1] != "image" {
|
||||
t.Fatalf("known GPT model modalities: got %v, want [text image]", model["input_modalities"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1041,6 +1041,11 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1050,8 +1055,9 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string)
|
||||
if !account.IsOpenAIApiKey() {
|
||||
return false
|
||||
}
|
||||
baseURL := strings.TrimSpace(account.GetCredential("base_url"))
|
||||
return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL)
|
||||
// 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
|
||||
@@ -1715,7 +1721,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
|
||||
upstreamBody := body
|
||||
convertedFromOpenAIModelList := false
|
||||
if request.useAPIKeyUpstream {
|
||||
convertedBody := convertOpenAIModelListToCodexManifest(body)
|
||||
convertedBody := convertOpenAIModelListToCodexManifestForAccount(body, request.credentialAccount)
|
||||
convertedFromOpenAIModelList = !bytes.Equal(convertedBody, body)
|
||||
body = convertedBody
|
||||
}
|
||||
@@ -1734,7 +1740,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
|
||||
body, err = completeAPIKeyCodexModelsManifestMetadata(
|
||||
body,
|
||||
false,
|
||||
request.credentialAccount != nil && isOfficialOpenAIModelsBaseURL(request.credentialAccount.GetOpenAIBaseURL()),
|
||||
request.credentialAccount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, &codexModelsManifestUpstreamError{
|
||||
@@ -1855,6 +1861,10 @@ func adjustAPIKeyCodexModelsManifest(body []byte) ([]byte, error) {
|
||||
// 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
|
||||
@@ -1883,7 +1893,13 @@ func convertOpenAIModelListToCodexManifest(body []byte) []byte {
|
||||
if len(modelIDs) == 0 {
|
||||
return body
|
||||
}
|
||||
converted, err := BuildCodexModelsManifest(modelIDs)
|
||||
imageInputModels := make(map[string]bool, len(modelIDs))
|
||||
for _, modelID := range modelIDs {
|
||||
if accountCodexModelSupportsImageInput(account, modelID) {
|
||||
imageInputModels[modelID] = true
|
||||
}
|
||||
}
|
||||
converted, err := buildCodexModelsManifest(modelIDs, imageInputModels, nil, nil)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
@@ -1904,7 +1920,7 @@ func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manife
|
||||
if len(manifest.upstreamSourceBody) > 0 {
|
||||
body = append([]byte(nil), manifest.upstreamSourceBody...)
|
||||
if manifest.convertedFromOpenAIModelList {
|
||||
body = convertOpenAIModelListToCodexManifest(body)
|
||||
body = convertOpenAIModelListToCodexManifestForAccount(body, account)
|
||||
}
|
||||
}
|
||||
var err error
|
||||
@@ -1915,7 +1931,7 @@ func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manife
|
||||
body, err = completeAPIKeyCodexModelsManifestMetadata(
|
||||
body,
|
||||
true,
|
||||
isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL()),
|
||||
account,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -2034,7 +2050,7 @@ func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwrit
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officialOpenAI bool) ([]byte, error) {
|
||||
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)
|
||||
@@ -2044,6 +2060,7 @@ func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officia
|
||||
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))
|
||||
@@ -2084,6 +2101,9 @@ func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officia
|
||||
}
|
||||
|
||||
descriptor := newConfiguredCodexModelDescriptor(slug)
|
||||
if accountCodexModelSupportsImageInput(account, slug) {
|
||||
descriptor.InputModalities = []string{"text", "image"}
|
||||
}
|
||||
if forceOfficialImage {
|
||||
descriptor.InputModalities = []string{"text", "image"}
|
||||
descriptor.SupportsImageDetailOriginal = true
|
||||
|
||||
@@ -492,7 +492,7 @@ func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageI
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabilities(t *testing.T) {
|
||||
func TestBuildCodexModelsManifestForGroupUsesProviderImageCapabilities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
@@ -602,6 +602,15 @@ func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabiliti
|
||||
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"},
|
||||
},
|
||||
}
|
||||
@@ -628,6 +637,67 @@ func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabiliti
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -1723,6 +1793,8 @@ func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testin
|
||||
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)
|
||||
}
|
||||
@@ -1769,6 +1841,32 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *
|
||||
require.Equal(t, map[string]any{"source": "upstream"}, envelope["metadata"])
|
||||
}
|
||||
|
||||
func TestCompleteAPIKeyCodexModelsManifestForClientUsesKnownGPTImageFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
manifest := &CodexModelsManifest{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()
|
||||
|
||||
Reference in New Issue
Block a user