mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
feat(codex): complete routed model catalogs
Codex treats the downloaded model catalog as a capability contract. Slug-only entries hide supported reasoning levels, can advertise invalid image inputs, and expose automatic or media-only models in the model picker. Generate complete route-aware metadata, preserve official manifests, filter non-agent models, and compute validators from the final catalog.
This commit is contained in:
@@ -1150,22 +1150,42 @@ func (h *GatewayHandler) CodexModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group)
|
||||
body, err := service.BuildCodexModelsManifest(modelIDs)
|
||||
forcedPlatform := ""
|
||||
if value, exists := middleware2.GetForcePlatformFromContext(c); exists {
|
||||
forcedPlatform = strings.TrimSpace(value)
|
||||
}
|
||||
modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group, forcedPlatform)
|
||||
modelIDs = service.FilterCodexModelIDsForGroup(modelIDs, apiKey.Group)
|
||||
body, err := h.gatewayService.BuildCodexModelsManifestForGroup(
|
||||
c.Request.Context(),
|
||||
apiKey.Group,
|
||||
forcedPlatform,
|
||||
modelIDs,
|
||||
)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
etag := service.CodexModelsManifestETag(body)
|
||||
c.Header("ETag", etag)
|
||||
if service.CodexModelsManifestETagMatches(c.GetHeader("If-None-Match"), etag) {
|
||||
c.Status(http.StatusNotModified)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", body)
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *service.Group) []string {
|
||||
func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *service.Group, platformOverride string) []string {
|
||||
if h == nil || h.gatewayService == nil || group == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
groupID := &group.ID
|
||||
platform := group.Platform
|
||||
platform := strings.TrimSpace(platformOverride)
|
||||
if platform == "" {
|
||||
platform = group.Platform
|
||||
}
|
||||
if platform == service.PlatformComposite {
|
||||
availableModels := h.compositeAvailableModels(ctx, groupID)
|
||||
fallbackModels := defaultModelIDsForPlatform(service.PlatformComposite)
|
||||
|
||||
@@ -27,10 +27,20 @@ type gatewayModelsResponseForTest struct {
|
||||
|
||||
type codexModelsResponseForTest struct {
|
||||
Models []struct {
|
||||
Slug string `json:"slug"`
|
||||
Slug string `json:"slug"`
|
||||
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
ModelMessages map[string]json.RawMessage `json:"model_messages"`
|
||||
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
|
||||
AvailabilityNUX json.RawMessage `json:"availability_nux"`
|
||||
Upgrade json.RawMessage `json:"upgrade"`
|
||||
} `json:"models"`
|
||||
}
|
||||
|
||||
type codexReasoningLevelForTest struct {
|
||||
Effort string `json:"effort"`
|
||||
}
|
||||
|
||||
type gatewayModelItemForTest struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
@@ -79,12 +89,33 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) {
|
||||
// Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list.
|
||||
func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
platform string
|
||||
model string
|
||||
name string
|
||||
platform string
|
||||
model string
|
||||
efforts []string
|
||||
modalities []string
|
||||
}{
|
||||
{name: "Grok", platform: service.PlatformGrok, model: "grok-4.6"},
|
||||
{name: "DeepSeek", platform: service.PlatformDeepseek, model: "deepseek-v4-pro"},
|
||||
{
|
||||
name: "Grok",
|
||||
platform: service.PlatformGrok,
|
||||
model: "grok-4.6",
|
||||
efforts: []string{"low", "medium", "high", "xhigh"},
|
||||
modalities: []string{"text", "image"},
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
platform: service.PlatformDeepseek,
|
||||
model: "deepseek-v4-pro",
|
||||
efforts: []string{"low", "high", "max"},
|
||||
modalities: []string{"text"},
|
||||
},
|
||||
{
|
||||
name: "provider-qualified Claude",
|
||||
platform: service.PlatformAnthropic,
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
efforts: []string{"low", "medium", "high", "max"},
|
||||
modalities: []string{"text"},
|
||||
},
|
||||
}
|
||||
|
||||
for index, tt := range tests {
|
||||
@@ -119,6 +150,12 @@ func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Len(t, got.Models, 1)
|
||||
require.Equal(t, tt.model, got.Models[0].Slug)
|
||||
require.NotEmpty(t, got.Models[0].ModelMessages)
|
||||
require.NotEmpty(t, got.Models[0].TruncationPolicy)
|
||||
require.NotNil(t, got.Models[0].AvailabilityNUX)
|
||||
require.NotNil(t, got.Models[0].Upgrade)
|
||||
require.Equal(t, tt.efforts, codexReasoningEffortsForTest(got.Models[0].SupportedReasoningLevels))
|
||||
require.Equal(t, tt.modalities, got.Models[0].InputModalities)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -163,6 +200,45 @@ func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T
|
||||
require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models))
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 122
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{
|
||||
ID: 1,
|
||||
Platform: service.PlatformDeepseek,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"deepseek-v4-pro": "deepseek-v4-pro"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
group := &service.Group{ID: groupID, Platform: service.PlatformDeepseek}
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
firstContext, _ := gin.CreateTestContext(first)
|
||||
firstContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
firstContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
h.CodexModels(firstContext)
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
etag := first.Header().Get("ETag")
|
||||
require.NotEmpty(t, etag)
|
||||
require.Equal(t, service.CodexModelsManifestETag(first.Body.Bytes()), etag)
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
secondContext, _ := gin.CreateTestContext(second)
|
||||
secondContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
secondContext.Request.Header.Set("If-None-Match", "W/"+etag)
|
||||
secondContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
h.CodexModels(secondContext)
|
||||
|
||||
require.Equal(t, http.StatusNotModified, second.Code)
|
||||
require.Empty(t, second.Body.Bytes())
|
||||
require.Equal(t, etag, second.Header().Get("ETag"))
|
||||
}
|
||||
|
||||
// Scenario: group models_list_config limits the generated Codex manifest.
|
||||
func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
@@ -211,7 +287,13 @@ func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.
|
||||
}
|
||||
|
||||
func codexModelSlugsForTest(models []struct {
|
||||
Slug string `json:"slug"`
|
||||
Slug string `json:"slug"`
|
||||
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
ModelMessages map[string]json.RawMessage `json:"model_messages"`
|
||||
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
|
||||
AvailabilityNUX json.RawMessage `json:"availability_nux"`
|
||||
Upgrade json.RawMessage `json:"upgrade"`
|
||||
}) []string {
|
||||
slugs := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
@@ -220,6 +302,14 @@ func codexModelSlugsForTest(models []struct {
|
||||
return slugs
|
||||
}
|
||||
|
||||
func codexReasoningEffortsForTest(levels []codexReasoningLevelForTest) []string {
|
||||
efforts := make([]string, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
efforts = append(efforts, level.Effort)
|
||||
}
|
||||
return efforts
|
||||
}
|
||||
|
||||
func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -56,7 +56,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
// 让 ops 错误日志携带实际选中的上游账号,便于定位失效账号(#4544)。
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), c.GetHeader("If-None-Match"))
|
||||
ifNoneMatch := c.GetHeader("If-None-Match")
|
||||
// The client ETag represents the final group-specific body, so fetch the
|
||||
// source manifest before applying local filtering and alias metadata.
|
||||
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), "")
|
||||
if err != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
@@ -70,6 +73,14 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
h.errorResponse(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err))
|
||||
return
|
||||
}
|
||||
if err := h.gatewayService.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest")
|
||||
return
|
||||
}
|
||||
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -79,6 +90,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
}
|
||||
if manifest.NotModified {
|
||||
c.Status(http.StatusNotModified)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", manifest.Body)
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -43,6 +44,10 @@ func (r codexModelsFailoverAccountRepo) ListSchedulableByPlatform(_ context.Cont
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (r codexModelsFailoverAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]service.Account, error) {
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
type codexModelsFailoverHTTPUpstream struct {
|
||||
service.HTTPUpstream
|
||||
mu sync.Mutex
|
||||
@@ -116,6 +121,79 @@ func TestCodexModelsCanceledRequestDoesNotWriteResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(43)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "custom-openai",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://upstream.example/v1",
|
||||
"model_mapping": map[string]any{
|
||||
"codex-auto-review": "codex-auto-review",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
upstream := &codexModelsFailoverHTTPUpstream{
|
||||
firstBody: `{"object":"list","data":[{"id":"codex-auto-review"},{"id":"gpt-5.6"}]}`,
|
||||
}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
repo,
|
||||
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
|
||||
group := &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"codex-auto-review", "gpt-5.6"},
|
||||
},
|
||||
}
|
||||
|
||||
first := performCodexModelsRequestForGroup(t, handler, group, "")
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first status: got %d, want %d; body=%s", first.Code, http.StatusOK, first.Body.String())
|
||||
}
|
||||
if body := first.Body.String(); !strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") {
|
||||
t.Fatalf("first body did not include the explicitly selected models: %s", body)
|
||||
}
|
||||
oldETag := first.Header().Get("ETag")
|
||||
if oldETag == "" {
|
||||
t.Fatal("first response did not include an ETag")
|
||||
}
|
||||
|
||||
group.ModelsListConfig.Enabled = false
|
||||
second := performCodexModelsRequestForGroup(t, handler, group, oldETag)
|
||||
if second.Code != http.StatusOK {
|
||||
t.Fatalf("second status: got %d, want %d; body=%s", second.Code, http.StatusOK, second.Body.String())
|
||||
}
|
||||
if body := second.Body.String(); strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") {
|
||||
t.Fatalf("second body was not the filtered manifest: %s", body)
|
||||
}
|
||||
if newETag := second.Header().Get("ETag"); newETag == "" || newETag == oldETag {
|
||||
t.Fatalf("second ETag: got %q, want a new final-body ETag", newETag)
|
||||
}
|
||||
|
||||
third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag"))
|
||||
if third.Code != http.StatusNotModified {
|
||||
t.Fatalf("third status: got %d, want %d; body=%s", third.Code, http.StatusNotModified, third.Body.String())
|
||||
}
|
||||
if third.Body.Len() != 0 {
|
||||
t.Fatalf("third body: got %q, want empty", third.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) {
|
||||
handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusServiceUnavailable)
|
||||
|
||||
@@ -148,9 +226,7 @@ func TestCodexModelsFailsOverFromRetryableUpstreamStatus(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want {
|
||||
t.Fatalf("body: got %q, want %q", got, want)
|
||||
}
|
||||
requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -183,9 +259,7 @@ func TestCodexModelsFailsOverFromInvalidManifestEnvelope(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want {
|
||||
t.Fatalf("body: got %q, want %q", got, want)
|
||||
}
|
||||
requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol")
|
||||
}
|
||||
|
||||
func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) {
|
||||
@@ -300,23 +374,61 @@ func newCodexModelsFailoverTestHandlerWithAccountCount(firstStatus, accountCount
|
||||
}
|
||||
|
||||
func performCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, groupID int64) *httptest.ResponseRecorder {
|
||||
return performCodexModelsRequestForPlatform(t, handler, groupID, service.PlatformOpenAI)
|
||||
return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: service.PlatformOpenAI}, "")
|
||||
}
|
||||
|
||||
func performCodexModelsRequestForPlatform(t *testing.T, handler *OpenAIGatewayHandler, groupID int64, platform string) *httptest.ResponseRecorder {
|
||||
return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: platform}, "")
|
||||
}
|
||||
|
||||
func performCodexModelsRequestForGroup(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.144.0", nil)
|
||||
if etag != "" {
|
||||
c.Request.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, Platform: platform},
|
||||
GroupID: &group.ID,
|
||||
Group: group,
|
||||
})
|
||||
|
||||
handler.CodexModels(c)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.ResponseRecorder, slug string) {
|
||||
t.Helper()
|
||||
|
||||
var envelope struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if len(envelope.Models) != 1 {
|
||||
t.Fatalf("models count: got %d, want 1; body=%s", len(envelope.Models), recorder.Body.String())
|
||||
}
|
||||
model := envelope.Models[0]
|
||||
if got := model["slug"]; got != slug {
|
||||
t.Fatalf("slug: got %v, want %q", got, slug)
|
||||
}
|
||||
if levels, ok := model["supported_reasoning_levels"].([]any); !ok || len(levels) == 0 {
|
||||
t.Fatalf("supported_reasoning_levels must be populated: %v", model["supported_reasoning_levels"])
|
||||
}
|
||||
if messages, ok := model["model_messages"].(map[string]any); !ok || messages["instructions_template"] == "" {
|
||||
t.Fatalf("model_messages.instructions_template must be populated: %v", model["model_messages"])
|
||||
}
|
||||
if policy, ok := model["truncation_policy"].(map[string]any); !ok || len(policy) == 0 {
|
||||
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"])
|
||||
}
|
||||
}
|
||||
|
||||
func equalInt64Slices(got, want []int64) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
effortLowMediumHigh = []string{"low", "medium", "high"}
|
||||
effortLowMediumHighMax = []string{"low", "medium", "high", "max"}
|
||||
effortLowMediumHighXHighMax = []string{"low", "medium", "high", "xhigh", "max"}
|
||||
)
|
||||
|
||||
var effortFamilies = []struct {
|
||||
family string
|
||||
levels []string
|
||||
}{
|
||||
{family: "claude-mythos-preview", levels: effortLowMediumHighMax},
|
||||
{family: "claude-mythos-5", levels: effortLowMediumHighXHighMax},
|
||||
{family: "claude-fable-5", levels: effortLowMediumHighXHighMax},
|
||||
{family: "claude-sonnet-4-6", levels: effortLowMediumHighMax},
|
||||
{family: "claude-sonnet-5", levels: effortLowMediumHighXHighMax},
|
||||
{family: "claude-opus-4-8", levels: effortLowMediumHighXHighMax},
|
||||
{family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax},
|
||||
{family: "claude-opus-4-6", levels: effortLowMediumHighMax},
|
||||
{family: "claude-opus-4-5", levels: effortLowMediumHigh},
|
||||
{family: "claude-opus-5", levels: effortLowMediumHighXHighMax},
|
||||
}
|
||||
|
||||
// EffortLevelsForModel returns the output_config.effort values accepted by a
|
||||
// Claude model, ordered from the lightest to the deepest reasoning level.
|
||||
func EffortLevelsForModel(model string) []string {
|
||||
id := normalizeEffortModelID(model)
|
||||
for _, entry := range effortFamilies {
|
||||
if id == entry.family || strings.HasPrefix(id, entry.family+"-") {
|
||||
return append([]string(nil), entry.levels...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEffortModelID(model string) string {
|
||||
id := strings.ToLower(strings.TrimSpace(model))
|
||||
id = strings.TrimPrefix(id, "models/")
|
||||
if slash := strings.IndexByte(id, '/'); slash >= 0 {
|
||||
id = strings.TrimPrefix(strings.TrimSpace(id[slash+1:]), "models/")
|
||||
}
|
||||
id = strings.TrimPrefix(id, "anthropic.")
|
||||
id = strings.TrimSuffix(id, "-thinking")
|
||||
if mapped, ok := ModelIDReverseOverrides[id]; ok {
|
||||
id = mapped
|
||||
}
|
||||
if len(id) >= 9 {
|
||||
suffix := id[len(id)-9:]
|
||||
if suffix[0] == '-' {
|
||||
digits := true
|
||||
for _, r := range suffix[1:] {
|
||||
if !unicode.IsDigit(r) {
|
||||
digits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if digits {
|
||||
id = id[:len(id)-9]
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEffortLevelsForModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
model string
|
||||
want []string
|
||||
}{
|
||||
{model: "claude-opus-4-6", want: []string{"low", "medium", "high", "max"}},
|
||||
{model: "anthropic/claude-sonnet-4-6", want: []string{"low", "medium", "high", "max"}},
|
||||
{model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
|
||||
{model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}},
|
||||
{model: "claude-haiku-4-5-20251001", want: nil},
|
||||
{model: "gpt-5.6", want: nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.model, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, tt.want, EffortLevelsForModel(tt.model))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,30 @@ func IsGrokModelID(model string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsGrokImagineModel reports whether model is a Grok Imagine image or video
|
||||
// model. These media models cannot act as the primary Codex agent model.
|
||||
func IsGrokImagineModel(model string) bool {
|
||||
normalized := strings.ToLower(StripGrokProviderPrefix(model))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(normalized, "imagine") {
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case normalized == "grok-imagine",
|
||||
normalized == "grok-imagine-1",
|
||||
normalized == "grok-imagine-edit",
|
||||
normalized == "grok-video-1.5":
|
||||
return true
|
||||
case strings.HasPrefix(normalized, "grok-imagine-image"),
|
||||
strings.HasPrefix(normalized, "grok-imagine-video"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsGrokTextResponsesModelID reports whether model is a known Grok text model
|
||||
// for the Responses API. Imagine image/video and unknown custom ids return false.
|
||||
func IsGrokTextResponsesModelID(model string) bool {
|
||||
|
||||
@@ -58,6 +58,16 @@ func TestIsGrokModelID(t *testing.T) {
|
||||
require.False(t, IsGrokModelID("claude-sonnet-4"))
|
||||
}
|
||||
|
||||
func TestIsGrokImagineModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.True(t, IsGrokImagineModel("grok-imagine-image"))
|
||||
require.True(t, IsGrokImagineModel("grok-imagine-video-1.5-preview"))
|
||||
require.True(t, IsGrokImagineModel("xai/grok-imagine-image-quality"))
|
||||
require.True(t, IsGrokImagineModel("grok-video-1.5"))
|
||||
require.False(t, IsGrokImagineModel("grok-4.6"))
|
||||
require.False(t, IsGrokImagineModel("grok-build-0.1"))
|
||||
}
|
||||
|
||||
func TestDefaultModelsIncludesGrok46(t *testing.T) {
|
||||
t.Parallel()
|
||||
ids := DefaultModelIDs()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,32 +24,766 @@ import (
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// Scenario: non-OpenAI groups expose their effective model IDs as a Codex manifest.
|
||||
func TestBuildCodexModelsManifest(t *testing.T) {
|
||||
body, err := BuildCodexModelsManifest([]string{
|
||||
" grok-4.6 ",
|
||||
"deepseek-v4-pro",
|
||||
"grok-4.6",
|
||||
"",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var got struct {
|
||||
Models []struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"models"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &got))
|
||||
require.Equal(t, []string{"grok-4.6", "deepseek-v4-pro"}, []string{
|
||||
got.Models[0].Slug,
|
||||
got.Models[1].Slug,
|
||||
})
|
||||
}
|
||||
|
||||
type codexModelsHTTPUpstreamStub struct {
|
||||
do func(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error)
|
||||
}
|
||||
|
||||
type codexModelsVisibilityAccountRepo struct {
|
||||
AccountRepository
|
||||
byGroup map[int64][]Account
|
||||
}
|
||||
|
||||
func (r codexModelsVisibilityAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) {
|
||||
accounts := r.byGroup[groupID]
|
||||
return append([]Account(nil), accounts...), nil
|
||||
}
|
||||
|
||||
type countingCodexModelsAccountRepo struct {
|
||||
AccountRepository
|
||||
accounts []Account
|
||||
err error
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) {
|
||||
r.calls.Add(1)
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
return append([]Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
func decodeCodexManifestModels(t *testing.T, body []byte) []map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var envelope struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(body, &envelope))
|
||||
return envelope.Models
|
||||
}
|
||||
|
||||
func codexManifestModelSlugs(t *testing.T, body []byte) []string {
|
||||
t.Helper()
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
slugs := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
slug, ok := model["slug"].(string)
|
||||
require.True(t, ok)
|
||||
slugs = append(slugs, slug)
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
func requireCompleteConfiguredCodexModel(t *testing.T, model map[string]any, slug string) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, slug, model["slug"])
|
||||
require.NotEmpty(t, model["display_name"])
|
||||
require.NotEmpty(t, model["description"])
|
||||
require.Equal(t, "unified_exec", model["shell_type"])
|
||||
require.Equal(t, "list", model["visibility"])
|
||||
require.Equal(t, true, model["supported_in_api"])
|
||||
require.NotNil(t, model["priority"])
|
||||
require.Equal(t, []any{}, model["additional_speed_tiers"])
|
||||
require.Equal(t, []any{}, model["service_tiers"])
|
||||
require.Contains(t, model, "default_service_tier")
|
||||
require.Contains(t, model, "availability_nux")
|
||||
require.Contains(t, model, "upgrade")
|
||||
require.Contains(t, model, "default_verbosity")
|
||||
require.Contains(t, model, "apply_patch_tool_type")
|
||||
require.Contains(t, model, "auto_compact_token_limit")
|
||||
require.Contains(t, model, "comp_hash")
|
||||
require.Contains(t, model, "auto_review_model_override")
|
||||
require.Contains(t, model, "model_specialty")
|
||||
require.Contains(t, model, "tool_mode")
|
||||
require.Contains(t, model, "multi_agent_version")
|
||||
require.Equal(t, true, model["supports_reasoning_summary_parameter"])
|
||||
require.Contains(t, model, "include_skills_usage_instructions")
|
||||
require.Contains(t, model, "include_plugin_usage_instructions")
|
||||
require.Contains(t, model, "include_apps_usage_instructions")
|
||||
require.Contains(t, model, "supports_image_detail_original")
|
||||
require.Contains(t, model, "node_repl_auto_review_required")
|
||||
require.Contains(t, model, "node_repl_disabled")
|
||||
require.Contains(t, model, "truncation_policy")
|
||||
require.Contains(t, model, "supports_parallel_tool_calls")
|
||||
require.Contains(t, model, "experimental_supported_tools")
|
||||
modelMessages, ok := model["model_messages"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, modelMessages["instructions_template"])
|
||||
for _, key := range []string{
|
||||
"instructions_variables",
|
||||
"approvals",
|
||||
"collaboration_modes",
|
||||
"auto_review",
|
||||
"permissions",
|
||||
"multi_agent",
|
||||
"token_budget",
|
||||
"guardian_v2",
|
||||
} {
|
||||
require.Contains(t, modelMessages, key)
|
||||
}
|
||||
}
|
||||
|
||||
func effortsFromManifestModel(t *testing.T, model map[string]any) []string {
|
||||
t.Helper()
|
||||
|
||||
levels, ok := model["supported_reasoning_levels"].([]any)
|
||||
require.True(t, ok)
|
||||
efforts := make([]string, 0, len(levels))
|
||||
for _, rawLevel := range levels {
|
||||
level, ok := rawLevel.(map[string]any)
|
||||
require.True(t, ok)
|
||||
effort, ok := level["effort"].(string)
|
||||
require.True(t, ok)
|
||||
efforts = append(efforts, effort)
|
||||
}
|
||||
return efforts
|
||||
}
|
||||
|
||||
func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deepSeek := newConfiguredCodexModelDescriptor("deepseek-v4-pro")
|
||||
require.Equal(t, "DeepSeek V4 Pro", deepSeek.DisplayName)
|
||||
require.Equal(t, int64(1_000_000), deepSeek.ContextWindow)
|
||||
require.Equal(t, int64(1_000_000), deepSeek.MaxContextWindow)
|
||||
require.NotNil(t, deepSeek.DefaultReasoningLevel)
|
||||
require.Equal(t, "high", *deepSeek.DefaultReasoningLevel)
|
||||
require.Equal(t, []configuredCodexReasoningLevel{
|
||||
{Effort: "low", Description: "Fast responses with lighter reasoning"},
|
||||
{Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"},
|
||||
{Effort: "max", Description: "Maximum reasoning depth for complex tasks"},
|
||||
}, deepSeek.SupportedReasoningLevels)
|
||||
require.True(t, deepSeek.SupportsParallelToolCalls)
|
||||
require.Equal(t, []string{"text"}, deepSeek.InputModalities)
|
||||
|
||||
grok := newConfiguredCodexModelDescriptor("grok-4.6")
|
||||
require.Equal(t, "Grok 4.6", grok.DisplayName)
|
||||
require.Equal(t, int64(500_000), grok.ContextWindow)
|
||||
require.Equal(t, int64(500_000), grok.MaxContextWindow)
|
||||
require.NotNil(t, grok.DefaultReasoningLevel)
|
||||
require.Equal(t, "high", *grok.DefaultReasoningLevel)
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grok.SupportedReasoningLevels))
|
||||
require.True(t, grok.SupportsParallelToolCalls)
|
||||
require.Equal(t, []string{"text"}, grok.InputModalities)
|
||||
require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
|
||||
require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"})
|
||||
|
||||
grokAlias := newConfiguredCodexModelDescriptor("xai/grok-4.6-latest")
|
||||
require.Equal(t, "Grok 4.6", grokAlias.DisplayName)
|
||||
require.NotNil(t, grokAlias.DefaultReasoningLevel)
|
||||
require.Equal(t, "high", *grokAlias.DefaultReasoningLevel)
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grokAlias.SupportedReasoningLevels))
|
||||
|
||||
grok45 := newConfiguredCodexModelDescriptor("grok-4.5")
|
||||
require.Equal(t, []string{"low", "medium", "high"}, effortsFromConfiguredCodexLevels(grok45.SupportedReasoningLevels))
|
||||
|
||||
grokNonReasoning := newConfiguredCodexModelDescriptor("grok-4.20-0309-non-reasoning")
|
||||
require.Equal(t, "Grok 4.20 Non Reasoning", grokNonReasoning.DisplayName)
|
||||
require.Nil(t, grokNonReasoning.DefaultReasoningLevel)
|
||||
require.Empty(t, grokNonReasoning.SupportedReasoningLevels)
|
||||
|
||||
claude := newConfiguredCodexModelDescriptor("claude-opus-4-6")
|
||||
require.Equal(t, "Claude Opus 4.6", claude.DisplayName)
|
||||
require.NotNil(t, claude.DefaultReasoningLevel)
|
||||
require.Equal(t, "medium", *claude.DefaultReasoningLevel)
|
||||
require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(claude.SupportedReasoningLevels))
|
||||
require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "xhigh"})
|
||||
require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
|
||||
require.True(t, claude.SupportsParallelToolCalls)
|
||||
|
||||
claudeOpus5 := newConfiguredCodexModelDescriptor("claude-opus-5")
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeOpus5.SupportedReasoningLevels))
|
||||
|
||||
providerQualifiedClaude := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-4-6")
|
||||
require.Equal(t, "Claude Sonnet 4.6", providerQualifiedClaude.DisplayName)
|
||||
require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(providerQualifiedClaude.SupportedReasoningLevels))
|
||||
|
||||
claudeHaiku := newConfiguredCodexModelDescriptor("claude-haiku-4-5-20251001")
|
||||
require.Equal(t, "Claude Haiku 4.5", claudeHaiku.DisplayName)
|
||||
require.Nil(t, claudeHaiku.DefaultReasoningLevel)
|
||||
require.Empty(t, claudeHaiku.SupportedReasoningLevels)
|
||||
|
||||
gpt56 := newConfiguredCodexModelDescriptor("gpt-5.6-sol")
|
||||
require.Equal(t, "GPT-5.6 Sol", gpt56.DisplayName)
|
||||
require.Equal(t, "OpenAI GPT coding model routed through Sub2API.", gpt56.Description)
|
||||
require.NotNil(t, gpt56.DefaultReasoningLevel)
|
||||
require.Equal(t, "low", *gpt56.DefaultReasoningLevel)
|
||||
require.Equal(t, configuredCodexGPTReasoningLevels("gpt-5.6-sol"), gpt56.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt56.SupportedReasoningLevels))
|
||||
require.True(t, gpt56.SupportsParallelToolCalls)
|
||||
require.True(t, gpt56.SupportVerbosity)
|
||||
require.Equal(t, []string{"text"}, gpt56.InputModalities)
|
||||
require.Equal(t, int64(872_000), gpt56.MaxContextWindow)
|
||||
require.Equal(t, configuredCodexTruncationPolicy{Mode: "tokens", Limit: 10_000}, gpt56.TruncationPolicy)
|
||||
require.NotNil(t, gpt56.DefaultVerbosity)
|
||||
require.Equal(t, "low", *gpt56.DefaultVerbosity)
|
||||
require.True(t, gpt56.SupportsReasoningSummaryParameter)
|
||||
require.Equal(t, "none", gpt56.DefaultReasoningSummary)
|
||||
|
||||
gpt56Luna := newConfiguredCodexModelDescriptor("gpt-5.6-luna")
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt56Luna.SupportedReasoningLevels))
|
||||
require.Equal(t, "medium", *gpt56Luna.DefaultReasoningLevel)
|
||||
|
||||
gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5")
|
||||
require.Equal(t, "GPT-5.5", gpt55.DisplayName)
|
||||
require.NotNil(t, gpt55.DefaultReasoningLevel)
|
||||
require.Equal(t, "medium", *gpt55.DefaultReasoningLevel)
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(gpt55.SupportedReasoningLevels))
|
||||
require.NotContains(t, gpt55.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"})
|
||||
require.NotNil(t, gpt55.DefaultVerbosity)
|
||||
require.Equal(t, "low", *gpt55.DefaultVerbosity)
|
||||
|
||||
gpt4o := newConfiguredCodexModelDescriptor("gpt-4o")
|
||||
require.Equal(t, "gpt-4o", gpt4o.DisplayName)
|
||||
require.Nil(t, gpt4o.DefaultReasoningLevel)
|
||||
require.Empty(t, gpt4o.SupportedReasoningLevels)
|
||||
require.True(t, gpt4o.SupportsParallelToolCalls)
|
||||
|
||||
image := newConfiguredCodexModelDescriptor("gpt-image-2")
|
||||
require.Equal(t, "gpt-image-2", image.DisplayName)
|
||||
require.Nil(t, image.DefaultReasoningLevel)
|
||||
require.Empty(t, image.SupportedReasoningLevels)
|
||||
|
||||
custom := newConfiguredCodexModelDescriptor("company-coding-model")
|
||||
require.Equal(t, "company-coding-model", custom.DisplayName)
|
||||
require.Equal(t, int64(272_000), custom.ContextWindow)
|
||||
require.Nil(t, custom.DefaultReasoningLevel)
|
||||
require.Empty(t, custom.SupportedReasoningLevels)
|
||||
require.False(t, custom.SupportsParallelToolCalls)
|
||||
require.NotEmpty(t, custom.ModelMessages.InstructionsTemplate)
|
||||
require.Equal(t, "auto", custom.DefaultReasoningSummary)
|
||||
require.Equal(t, configuredCodexTruncationPolicy{Mode: "bytes", Limit: 10_000}, custom.TruncationPolicy)
|
||||
}
|
||||
|
||||
func effortsFromConfiguredCodexLevels(levels []configuredCodexReasoningLevel) []string {
|
||||
efforts := make([]string, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
efforts = append(efforts, level.Effort)
|
||||
}
|
||||
return efforts
|
||||
}
|
||||
|
||||
// Scenario: 专用图片生成模型不进入 Codex 主模型目录。
|
||||
func TestBuildCodexModelsManifestOmitsDedicatedImageModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body, err := BuildCodexModelsManifest([]string{
|
||||
"grok-4.6",
|
||||
"gpt-image-1",
|
||||
"gpt-image-1.5",
|
||||
"gpt-image-2",
|
||||
"openai/gpt-image-2",
|
||||
"gemini-2.5-flash-image",
|
||||
"gemini-3.1-flash-image-preview",
|
||||
"gemini-3-pro-image",
|
||||
"google/gemini-3-pro-image",
|
||||
"google/models/gemini-2.5-flash-image-preview",
|
||||
"grok-imagine-image",
|
||||
"grok-imagine-video",
|
||||
"xai/grok-imagine-image-quality",
|
||||
"grok-4.5",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
slugs := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
slug, _ := model["slug"].(string)
|
||||
slugs = append(slugs, slug)
|
||||
}
|
||||
require.Equal(t, []string{"grok-4.6", "grok-4.5"}, slugs)
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupAdvertisesOfficialGrokResponsesImageInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 701
|
||||
svc := &GatewayService{
|
||||
accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {{
|
||||
ID: 1,
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{"grok-4.5"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 702
|
||||
svc := &GatewayService{
|
||||
accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {{
|
||||
ID: 2,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{"gpt-5.6-sol"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupUsesConservativeProviderImageCapabilities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
accounts []Account
|
||||
modalities []any
|
||||
}{
|
||||
{
|
||||
name: "official Grok 4.6",
|
||||
model: "grok-4.6",
|
||||
accounts: []Account{{
|
||||
ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
||||
}},
|
||||
modalities: []any{"text", "image"},
|
||||
},
|
||||
{
|
||||
name: "official Grok Build vision host",
|
||||
model: "grok-build-0.1",
|
||||
accounts: []Account{{
|
||||
ID: 11, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
||||
}},
|
||||
modalities: []any{"text", "image"},
|
||||
},
|
||||
{
|
||||
name: "official Grok 4.20 vision model",
|
||||
model: "grok-4.20-0309-reasoning",
|
||||
accounts: []Account{{
|
||||
ID: 23, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
||||
}},
|
||||
modalities: []any{"text", "image"},
|
||||
},
|
||||
{
|
||||
name: "Grok 3 Mini is text only",
|
||||
model: "grok-3-mini",
|
||||
accounts: []Account{{
|
||||
ID: 24, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
||||
}},
|
||||
modalities: []any{"text"},
|
||||
},
|
||||
{
|
||||
name: "Grok Composer has only Chat image bridge",
|
||||
model: "grok-composer-2.5-fast",
|
||||
accounts: []Account{{
|
||||
ID: 12, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
||||
}},
|
||||
modalities: []any{"text"},
|
||||
},
|
||||
{
|
||||
name: "custom Grok host",
|
||||
model: "grok-4.5",
|
||||
accounts: []Account{{
|
||||
ID: 13, Platform: PlatformGrok, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://relay.example.test/v1"},
|
||||
}},
|
||||
modalities: []any{"text"},
|
||||
},
|
||||
{
|
||||
name: "malformed Grok host",
|
||||
model: "grok-4.5",
|
||||
accounts: []Account{{
|
||||
ID: 19, Platform: PlatformGrok, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "::invalid::url"},
|
||||
}},
|
||||
modalities: []any{"text"},
|
||||
},
|
||||
{
|
||||
name: "mixed official and custom Grok candidates",
|
||||
model: "grok-4.5",
|
||||
accounts: []Account{
|
||||
{ID: 14, Platform: PlatformGrok, Type: AccountTypeOAuth},
|
||||
{
|
||||
ID: 15, Platform: PlatformGrok, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://relay.example.test/v1"},
|
||||
},
|
||||
},
|
||||
modalities: []any{"text"},
|
||||
},
|
||||
{
|
||||
name: "DeepSeek V4",
|
||||
model: "deepseek-v4-pro",
|
||||
accounts: []Account{{
|
||||
ID: 16, Platform: PlatformDeepseek, Type: AccountTypeAPIKey,
|
||||
}},
|
||||
modalities: []any{"text"},
|
||||
},
|
||||
{
|
||||
name: "official OpenAI API key",
|
||||
model: "gpt-5.6-sol",
|
||||
accounts: []Account{{
|
||||
ID: 17, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
}},
|
||||
modalities: []any{"text", "image"},
|
||||
},
|
||||
{
|
||||
name: "official OpenAI legacy text model",
|
||||
model: "gpt-3.5-turbo",
|
||||
accounts: []Account{{
|
||||
ID: 20, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
}},
|
||||
modalities: []any{"text"},
|
||||
},
|
||||
{
|
||||
name: "custom OpenAI-compatible host",
|
||||
model: "gpt-5.6-sol",
|
||||
accounts: []Account{{
|
||||
ID: 18, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
|
||||
}},
|
||||
modalities: []any{"text"},
|
||||
},
|
||||
}
|
||||
|
||||
for i, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
groupID := int64(710 + i)
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: tt.accounts,
|
||||
}}}
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{tt.model},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, tt.modalities, models[0]["input_modalities"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupUsesExplicitCompositeResponsesRouteModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 730
|
||||
routeRepo := compositeRouteRepoStub{routes: []CompositeModelRoute{{
|
||||
ID: 1,
|
||||
GroupID: groupID,
|
||||
PublicModel: "vision-alias",
|
||||
MatchType: CompositeRouteMatchExact,
|
||||
TargetPlatform: PlatformGrok,
|
||||
UpstreamModel: "grok-4.5",
|
||||
Endpoint: CompositeRouteEndpointResponses,
|
||||
Enabled: true,
|
||||
}}}
|
||||
svc := &GatewayService{
|
||||
accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {{ID: 20, Platform: PlatformGrok, Type: AccountTypeOAuth}},
|
||||
}},
|
||||
compositeResolver: NewCompositeRouteResolver(routeRepo),
|
||||
}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{"vision-alias"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupUsesAccountMappingOwnershipAndMappedModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 731
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {{
|
||||
ID: 21,
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"vision-alias": "grok-4.5"},
|
||||
},
|
||||
}},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{"vision-alias"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 732
|
||||
repo := &countingCodexModelsAccountRepo{accounts: []Account{{
|
||||
ID: 22,
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"vision-alias-a": "grok-4.5",
|
||||
"vision-alias-b": "grok-4.6",
|
||||
},
|
||||
},
|
||||
}}}
|
||||
svc := &GatewayService{accountRepo: repo}
|
||||
_, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{"vision-alias-a", "vision-alias-b", "deepseek-v4-pro"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(1), repo.calls.Load())
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupSkipsCapabilityLookupForTextOnlyPlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := &countingCodexModelsAccountRepo{}
|
||||
svc := &GatewayService{accountRepo: repo}
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: 733, Platform: PlatformDeepseek},
|
||||
"",
|
||||
[]string{"deepseek-v4-pro"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(0), repo.calls.Load())
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupFallsBackWhenCapabilityLookupFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := &countingCodexModelsAccountRepo{err: errors.New("account repository unavailable")}
|
||||
svc := &GatewayService{accountRepo: repo}
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: 734, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{"gpt-5.6-sol", "grok-4.5"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(1), repo.calls.Load())
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 2)
|
||||
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
||||
require.Equal(t, []any{"text"}, models[1]["input_modalities"])
|
||||
}
|
||||
|
||||
func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 71
|
||||
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
|
||||
byGroup: map[int64][]Account{
|
||||
groupID: {
|
||||
{
|
||||
Platform: PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"deepseek-4-pro": "deepseek-v4-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
72: {
|
||||
{
|
||||
Platform: PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"other-group-model": "upstream-model"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
manifest := &CodexModelsManifest{
|
||||
Body: []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6","unknown":{"kept":true}}],"metadata":{"version":1}}`),
|
||||
}
|
||||
|
||||
err := svc.MergeGroupConfiguredCodexModels(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformOpenAI},
|
||||
manifest,
|
||||
"",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, manifest.Body)
|
||||
require.Len(t, models, 2)
|
||||
require.Equal(t, "gpt-5.6", models[0]["slug"])
|
||||
require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"])
|
||||
requireCompleteConfiguredCodexModel(t, models[1], "deepseek-4-pro")
|
||||
require.EqualValues(t, 1_000_000, models[1]["context_window"])
|
||||
require.EqualValues(t, 1_000_000, models[1]["max_context_window"])
|
||||
require.Equal(t, "high", models[1]["default_reasoning_level"])
|
||||
require.Len(t, models[1]["supported_reasoning_levels"], 3)
|
||||
require.NotContains(t, string(manifest.Body), "other-group-model")
|
||||
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
||||
}
|
||||
|
||||
func TestMergeGroupConfiguredCodexModelsFiltersAutoReviewByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 74
|
||||
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}}
|
||||
manifest := &CodexModelsManifest{
|
||||
Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"codex-auto-future","visibility":"list"},{"slug":"gpt-image-2","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`),
|
||||
}
|
||||
|
||||
require.NoError(t, svc.MergeGroupConfiguredCodexModels(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformOpenAI},
|
||||
manifest,
|
||||
"",
|
||||
))
|
||||
models := decodeCodexManifestModels(t, manifest.Body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "gpt-5.6", models[0]["slug"])
|
||||
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
||||
}
|
||||
|
||||
// Scenario: OpenAI 账号映射不启用 Auto Review。
|
||||
func TestMergeGroupConfiguredCodexModelsFiltersAccountMappedAutoReviewByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 75
|
||||
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
|
||||
byGroup: map[int64][]Account{
|
||||
groupID: {
|
||||
{
|
||||
Platform: PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
openai.CodexUsageProbeModel: openai.CodexUsageProbeModel,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
manifest := &CodexModelsManifest{
|
||||
Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"hide","model_messages":{"auto_review":{"enabled":true}}},{"slug":"gpt-5.6","visibility":"list"}]}`),
|
||||
}
|
||||
|
||||
require.NoError(t, svc.MergeGroupConfiguredCodexModels(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformOpenAI},
|
||||
manifest,
|
||||
"",
|
||||
))
|
||||
require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body))
|
||||
}
|
||||
|
||||
// Scenario: 启用的分组自定义列表允许 Auto Review。
|
||||
func TestMergeGroupConfiguredCodexModelsKeepsExplicitAutoReviewSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 76
|
||||
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}}
|
||||
manifest := &CodexModelsManifest{
|
||||
Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`),
|
||||
}
|
||||
group := &Group{
|
||||
ID: groupID,
|
||||
Platform: PlatformOpenAI,
|
||||
ModelsListConfig: GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{openai.CodexUsageProbeModel},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, ""))
|
||||
require.Equal(t, []string{"codex-auto-review"}, codexManifestModelSlugs(t, manifest.Body))
|
||||
}
|
||||
|
||||
func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 73
|
||||
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
|
||||
byGroup: map[int64][]Account{
|
||||
groupID: {
|
||||
{
|
||||
Platform: PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"deepseek-4-pro": "deepseek-v4-pro",
|
||||
"hidden-alias": "hidden-upstream",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
group := &Group{
|
||||
ID: groupID,
|
||||
Platform: PlatformOpenAI,
|
||||
ModelsListConfig: GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"deepseek-4-pro"},
|
||||
},
|
||||
}
|
||||
upstreamBody := []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6"}]}`)
|
||||
manifest := &CodexModelsManifest{Body: upstreamBody}
|
||||
|
||||
require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, ""))
|
||||
models := decodeCodexManifestModels(t, manifest.Body)
|
||||
require.Len(t, models, 1)
|
||||
requireCompleteConfiguredCodexModel(t, models[0], "deepseek-4-pro")
|
||||
|
||||
finalETag := manifest.ETag
|
||||
second := &CodexModelsManifest{Body: upstreamBody}
|
||||
require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, finalETag))
|
||||
require.True(t, second.NotModified)
|
||||
require.Empty(t, second.Body)
|
||||
require.Equal(t, finalETag, second.ETag)
|
||||
}
|
||||
|
||||
type codexModelsBlockingBody struct {
|
||||
ctx context.Context
|
||||
readStarted chan struct{}
|
||||
@@ -429,7 +1163,7 @@ func TestFetchCodexModelsManifestMissingToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) {
|
||||
manifestBody := `{"models":[{"slug":"gpt-5.6"}]}`
|
||||
manifestBody := `{"models":[{"slug":"deepseek-v4-pro"}]}`
|
||||
var gotRequest *http.Request
|
||||
var gotProxyURL string
|
||||
var gotAccountID int64
|
||||
@@ -486,12 +1220,13 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) {
|
||||
if gotProxyURL != "" || gotAccountID != 2 || gotConcurrency != 3 {
|
||||
t.Errorf("upstream routing metadata: proxy=%q account_id=%d concurrency=%d", gotProxyURL, gotAccountID, gotConcurrency)
|
||||
}
|
||||
if string(manifest.Body) != manifestBody {
|
||||
t.Errorf("body not passed through verbatim: got %q", manifest.Body)
|
||||
}
|
||||
if manifest.ETag != `W/"api-key-manifest"` {
|
||||
t.Errorf("etag not passed through: got %q", manifest.ETag)
|
||||
}
|
||||
models := decodeCodexManifestModels(t, manifest.Body)
|
||||
require.Len(t, models, 1)
|
||||
requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-pro")
|
||||
require.Equal(t, "DeepSeek V4 Pro", models[0]["display_name"])
|
||||
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
||||
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
||||
require.Equal(t, `W/"api-key-manifest"`, manifest.upstreamETag)
|
||||
}
|
||||
|
||||
func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testing.T) {
|
||||
@@ -516,13 +1251,96 @@ func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testin
|
||||
if err != nil {
|
||||
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
||||
}
|
||||
if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"},{"slug":"gpt-5.6-codex"}]}`; got != want {
|
||||
t.Errorf("converted body: got %q, want %q", got, want)
|
||||
}
|
||||
models := decodeCodexManifestModels(t, manifest.Body)
|
||||
require.Len(t, models, 2)
|
||||
requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6")
|
||||
requireCompleteConfiguredCodexModel(t, models[1], "gpt-5.6-codex")
|
||||
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
||||
require.Equal(t, `W/"openai-list"`, manifest.upstreamETag)
|
||||
}
|
||||
|
||||
func TestConvertOpenAIModelListToCodexManifestUsesCompleteDescriptors(t *testing.T) {
|
||||
upstreamBody := `{"object":"list","data":[{"id":"gpt-5.5","object":"model"}]}`
|
||||
|
||||
converted := convertOpenAIModelListToCodexManifest([]byte(upstreamBody))
|
||||
models := decodeCodexManifestModels(t, converted)
|
||||
|
||||
require.Len(t, models, 1)
|
||||
requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.5")
|
||||
require.Equal(t, "GPT-5.5", models[0]["display_name"])
|
||||
require.Equal(t, "medium", models[0]["default_reasoning_level"])
|
||||
require.Len(t, models[0]["supported_reasoning_levels"], 4)
|
||||
}
|
||||
|
||||
func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
manifest := &CodexModelsManifest{
|
||||
Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`),
|
||||
}
|
||||
account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
|
||||
|
||||
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
|
||||
models := decodeCodexManifestModels(t, manifest.Body)
|
||||
require.Len(t, models, 1)
|
||||
requireCompleteConfiguredCodexModel(t, models[0], "grok-4.6")
|
||||
require.Equal(t, "Provider supplied", models[0]["description"])
|
||||
require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"])
|
||||
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0]))
|
||||
modelMessages, ok := models[0]["model_messages"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, modelMessages["instructions_template"])
|
||||
require.Equal(t, map[string]any{"enabled": true}, modelMessages["auto_review"])
|
||||
require.Equal(t, map[string]any{"mode": "tokens", "limit": float64(10_000)}, models[0]["truncation_policy"])
|
||||
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
||||
|
||||
var envelope map[string]any
|
||||
require.NoError(t, json.Unmarshal(manifest.Body, &envelope))
|
||||
require.Equal(t, map[string]any{"source": "upstream"}, envelope["metadata"])
|
||||
}
|
||||
|
||||
func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTImageInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o"},{"slug":"gpt-3.5-turbo"},{"slug":"gpt-4"}]}`)}
|
||||
account := newCodexModelsAPIKeyTestAccount("")
|
||||
|
||||
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
|
||||
models := decodeCodexManifestModels(t, manifest.Body)
|
||||
require.Len(t, models, 4)
|
||||
|
||||
bySlug := make(map[string]map[string]any, len(models))
|
||||
for _, model := range models {
|
||||
slug, ok := model["slug"].(string)
|
||||
require.True(t, ok)
|
||||
bySlug[slug] = model
|
||||
}
|
||||
for _, slug := range []string{"gpt-5.6-sol", "gpt-4o"} {
|
||||
require.Equal(t, []any{"text", "image"}, bySlug[slug]["input_modalities"])
|
||||
require.Equal(t, true, bySlug[slug]["supports_image_detail_original"])
|
||||
}
|
||||
for _, slug := range []string{"gpt-3.5-turbo", "gpt-4"} {
|
||||
require.Equal(t, []any{"text"}, bySlug[slug]["input_modalities"])
|
||||
require.Equal(t, false, bySlug[slug]["supports_image_detail_original"])
|
||||
}
|
||||
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
||||
}
|
||||
|
||||
func TestCompleteAPIKeyCodexModelsManifestForClientFiltersOfficialNonAgentModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &OpenAIGatewayService{}
|
||||
manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o-realtime-preview"},{"slug":"gpt-4o-mini-tts"},{"slug":"text-embedding-3-large"},{"slug":"omni-moderation-latest"},{"slug":"o4-mini"},{"slug":"codex-mini-latest"}]}`)}
|
||||
account := newCodexModelsAPIKeyTestAccount("")
|
||||
|
||||
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
|
||||
require.Equal(t, []string{"gpt-5.6-sol", "o4-mini", "codex-mini-latest"}, codexManifestModelSlugs(t, manifest.Body))
|
||||
require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
|
||||
}
|
||||
|
||||
func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -594,17 +1412,27 @@ func TestFetchCodexModelsManifestOAuthPreservesResponsesLite(t *testing.T) {
|
||||
require.Equal(t, manifestBody, string(manifest.Body))
|
||||
}
|
||||
|
||||
func TestConvertOpenAIModelListToCodexManifest(t *testing.T) {
|
||||
func TestConvertOpenAIModelListToCompleteCodexManifest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := []byte(`{"object":"list","data":[{"id":"deepseek-v4-flash"},{"id":"deepseek-v4-pro"}]}`)
|
||||
models := decodeCodexManifestModels(t, convertOpenAIModelListToCodexManifest(body))
|
||||
|
||||
require.Len(t, models, 2)
|
||||
requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-flash")
|
||||
requireCompleteConfiguredCodexModel(t, models[1], "deepseek-v4-pro")
|
||||
require.Equal(t, "DeepSeek V4 Flash", models[0]["display_name"])
|
||||
require.Equal(t, "DeepSeek V4 Pro", models[1]["display_name"])
|
||||
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
||||
require.EqualValues(t, 1_000_000, models[1]["context_window"])
|
||||
}
|
||||
|
||||
func TestConvertOpenAIModelListToCodexManifestLeavesUnsupportedBodiesUnchanged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "standard list",
|
||||
body: `{"object":"list","data":[{"id":"m-1"},{"id":"m-2"}]}`,
|
||||
want: `{"models":[{"slug":"m-1"},{"slug":"m-2"}]}`,
|
||||
},
|
||||
{
|
||||
name: "codex manifest unchanged",
|
||||
body: `{"models":[{"slug":"m-1"}]}`,
|
||||
@@ -1439,7 +2267,7 @@ func TestFetchCodexModelsManifestAPIKeyUpstreamError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCodexModelsManifestAPIKeyRejectsOfficialOpenAIBaseURL(t *testing.T) {
|
||||
func TestFetchCodexModelsManifestAPIKeyUsesOfficialOpenAIModelsEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseURL string
|
||||
@@ -1451,23 +2279,32 @@ func TestFetchCodexModelsManifestAPIKeyRejectsOfficialOpenAIBaseURL(t *testing.T
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
t.Fatal("official OpenAI API key must not be used as a Codex manifest upstream")
|
||||
return nil, nil
|
||||
var gotURL string
|
||||
s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||
gotURL = req.URL.String()
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol"}]}`)),
|
||||
}, nil
|
||||
}})
|
||||
|
||||
_, err := s.FetchCodexModelsManifest(
|
||||
manifest, err := s.FetchCodexModelsManifest(
|
||||
context.Background(),
|
||||
newCodexModelsAPIKeyTestAccount(tt.baseURL),
|
||||
"0.144.0",
|
||||
"",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported API key upstream error, got nil")
|
||||
}
|
||||
if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_UNSUPPORTED" {
|
||||
t.Errorf("error reason: got %q", infraerrors.Reason(err))
|
||||
}
|
||||
require.NoError(t, err)
|
||||
parsedURL, parseErr := url.Parse(gotURL)
|
||||
require.NoError(t, parseErr)
|
||||
require.Equal(t, "api.openai.com", strings.ToLower(parsedURL.Hostname()))
|
||||
require.Equal(t, "/v1/models", parsedURL.Path)
|
||||
require.Equal(t, "0.144.0", parsedURL.Query().Get("client_version"))
|
||||
models := decodeCodexManifestModels(t, manifest.Body)
|
||||
require.Len(t, models, 1)
|
||||
requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6-sol")
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,34 @@ describe('Codex models API', () => {
|
||||
})
|
||||
|
||||
it('fetches a manifest with the current API key without adding it to the catalog', async () => {
|
||||
const manifest = {
|
||||
models: [
|
||||
{
|
||||
slug: 'grok-4.6',
|
||||
default_reasoning_level: 'high',
|
||||
supported_reasoning_levels: [
|
||||
{ effort: 'low', description: 'Fast responses' },
|
||||
{ effort: 'xhigh', description: 'Extra-high reasoning depth' }
|
||||
],
|
||||
input_modalities: ['text', 'image'],
|
||||
model_messages: { instructions_template: 'Use the routed model.' }
|
||||
},
|
||||
{
|
||||
slug: 'deepseek-v4-pro',
|
||||
default_reasoning_level: 'high',
|
||||
supported_reasoning_levels: [
|
||||
{ effort: 'low', description: 'Fast responses' },
|
||||
{ effort: 'max', description: 'Maximum reasoning depth' }
|
||||
],
|
||||
input_modalities: ['text'],
|
||||
model_messages: { instructions_template: 'Use the routed model.' }
|
||||
}
|
||||
]
|
||||
}
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ models: [{ slug: 'grok-4.6' }, { slug: 'deepseek-v4-pro' }] })
|
||||
json: async () => manifest
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
@@ -35,6 +59,10 @@ describe('Codex models API', () => {
|
||||
})
|
||||
)
|
||||
expect(result.modelCount).toBe(2)
|
||||
expect(JSON.parse(result.content)).toEqual(manifest)
|
||||
expect(result.content).toContain('"effort": "xhigh"')
|
||||
expect(result.content).toContain('"input_modalities"')
|
||||
expect(result.content).toContain('"instructions_template"')
|
||||
expect(result.content).not.toContain('sk-user-test')
|
||||
})
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ vi.mock('file-saver', () => ({
|
||||
|
||||
import UseKeyModal from '../UseKeyModal.vue'
|
||||
|
||||
function readBlobAsText(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', () => resolve(String(reader.result || '')))
|
||||
reader.addEventListener('error', () => reject(reader.error))
|
||||
reader.readAsText(blob)
|
||||
})
|
||||
}
|
||||
|
||||
describe('UseKeyModal', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
@@ -606,10 +615,28 @@ describe('UseKeyModal', () => {
|
||||
|
||||
// Scenario: API Key users can fetch a routed group catalog and reference it from config.toml.
|
||||
it('offers a downloadable Codex catalog for Composite API keys', async () => {
|
||||
const manifest = {
|
||||
models: [
|
||||
{
|
||||
slug: 'gpt-5.5',
|
||||
default_reasoning_level: 'medium',
|
||||
supported_reasoning_levels: [{ effort: 'xhigh', description: 'Extra-high reasoning depth' }],
|
||||
input_modalities: ['text', 'image'],
|
||||
model_messages: { instructions_template: 'Use the routed model.' }
|
||||
},
|
||||
{
|
||||
slug: 'grok-4.6',
|
||||
default_reasoning_level: 'high',
|
||||
supported_reasoning_levels: [{ effort: 'xhigh', description: 'Extra-high reasoning depth' }],
|
||||
input_modalities: ['text'],
|
||||
model_messages: { instructions_template: 'Use the routed model.' }
|
||||
}
|
||||
]
|
||||
}
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ models: [{ slug: 'gpt-5.5' }, { slug: 'grok-4.6' }] })
|
||||
json: async () => manifest
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
@@ -663,6 +690,8 @@ describe('UseKeyModal', () => {
|
||||
expect(downloadButton).toBeDefined()
|
||||
await downloadButton!.trigger('click')
|
||||
expect(saveAsMock).toHaveBeenCalledWith(expect.any(Blob), 'codex-models.json')
|
||||
const downloadedBlob = saveAsMock.mock.calls[0]?.[0] as Blob
|
||||
expect(JSON.parse(await readBlobAsText(downloadedBlob))).toEqual(manifest)
|
||||
|
||||
const windowsTab = wrapper.findAll('button').find((button) => button.text().trim() === 'Windows')
|
||||
expect(windowsTab).toBeDefined()
|
||||
|
||||
Reference in New Issue
Block a user