fix(opencode): honor free model metadata

This commit is contained in:
777genius 2026-05-20 17:15:14 +03:00
parent e3ff8e1df5
commit 147af0e0e5
4 changed files with 143 additions and 10 deletions

View file

@ -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 {

View file

@ -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';

View file

@ -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(
<ProviderModelBadges
providerId="opencode"
models={['openrouter/openai/gpt-oss-20b']}
providerStatus={{
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: '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 },
},
],
diagnostics: {
configReadState: 'ready',
appServerState: 'healthy',
},
},
}}
/>
);
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(
<ProviderModelBadges

View file

@ -1,11 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
getVisibleTeamProviderModels,
isAnthropicOneMillionContextTeamModel,
isAnthropicSonnetOneMillionContextTeamModel,
isAnthropicSonnetTeamModel,
} from '@renderer/utils/teamModelCatalog';
import { describe, expect, it } from 'vitest';
describe('teamModelCatalog', () => {
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);