diff --git a/src/renderer/components/runtime/ProviderModelBadges.tsx b/src/renderer/components/runtime/ProviderModelBadges.tsx index 93e7b174..08f1a8bc 100644 --- a/src/renderer/components/runtime/ProviderModelBadges.tsx +++ b/src/renderer/components/runtime/ProviderModelBadges.tsx @@ -53,7 +53,11 @@ function getCatalogBadgeLabel( const catalogItem = providerStatus?.modelCatalog?.models.find( (item) => item.launchModel === model || item.id === model ); - return catalogItem?.badgeLabel?.trim() || null; + const badgeLabel = catalogItem?.badgeLabel?.trim(); + if (badgeLabel) { + return badgeLabel; + } + return catalogItem?.metadata?.free === true ? 'Free' : null; } function normalizeBadgeText(value: string): string { diff --git a/src/renderer/utils/teamModelCatalog.ts b/src/renderer/utils/teamModelCatalog.ts index 3cd56890..daa18a03 100644 --- a/src/renderer/utils/teamModelCatalog.ts +++ b/src/renderer/utils/teamModelCatalog.ts @@ -4,12 +4,7 @@ import { getOpenCodeQualifiedModelSourceLabel, parseOpenCodeQualifiedModelRef, } from '@shared/utils/opencodeModelRef'; -import { - filterVisibleProviderRuntimeModels, - GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL, - GPT_5_2_CODEX_UI_DISABLED_MODEL, - GPT_5_3_CODEX_SPARK_UI_DISABLED_MODEL, -} from '@shared/utils/providerModelVisibility'; +import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility'; import type { CliProviderId, CliProviderStatus, TeamProviderId } from '@shared/types'; @@ -420,6 +415,10 @@ function isFreeOpenCodeModelForOrdering( } const runtimeModel = getRuntimeCatalogModel(providerId, model, providerStatus); + if (runtimeModel?.metadata?.free === true) { + return true; + } + const badgeLabel = runtimeModel?.badgeLabel?.trim().toLowerCase(); if (badgeLabel) { return badgeLabel === 'free'; diff --git a/test/renderer/components/runtime/ProviderModelBadges.test.tsx b/test/renderer/components/runtime/ProviderModelBadges.test.tsx index 00301067..159a5add 100644 --- a/test/renderer/components/runtime/ProviderModelBadges.test.tsx +++ b/test/renderer/components/runtime/ProviderModelBadges.test.tsx @@ -1,8 +1,8 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; function render(element: React.ReactElement): HTMLDivElement { const host = document.createElement('div'); @@ -171,6 +171,54 @@ describe('ProviderModelBadges', () => { expect(host.textContent?.match(/Free/g)).toHaveLength(1); }); + it('renders OpenCode free badges from metadata when badgeLabel is absent', () => { + const host = render( + + ); + + expect(host.textContent).toContain('gpt-oss'); + expect(host.textContent).toContain('Free'); + }); + it('does not duplicate a catalog badge that matches the displayed model label', () => { const host = render( { it('filters UI-disabled Codex models from provider badge lists', () => { @@ -139,6 +138,89 @@ describe('teamModelCatalog', () => { ]); }); + it('orders OpenCode free models by metadata when badge labels are absent', () => { + expect( + getVisibleTeamProviderModels( + 'opencode', + [ + 'openai/gpt-5.4', + 'opencode/big-pickle', + 'openrouter/openai/gpt-oss-20b', + ], + { + providerId: 'opencode', + authMethod: 'opencode_managed', + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + modelCatalog: { + schemaVersion: 1, + providerId: 'opencode', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-05-12T00:00:00.000Z', + staleAt: '2026-05-12T00:10:00.000Z', + defaultModelId: 'opencode/big-pickle', + defaultLaunchModel: 'opencode/big-pickle', + models: [ + { + id: 'openai/gpt-5.4', + launchModel: 'openai/gpt-5.4', + displayName: 'openai/gpt-5.4', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + metadata: { free: false }, + }, + { + id: 'openrouter/openai/gpt-oss-20b', + launchModel: 'openrouter/openai/gpt-oss-20b', + displayName: 'openrouter/openai/gpt-oss-20b', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: false, + upgrade: false, + source: 'app-server', + badgeLabel: null, + metadata: { free: true }, + }, + { + id: 'opencode/big-pickle', + launchModel: 'opencode/big-pickle', + displayName: 'opencode/big-pickle', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + inputModalities: ['text'], + supportsPersonality: true, + isDefault: true, + upgrade: false, + source: 'app-server', + badgeLabel: null, + metadata: { free: true }, + }, + ], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }, + } + ) + ).toEqual([ + 'opencode/big-pickle', + 'openrouter/openai/gpt-oss-20b', + 'openai/gpt-5.4', + ]); + }); + it('detects Sonnet aliases with or without 1M suffix', () => { expect(isAnthropicSonnetTeamModel('sonnet')).toBe(true); expect(isAnthropicSonnetTeamModel('sonnet[1m]')).toBe(true);