diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 71588741a1..d07cd57e13 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1188,7 +1188,7 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi } if platform == service.PlatformComposite { availableModels := h.compositeAvailableModels(ctx, groupID) - fallbackModels := defaultModelIDsForPlatform(service.PlatformComposite) + fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite) if group.CustomModelsListEnabled() { return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models) } @@ -1199,7 +1199,7 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi } availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) - fallbackModels := defaultModelIDsForPlatform(platform) + fallbackModels := defaultCodexModelIDsForPlatform(platform) if group.CustomModelsListEnabled() { return filterModelsByCustomList( customModelsListSource(platform, availableModels, fallbackModels), @@ -1424,6 +1424,15 @@ func customModelsListAllowsModel(availablePatterns []string, model string) bool return false } +func defaultCodexModelIDsForPlatform(platform string) []string { + switch platform { + case service.PlatformDeepseek: + return []string{"deepseek-v4-pro", "deepseek-v4-flash"} + default: + return defaultModelIDsForPlatform(platform) + } +} + func defaultModelIDsForPlatform(platform string) []string { switch platform { case service.PlatformOpenAI: diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 15fc52416a..05069bf25a 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -774,6 +774,89 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T) } } +func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) { + require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek)) + require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic)) +} + +func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 130 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformDeepseek, + Status: service.StatusActive, + Schedulable: true, + Credentials: map[string]any{}, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + slugs := make([]string, 0, len(got.Models)) + for _, model := range got.Models { + slugs = append(slugs, model.Slug) + } + require.Contains(t, slugs, "deepseek-v4-pro") + require.Contains(t, slugs, "deepseek-v4-flash") + require.NotContains(t, slugs, "claude-sonnet-4-6") + require.NotContains(t, slugs, "claude-opus-4-6") +} + +func TestGatewayCodexModels_OmitsWildcardMappingKeys(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 131 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformDeepseek, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "foo-*": "deepseek-v4-pro", + "deepseek-v4-pro": "deepseek-v4-pro", + }, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + slugs := make([]string, 0, len(got.Models)) + for _, model := range got.Models { + slugs = append(slugs, model.Slug) + } + require.Equal(t, []string{"deepseek-v4-pro"}, slugs) +} + func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index 331f80d78c..9eabd3e5b2 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -17,6 +17,7 @@ import ( middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" ) type codexModelsFailoverAccountRepo struct { @@ -195,6 +196,96 @@ func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) { } } +func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: 1, + Name: "shared-api-key", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-shared", + "base_url": "https://upstream.example/v1", + }, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{ + firstBody: `{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`, + } + 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} + groupA := &service.Group{ + ID: 91, + Platform: service.PlatformOpenAI, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-a"}, + }, + } + groupB := &service.Group{ + ID: 92, + Platform: service.PlatformOpenAI, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-b"}, + }, + } + + firstA := performCodexModelsRequestForGroup(t, handler, groupA, "") + require.Equal(t, http.StatusOK, firstA.Code, firstA.Body.String()) + require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, firstA)) + + firstB := performCodexModelsRequestForGroup(t, handler, groupB, "") + require.Equal(t, http.StatusOK, firstB.Code, firstB.Body.String()) + require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, firstB)) + + etagA := firstA.Header().Get("ETag") + require.NotEmpty(t, etagA) + + var wg sync.WaitGroup + results := make([]*httptest.ResponseRecorder, 8) + for i := range results { + wg.Add(1) + go func(index int) { + defer wg.Done() + if index%2 == 0 { + results[index] = performCodexModelsRequestForGroup(t, handler, groupA, etagA) + return + } + results[index] = performCodexModelsRequestForGroup(t, handler, groupB, "") + }(i) + } + wg.Wait() + + sawGroupB := false + for _, recorder := range results { + require.NotNil(t, recorder) + switch recorder.Code { + case http.StatusNotModified: + require.Empty(t, recorder.Body.Bytes()) + case http.StatusOK: + slugs := codexHandlerManifestSlugs(t, recorder) + if len(slugs) == 1 && slugs[0] == "model-b" { + sawGroupB = true + continue + } + require.Equal(t, []string{"model-a"}, slugs) + default: + t.Fatalf("unexpected status %d body=%s", recorder.Code, recorder.Body.String()) + } + } + require.True(t, sawGroupB) +} + // Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。 func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) { gin.SetMode(gin.TestMode) @@ -485,6 +576,24 @@ func performCodexModelsRequestForGroup(t *testing.T, handler *OpenAIGatewayHandl return recorder } +func codexHandlerManifestSlugs(t *testing.T, recorder *httptest.ResponseRecorder) []string { + t.Helper() + + var envelope struct { + Models []struct { + Slug string `json:"slug"` + } `json:"models"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String()) + } + slugs := make([]string, 0, len(envelope.Models)) + for _, model := range envelope.Models { + slugs = append(slugs, model.Slug) + } + return slugs +} + func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.ResponseRecorder, slug string) { t.Helper() diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index b59fc99630..832a0f7c6c 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -38,10 +38,12 @@ const ( codexAutoModelPrefix = "codex-auto-" ) -// FilterCodexModelIDsForGroup removes dedicated media-generation models 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. +// 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() { @@ -62,6 +64,9 @@ func FilterCodexModelIDsForGroup(modelIDs []string, group *Group) []string { if isCodexDedicatedMediaModel(modelID) { continue } + if strings.Contains(modelID, "*") { + continue + } if strings.HasPrefix(modelID, codexAutoModelPrefix) { if _, ok := explicitlyEnabled[modelID]; !ok { continue @@ -2046,6 +2051,17 @@ func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string return fmt.Sprintf("%x", hasher.Sum(nil)) } +func cloneCodexModelsManifest(manifest *CodexModelsManifest) *CodexModelsManifest { + if manifest == nil { + return nil + } + cloned := *manifest + if manifest.Body != nil { + cloned.Body = append([]byte(nil), manifest.Body...) + } + return &cloned +} + func codexModelsManifestForClient(manifest *CodexModelsManifest, ifNoneMatch string) *CodexModelsManifest { if manifest == nil { return nil @@ -2053,7 +2069,7 @@ func codexModelsManifestForClient(manifest *CodexModelsManifest, ifNoneMatch str if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) { return &CodexModelsManifest{ETag: manifest.ETag, NotModified: true} } - return manifest + return cloneCodexModelsManifest(manifest) } func codexModelsManifestETagMatches(ifNoneMatch, etag string) bool { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index de9ea4c417..d02e59ddb4 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -137,6 +137,16 @@ func newCodexCatalogMappedAccount( return account } +func TestFilterCodexModelIDsForGroupOmitsWildcardKeys(t *testing.T) { + t.Parallel() + + got := FilterCodexModelIDsForGroup( + []string{"deepseek-v4-pro", "foo-*", " bar-* ", "gpt-5.5"}, + &Group{Platform: PlatformDeepseek}, + ) + require.Equal(t, []string{"deepseek-v4-pro", "gpt-5.5"}, got) +} + func decodeCodexManifestModels(t *testing.T, body []byte) []map[string]any { t.Helper() @@ -2151,6 +2161,77 @@ func TestFetchCodexModelsManifestAPIKeyFreshCacheHandlesETagLocally(t *testing.T } } +func TestFetchCodexModelsManifestAPIKeyCacheSurvivesClientMutation(t *testing.T) { + var calls atomic.Int32 + upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + calls.Add(1) + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`)), + }, nil + }} + s := newCodexModelsAPIKeyTestService(upstream) + s.accountRepo = codexModelsVisibilityAccountRepo{} + account := newCodexModelsAPIKeyTestAccount("https://upstream.example") + + first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, err) + require.Contains(t, string(first.Body), "model-a") + require.Contains(t, string(first.Body), "model-b") + + require.NoError(t, s.CompleteAPIKeyCodexModelsManifestForClient(first, account)) + require.NoError(t, s.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ + ID: 81, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-a"}, + }, + }, + first, + "", + )) + require.Equal(t, []string{"model-a"}, codexManifestModelSlugs(t, first.Body)) + + second, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, err) + require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, second.Body)) + require.Equal(t, int32(1), calls.Load()) + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, fetchErr) + require.NoError(t, s.MergeGroupConfiguredCodexModels( + context.Background(), + &Group{ + ID: 82, + Platform: PlatformOpenAI, + ModelsListConfig: GroupModelsListConfig{ + Enabled: true, + Models: []string{"model-b"}, + }, + }, + manifest, + "", + )) + require.Equal(t, []string{"model-b"}, codexManifestModelSlugs(t, manifest.Body)) + }() + } + wg.Wait() + + third, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") + require.NoError(t, err) + require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, third.Body)) + require.Equal(t, int32(1), calls.Load()) +} + func TestFetchCodexModelsManifestAPIKeyCacheKeyIsolatesRequestIdentity(t *testing.T) { var calls atomic.Int32 upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 094eff52b9..8f540c4059 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -263,6 +263,12 @@ import Icon from '@/components/icons/Icon.vue' import { useClipboard } from '@/composables/useClipboard' import { fetchCodexModelsManifest } from '@/api/codex' import type { GroupPlatform } from '@/types' +import { + findCodexCatalogModel, + formatCodexReasoningEffortTomlLine, + parseCodexCatalogModels, + selectCodexConfigReasoningEffort +} from '@/utils/codexCatalogConfig' interface Props { show: boolean @@ -627,29 +633,21 @@ function downloadCodexModelManifest() { ) } -const codexCatalogModelSlugs = computed(() => { - if (!codexModelManifestContent.value) return [] - try { - const payload: unknown = JSON.parse(codexModelManifestContent.value) - if (typeof payload !== 'object' || payload === null || !('models' in payload)) return [] - const models = (payload as { models?: unknown }).models - if (!Array.isArray(models)) return [] - return models.flatMap((model) => { - if (typeof model !== 'object' || model === null || !('slug' in model)) return [] - const slug = (model as { slug?: unknown }).slug - if (typeof slug !== 'string' || !slug.trim()) return [] - return [slug.trim()] - }) - } catch { - return [] - } -}) +const codexCatalogModelSlugs = computed(() => + parseCodexCatalogModels(codexModelManifestContent.value).map((model) => model.slug) +) function selectCodexCatalogModel(preferredModel: string): string { if (codexCatalogModelSlugs.value.includes(preferredModel)) return preferredModel return codexCatalogModelSlugs.value[0] || preferredModel } +function codexReasoningEffortTomlLine(modelSlug: string): string { + return formatCodexReasoningEffortTomlLine( + selectCodexConfigReasoningEffort(findCodexCatalogModel(codexModelManifestContent.value, modelSlug)) + ) +} + const escapeHtml = (value: string) => value .replace(/&/g, '&') .replace(/ { expect(configToml).not.toContain('supports_websockets') expect(configToml).not.toContain('responses_websockets_v2') expect(configToml).toContain('[features]\ngoals = true') + expect(configToml).not.toContain('model_reasoning_effort = "xhigh"') expect(codeBlocks).toContain('{\n "OPENAI_API_KEY": "sk-test"\n}') expect(wrapper.text()).toContain('auth.json') expect(wrapper.find('[data-testid="codex-api-key-restart-notice"]').exists()).toBe(false) @@ -759,4 +760,48 @@ describe('UseKeyModal', () => { expect(config).toContain('model = "gpt-5.5"') expect(config).toContain('review_model = "gpt-5.5"') }) + + it('derives OpenAI Codex reasoning effort from the selected catalog descriptor', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + models: [ + { + slug: 'glm-5.3', + default_reasoning_level: 'none', + supported_reasoning_levels: [{ effort: 'none' }] + } + ] + }) + })) + + const wrapper = mount(UseKeyModal, { + props: { + show: true, + apiKey: 'sk-openai-test', + baseUrl: 'https://example.com/v1', + platform: 'openai' + }, + global: { + stubs: { + BaseDialog: { + template: '
' + }, + Icon: { + template: '' + } + } + } + }) + + await wrapper.get('[data-testid="codex-model-catalog-fetch"]').trigger('click') + await flushPromises() + + const configToml = wrapper.findAll('pre code') + .map((code) => code.text()) + .find((content) => content.includes('model_provider = "OpenAI"')) + expect(configToml).toContain('model = "glm-5.3"') + expect(configToml).not.toContain('model_reasoning_effort') + }) }) diff --git a/frontend/src/utils/__tests__/codexCatalogConfig.spec.ts b/frontend/src/utils/__tests__/codexCatalogConfig.spec.ts new file mode 100644 index 0000000000..ff78bb1b0a --- /dev/null +++ b/frontend/src/utils/__tests__/codexCatalogConfig.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + findCodexCatalogModel, + formatCodexReasoningEffortTomlLine, + parseCodexCatalogModels, + selectCodexConfigReasoningEffort +} from '@/utils/codexCatalogConfig' + +describe('codexCatalogConfig', () => { + it('parses catalog slugs and finds a model by id', () => { + const content = JSON.stringify({ + models: [ + { slug: 'glm-5.3', default_reasoning_level: 'none', supported_reasoning_levels: [{ effort: 'none' }] }, + { slug: ' ', supported_reasoning_levels: [] } + ] + }) + expect(parseCodexCatalogModels(content).map((model) => model.slug)).toEqual(['glm-5.3']) + expect(findCodexCatalogModel(content, 'glm-5.3')?.slug).toBe('glm-5.3') + expect(findCodexCatalogModel(content, 'missing')).toBeUndefined() + }) + + it('omits effort when the descriptor only advertises none', () => { + expect(selectCodexConfigReasoningEffort({ + slug: 'glm-5.3', + default_reasoning_level: 'none', + supported_reasoning_levels: [{ effort: 'none' }] + })).toBeNull() + expect(formatCodexReasoningEffortTomlLine(null)).toBe('') + }) + + it('does not emit an effort absent from supported_reasoning_levels', () => { + expect(selectCodexConfigReasoningEffort({ + slug: 'glm-5.3', + default_reasoning_level: 'xhigh', + supported_reasoning_levels: [{ effort: 'none' }] + })).toBeNull() + }) + + it('uses the catalog default when it is a supported non-none effort', () => { + expect(selectCodexConfigReasoningEffort({ + slug: 'gpt-5.5', + default_reasoning_level: 'medium', + supported_reasoning_levels: [ + { effort: 'low' }, + { effort: 'medium' }, + { effort: 'high' }, + { effort: 'xhigh' } + ] + })).toBe('medium') + expect(formatCodexReasoningEffortTomlLine('medium')).toBe('model_reasoning_effort = "medium"\n') + }) + + it('falls back to the first usable supported effort when default is missing', () => { + expect(selectCodexConfigReasoningEffort({ + slug: 'custom', + supported_reasoning_levels: [{ effort: 'none' }, { effort: 'high' }] + })).toBe('high') + }) +}) diff --git a/frontend/src/utils/codexCatalogConfig.ts b/frontend/src/utils/codexCatalogConfig.ts new file mode 100644 index 0000000000..18e07dc991 --- /dev/null +++ b/frontend/src/utils/codexCatalogConfig.ts @@ -0,0 +1,63 @@ +export interface CodexCatalogReasoningLevel { + effort?: unknown +} + +export interface CodexCatalogModel { + slug: string + default_reasoning_level?: unknown + supported_reasoning_levels?: CodexCatalogReasoningLevel[] +} + +function trimEffort(value: unknown): string { + if (typeof value !== 'string') return '' + return value.trim() +} + +export function parseCodexCatalogModels(content: string | null | undefined): CodexCatalogModel[] { + if (!content) return [] + try { + const payload: unknown = JSON.parse(content) + if (typeof payload !== 'object' || payload === null || !('models' in payload)) return [] + const models = (payload as { models?: unknown }).models + if (!Array.isArray(models)) return [] + return models.flatMap((model) => { + if (typeof model !== 'object' || model === null || !('slug' in model)) return [] + const slug = trimEffort((model as { slug?: unknown }).slug) + if (!slug) return [] + return [{ ...(model as CodexCatalogModel), slug }] + }) + } catch { + return [] + } +} + +export function findCodexCatalogModel( + content: string | null | undefined, + slug: string +): CodexCatalogModel | undefined { + const wanted = slug.trim() + if (!wanted) return undefined + return parseCodexCatalogModels(content).find((model) => model.slug === wanted) +} + +export function selectCodexConfigReasoningEffort( + model: CodexCatalogModel | undefined +): string | null { + if (!model) return null + const efforts = (model.supported_reasoning_levels ?? []).flatMap((level) => { + const effort = trimEffort(level?.effort) + return effort ? [effort] : [] + }) + if (efforts.length === 0) return null + + const defaultLevel = trimEffort(model.default_reasoning_level) + if (defaultLevel && efforts.includes(defaultLevel)) { + return defaultLevel === 'none' ? null : defaultLevel + } + return efforts.find((effort) => effort !== 'none') ?? null +} + +export function formatCodexReasoningEffortTomlLine(effort: string | null): string { + if (!effort) return '' + return `model_reasoning_effort = "${effort}"\n` +}