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);