Repository navigation
feat(dashboard): import only free models + free-model list controls - #4176
diegosouzapw merged 3 commits into
Conversation
Add an opt-in "import only free models" workflow plus free-aware controls to the provider model list. Add API Key / Edit Connection: - New "Import only free models" toggle, shown only for providers that have documented free models (derived from FREE_MODEL_BUDGETS). Persists as providerSpecificData.importFreeModelsOnly. Editing an existing connection and changing the flag auto-triggers a replace re-sync so the available model list reflects the new choice. Model sync: - sync-models filters fetched models to free-only when the flag is set, and returns freeFilterEmpty so the UI can show a clear "no free models found" message instead of a silent empty import. Model list (Compatible / Passthrough / base sections): - "Free first" sort and "All / Free only / Paid only" view filters in the toolbar (view-only; no routing/persistence change). - "Free" badge now reflects catalog-detected free models. Free detection (src/shared/utils/freeModels.ts): - isFreeModel: :free suffix, zero prompt+completion price, or membership in the curated free-tier catalog. Resolves provider alias -> canonical id so e.g. "ollamacloud" matches the catalog's "ollama-cloud". - sortModelsFreeFirst: deterministic free-first ordering (alphabetical tie- break) so the order is stable across re-renders/refetches (e.g. Test all). - providerHasFreeModels / selectModelsForImport helpers. Note: "free" is not machine-readable for every provider (e.g. NVIDIA's "Free Endpoint" set is only knowable by probing) — those rely on the existing Test-all + auto-hide flow. Tests: free-models unit suite (detection, alias resolution, import filter, sort) + component tests for both modals and the toolbar controls.
There was a problem hiding this comment.
Code Review
This pull request introduces a feature to filter, sort, and import only free models for supported providers. It adds a new utility file freeModels.ts, integrates free-model toggles and filters across several UI components (such as AddApiKeyModal, EditConnectionModal, and ModelVisibilityToolbar), updates the model synchronization API route, and includes comprehensive unit tests. The code reviewer identified a potential bug in EditConnectionModal.tsx where the importFreeModelsOnly setting might fail to save if updates.providerSpecificData is undefined, and suggested robustness improvements in freeModels.ts to handle nullable provider parameters safely.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if (showFreeModelsToggle && updates.providerSpecificData) { | ||
| // Store an explicit boolean (not undefined): the PUT route merges | ||
| // { ...existing, ...incoming }, so an undefined/omitted key would keep the | ||
| // previously-saved `true` and unchecking would never take effect. | ||
| updates.providerSpecificData.importFreeModelsOnly = formData.importFreeModelsOnly === true; | ||
| } |
There was a problem hiding this comment.
If connection.providerSpecificData is initially undefined or null, updates.providerSpecificData may also be undefined. In that case, the condition showFreeModelsToggle && updates.providerSpecificData will evaluate to false, and the importFreeModelsOnly setting will silently fail to save. To prevent this, we should ensure updates.providerSpecificData is initialized as an empty object if it does not exist.
if (showFreeModelsToggle) {
if (!updates.providerSpecificData) {
updates.providerSpecificData = {};
}
// Store an explicit boolean (not undefined): the PUT route merges
// { ...existing, ...incoming }, so an undefined/omitted key would keep the
// previously-saved 'true' and unchecking would never take effect.
updates.providerSpecificData.importFreeModelsOnly = formData.importFreeModelsOnly === true;
}
| export function isFreeModel(provider: string, model: FreeModelCandidate): boolean { | ||
| if (typeof model.id === "string" && model.id.endsWith(":free")) return true; | ||
| if (isZeroPrice(model.pricing?.prompt) && isZeroPrice(model.pricing?.completion)) return true; | ||
| if (typeof model.id === "string") { | ||
| const canonical = resolveProviderId(provider); | ||
| if ( | ||
| FREE_MODEL_IDS_BY_PROVIDER.get(provider)?.has(model.id) || | ||
| FREE_MODEL_IDS_BY_PROVIDER.get(canonical)?.has(model.id) | ||
| ) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } |
There was a problem hiding this comment.
To prevent potential runtime errors or TypeScript compilation issues when provider is undefined or null (e.g., before a connection is fully loaded or resolved in the parent components), we should make isFreeModel more robust by allowing provider to be nullable and checking its type before performing lookups.
| export function isFreeModel(provider: string, model: FreeModelCandidate): boolean { | |
| if (typeof model.id === "string" && model.id.endsWith(":free")) return true; | |
| if (isZeroPrice(model.pricing?.prompt) && isZeroPrice(model.pricing?.completion)) return true; | |
| if (typeof model.id === "string") { | |
| const canonical = resolveProviderId(provider); | |
| if ( | |
| FREE_MODEL_IDS_BY_PROVIDER.get(provider)?.has(model.id) || | |
| FREE_MODEL_IDS_BY_PROVIDER.get(canonical)?.has(model.id) | |
| ) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| export function isFreeModel(provider: string | undefined | null, model: FreeModelCandidate): boolean { | |
| if (typeof model.id === "string" && model.id.endsWith(":free")) return true; | |
| if (isZeroPrice(model.pricing?.prompt) && isZeroPrice(model.pricing?.completion)) return true; | |
| if (typeof model.id === "string" && typeof provider === "string") { | |
| const canonical = resolveProviderId(provider); | |
| if ( | |
| FREE_MODEL_IDS_BY_PROVIDER.get(provider)?.has(model.id) || | |
| FREE_MODEL_IDS_BY_PROVIDER.get(canonical)?.has(model.id) | |
| ) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } |
| export function selectModelsForImport<T extends FreeModelCandidate>( | ||
| provider: string, | ||
| fetchedModels: T[], | ||
| importFreeOnly: boolean | ||
| ): SelectModelsForImportResult<T> { | ||
| if (!importFreeOnly) { | ||
| return { models: fetchedModels, freeFilterEmpty: false }; | ||
| } | ||
| const models = fetchedModels.filter((m) => isFreeModel(provider, m)); | ||
| const freeFilterEmpty = fetchedModels.length > 0 && models.length === 0; | ||
| return { models, freeFilterEmpty }; | ||
| } |
There was a problem hiding this comment.
Update selectModelsForImport to accept a nullable provider parameter to align with the updated isFreeModel signature and ensure safe model filtering.
| export function selectModelsForImport<T extends FreeModelCandidate>( | |
| provider: string, | |
| fetchedModels: T[], | |
| importFreeOnly: boolean | |
| ): SelectModelsForImportResult<T> { | |
| if (!importFreeOnly) { | |
| return { models: fetchedModels, freeFilterEmpty: false }; | |
| } | |
| const models = fetchedModels.filter((m) => isFreeModel(provider, m)); | |
| const freeFilterEmpty = fetchedModels.length > 0 && models.length === 0; | |
| return { models, freeFilterEmpty }; | |
| } | |
| export function selectModelsForImport<T extends FreeModelCandidate>( | |
| provider: string | undefined | null, | |
| fetchedModels: T[], | |
| importFreeOnly: boolean | |
| ): SelectModelsForImportResult<T> { | |
| if (!importFreeOnly) { | |
| return { models: fetchedModels, freeFilterEmpty: false }; | |
| } | |
| const models = fetchedModels.filter((m) => isFreeModel(provider, m)); | |
| const freeFilterEmpty = fetchedModels.length > 0 && models.length === 0; | |
| return { models, freeFilterEmpty }; | |
| } |
…unner collects them The 3 React-component tests landed at top-level tests/unit/*.test.tsx, which no runner collects: test:unit only globs tests/unit/*.test.ts, and test:vitest:ui filters to tests/unit/ui. check:test-discovery flagged them as NEW orphans (never run). Moved them under tests/unit/ui/ (collected by vitest.config.ts + the ui path filter) and bumped the relative import one level (../../ -> ../../../). Also bumped the file-size baseline for the two connection modals (cohesive free-models toggle, mirroring cheaper-inference#3879/cheaper-inference#2997). 12/12 vitest + 16/16 node tests pass; test-discovery clean. Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
# Conflicts: # config/quality/file-size-baseline.json
5ef6a46
into
cheaper-inference:release/v3.8.29
|
Merged into On review we made two small adjustments on your branch (you stay the author):
Shipping in the next release. 🙏 |
… (QG v2 Fase 9 T5 D4-D6) (#4175) Integrated into release/v3.8.29 — combo.ts god-file split part 2 (QG v2 Fase 9 T5 D4-D6): shadow routing, target sorters, and combo structure resolution extracted byte-identically to combo/{shadowRouting,targetSorters,comboStructure,comboData}.ts; combo.ts 4740->3819, public surface re-exported (callers unchanged). Validated on the rebased+merged tree: typecheck:core, file-size, test-discovery green; 356/356 full combo suite + 51/51 smoke pass. Resynced with current release (baseline union: combo-split entries + #4176 free-models entry; combo.ts=3819, rateLimitManager=1035).
…heaper-inference#4176) Integrated into release/v3.8.29 — import-only-free-models connection option + free/paid list filters and free-first sort. Thanks @felipesartori! On review we relocated the 3 React-component tests from tests/unit/ to tests/unit/ui/ so a runner actually collects them (check:test-discovery had flagged them as orphans), bumped the file-size baseline for the two connection modals (cohesive toggle, mirroring cheaper-inference#3879/cheaper-inference#2997), and synced the branch with the current release. 12/12 vitest + 16/16 node tests green.
… (QG v2 Fase 9 T5 D4-D6) (cheaper-inference#4175) Integrated into release/v3.8.29 — combo.ts god-file split part 2 (QG v2 Fase 9 T5 D4-D6): shadow routing, target sorters, and combo structure resolution extracted byte-identically to combo/{shadowRouting,targetSorters,comboStructure,comboData}.ts; combo.ts 4740->3819, public surface re-exported (callers unchanged). Validated on the rebased+merged tree: typecheck:core, file-size, test-discovery green; 356/356 full combo suite + 51/51 smoke pass. Resynced with current release (baseline union: combo-split entries + cheaper-inference#4176 free-models entry; combo.ts=3819, rateLimitManager=1035).
Problem
Adding a provider connection auto-imports all of the provider's models. Users who only want the provider's free models had no way to opt in, and the model list had no way to sort/filter by free vs paid.
What this adds
"Import only free models" toggle
FREE_MODEL_BUDGETS).providerSpecificData.importFreeModelsOnly.Model sync
sync-modelsfilters fetched models to free-only when the flag is set, and returnsfreeFilterEmptyso the UI shows a clear "no free models found" message instead of a silent empty import.Model list controls (Compatible / Passthrough / base sections)
Free detection —
src/shared/utils/freeModels.tsisFreeModel::freesuffix, zero prompt+completion price, or membership in the curated free-tier catalog. Resolves provider alias → canonical id (e.g.ollamacloud→ollama-cloud) so catalog lookups match.sortModelsFreeFirst: deterministic free-first ordering (alphabetical tie-break) so order stays stable across re-renders/refetches (e.g. while Test all runs).providerHasFreeModels/selectModelsForImporthelpers.Known limitation (by design)
"Free" is not machine-readable for every provider. For example, NVIDIA's "Free Endpoint" set is not exposed by any API — it's only knowable by probing each model (HTTP 200/429 = hosted). Those providers rely on the existing Test all models + Auto-hide failed flow. Documented in code comments.
Tests
tests/unit/free-models.test.ts— detection (:free/ zero-price / catalog), alias resolution, import filter (incl.freeFilterEmpty), and free-first sort (stability + no mutation).tests/unit/add-api-key-modal-free-models.test.tsx— toggle visibility + payload.tests/unit/edit-connection-modal-free-models.test.tsx— toggle visibility, persisted-state reflection, explicit-false on untoggle (regression for the PUT merge keeping staletrue), and auto re-sync.tests/unit/model-visibility-toolbar-free.test.tsx— sort + free/paid filter controls.Validation (Hard Rule #18)
TDD: each behavior added as a failing test first, then implemented. All green:
node --test tests/unit/free-models.test.ts→ 16 passedconnModalsregression)typecheck:coreclean, ESLint 0 errors, i18n UI coverage ≥65 passes.Also manually verified locally against live OpenRouter and ollama-cloud connections.
Notes
providerSpecificDataJSON column.en/pt-BR/pt; toolbar labels useproviderTextfallback for other locales (consistent with existing toolbar keys).🤖 Generated with Claude Code