Skip to content

feat(dashboard): import only free models + free-model list controls - #4176

Merged
diegosouzapw merged 3 commits into
cheaper-inference:release/v3.8.29from
felipesartori:feat/free-models-import
Jun 18, 2026
Merged

diegosouzapw merged 3 commits into
cheaper-inference:release/v3.8.29from
felipesartori:feat/free-models-import

Conversation

@felipesartori

Copy link
Copy Markdown
Contributor

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

  • Shown in Add API Key and Edit Connection, 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 immediately.

Model sync

  • sync-models filters fetched models to free-only when the flag is set, and returns freeFilterEmpty so the UI shows a clear "no free models found" message instead of a silent empty import.

Model list controls (Compatible / Passthrough / base sections)

  • Free first sort and All / Free only / Paid only view filters in the toolbar (view-only — no routing/persistence change).
  • The 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 (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 / selectModelsForImport helpers.

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 stale true), 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 passed
  • vitest component tests → 20 passed (incl. existing connModals regression)
  • typecheck:core clean, ESLint 0 errors, i18n UI coverage ≥65 passes.

Also manually verified locally against live OpenRouter and ollama-cloud connections.

Notes

  • No DB schema/migration changes — uses the existing providerSpecificData JSON column.
  • i18n keys added to en / pt-BR / pt; toolbar labels use providerText fallback for other locales (consistent with existing toolbar keys).

🤖 Generated with Claude Code

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +564 to +569
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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

Comment on lines +55 to +68
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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;
}

Comment on lines +101 to +112
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 };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update selectModelsForImport to accept a nullable provider parameter to align with the updated isFreeModel signature and ensure safe model filtering.

Suggested change
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 };
}

felipesartori and others added 2 commits June 18, 2026 12:15
…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
@diegosouzapw
diegosouzapw merged commit 5ef6a46 into cheaper-inference:release/v3.8.29 Jun 18, 2026
4 checks passed
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Merged into release/v3.8.29 🎉 Thanks for this, @felipesartori — the free-models import option + free/paid filters + free-first sort are a great UX addition, and the selectModelsForImport/freeFilterEmpty round-trip is cleanly tested.

On review we made two small adjustments on your branch (you stay the author):

  1. Moved the 3 React-component tests from tests/unit/*.test.tsx to tests/unit/ui/ — top-level .tsx files aren't collected by any runner (test:unit globs *.test.ts, test:vitest:ui filters to tests/unit/ui), so check:test-discovery had flagged them as orphans that never run. They pass 12/12 at the new path.
  2. Bumped the file-size baseline for the two connection modals (the toggle is cohesive UI, mirroring the prior fix: stabilize reasoning streams and request logs #3879/[Feature] Per-connection 'disable-cooling' opt-out #2997 toggle bumps) and synced your branch with the current release.

Shipping in the next release. 🙏

diegosouzapw added a commit that referenced this pull request Jun 18, 2026
… (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).
@diegosouzapw diegosouzapw mentioned this pull request Jun 19, 2026
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
…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.
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
… (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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants