(feat) Auto get free models from openrouter #2630
Conversation
Summary of ChangesHello @coderdylanbates, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces a significant enhancement by automating the discovery and integration of free models from OpenRouter. It fetches the latest list of free models directly from the OpenRouter API and presents them in a structured sub-menu within the model selection interface. This change aims to simplify model selection for users and ensure access to current free offerings. Additionally, it includes several foundational improvements to path handling utilities and build configurations, contributing to better cross-platform compatibility and overall system robustness. Highlights
Changelog
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
Greptile SummaryThis PR adds automatic fetching and caching of free OpenRouter models at startup, surfacing them in a dedicated "Free models" submenu within Key changes:
The core fetch/cache/submenu flow is solid and the dynamic free model list is a good improvement. Remaining concerns are style and UX clarity issues around naming, sanitization, and dead exports. Confidence Score: 4/5
Sequence DiagramsequenceDiagram
participant Main as main.ts (onReady)
participant Hydrate as hydrateOpenRouterFreeModels
participant OR as OpenRouter API
participant Cache as cachedFreeModels (module state)
participant IPC as getLanguageModels IPC
participant UI as ModelPicker (React)
Main->>Hydrate: hydrateOpenRouterFreeModels() (no await)
activate Hydrate
Note over Cache: initialised with DEFAULT_FREE_MODELS
Hydrate->>OR: GET /api/v1/models (5s timeout)
OR-->>Hydrate: { data: [...] }
Hydrate->>Hydrate: Zod parse → buildOpenRouterFreeModels()
Hydrate->>Cache: cachedFreeModels = freeModels (or DEFAULT on error)
deactivate Hydrate
UI->>IPC: getLanguageModels({ providerId: "openrouter" })
IPC->>Cache: getOpenRouterFreeModels()
Cache-->>IPC: LanguageModel[]
IPC->>IPC: dedupe([...freeModels, ...hardcodedModels])
IPC-->>UI: merged LanguageModel[]
UI->>UI: split into openRouterFreeModels / displayModels
UI-->>UI: render "Free models" submenu + main list
Last reviewed commit: 383f7e0 |
Additional Comments (1)
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/main.ts
Line: 91:96
Comment:
**Unhandled async startup fetch**
`hydrateOpenRouterFreeModels()` is invoked without `await`, so any fetch/parse errors become an unhandled rejection unless `hydrateOpenRouterFreeModels` fully swallows them. Since it does `fetch(...)` and can throw before entering its `catch` only if something synchronous fails (rare), this still means the app can proceed with stale `DEFAULT_FREE_MODELS` while the UI may render before the cache is hydrated. If startup ordering matters, `await` this (or explicitly `void hydrateOpenRouterFreeModels()` and ensure it never rejects).
How can I resolve this? If you propose a fix, please make it concise. |
Additional Comments (1)
Even if this is unlikely today, the current code makes the runtime safety depend on the hardcoded constants always containing at least one Prompt To Fix With AIThis is a comment left during a code review.
Path: src/ipc/utils/get_model_client.ts
Line: 136:154
Comment:
**Empty fallback model list**
`createFallback({ models: getOpenRouterFreeModelNames().map(...) })` can be called with an empty array if the OpenRouter free-model hydration fails and `DEFAULT_FREE_MODELS` is empty (e.g., if the hardcoded `MODEL_OPTIONS.openrouter` list ever drops all `:free` entries). `createFallback` typically expects at least one underlying model; with an empty list this will fail at runtime when the user selects `auto/free`.
Even if this is unlikely today, the current code makes the runtime safety depend on the hardcoded constants always containing at least one `:free` model.
How can I resolve this? If you propose a fix, please make it concise. |
Additional Comments (1)
Prompt To Fix With AIThis is a comment left during a code review.
Path: src/components/ModelPicker.tsx
Line: 343:343
Comment:
**Model count is misleading**
`models.length` includes free models that have been moved into the "Free models" sub-submenu. Users will see e.g. "30 models" in the OpenRouter trigger but only find the non-free subset when they click through. For OpenRouter, this should use `displayModels.length + (openRouterFreeModels.length > 0 ? 1 : 0)` or simply `displayModels.length` to accurately reflect what's shown in the main submenu, or the total with a note.
```suggestion
{displayModels.length + openRouterFreeModels.length} models
```
How can I resolve this? If you propose a fix, please make it concise. |
wwwillchen
left a comment
There was a problem hiding this comment.
hi @coderdylanbates thanks for the PR
i think it's overall a good change, but needs a few changes before we can land it:
UX-wise
- Let's pin the Free (OpenRouter) to the top since it's a good default option.
- We should plumb the model description too
Testing
Our project primarily relies on e2e tests for coverage. here's some docs on e2e testing: https://github.com/dyad-sh/dyad/blob/main/CONTRIBUTING.md#e2e-tests - the main thing would be to create a fake OpenRouter endpoint which we do for other things like github, llm servers, etc. in https://github.com/dyad-sh/dyad/tree/main/testing/fake-llm-server
There's also a lot of other seemingly unrelated changes. Let's revert those changes and focus only on openrouter-specific changes for this PR.
| "esprima", | ||
| "source-map", | ||
| "tslib", | ||
| "bindings", |
There was a problem hiding this comment.
is there a reason this file is changing? it doesn't look related to the openrouter changes. there's also a few other unrelated changes in this PR as well
There was a problem hiding this comment.
Hey @wwwillchen the other seemingly unrelated changes are related to tests that were not passing on my machine. I wanted to make sure all of the tests passed so I changed them a bit, however I understand those changes may not be in the scope of this PR so I will remove the changes.
There was a problem hiding this comment.
Hey @wwwillchen I've been trying to add e2e tests and followed the instructions in Contributing.md but it seems the testing suit always errors out for me with exit code 2, even though I have all dependencies and build tools installed. This seems to happen even without my new tests added, and I'm not sure how you want me to go about it since you don't want me to edit the tests. Of course, I could edit the testing suite on my local version of the project, but I worry the results won't accurately reflect those in the proper production test suite.
There was a problem hiding this comment.
hi @coderdylanbates - sorry for the delay, could you share more details on the error you're getting when running e2e tests? e.g. error logs, etc.
i've rebased the PR and going to run the e2e tests in CI. if it's clean, i'll land it - thanks
There was a problem hiding this comment.
1 issue found across 1 file (changes from recent commits).
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="forge.config.ts">
<violation number="1">
P2: Removed whitelist entries can exclude externalized runtime deps (recast/ast-types/etc.), causing packaged app to miss required modules.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| const logger = log.scope("openrouter_free_models"); | ||
|
|
||
| const DEFAULT_FREE_MODELS: LanguageModel[] = MODEL_OPTIONS.openrouter | ||
| .filter((model) => model.name.endsWith(":free")) |
There was a problem hiding this comment.
Filter won't match openrouter/free (ends with /free, not :free), so DEFAULT_FREE_MODELS will be empty
| .filter((model) => model.name.endsWith(":free")) | |
| .filter((model) => model.name.endsWith(":free") || model.name.endsWith("/free")) |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ipc/shared/openrouter_free_models.ts
Line: 42
Comment:
Filter won't match `openrouter/free` (ends with `/free`, not `:free`), so `DEFAULT_FREE_MODELS` will be empty
```suggestion
.filter((model) => model.name.endsWith(":free") || model.name.endsWith("/free"))
```
How can I resolve this? If you propose a fix, please make it concise.| ).toBeVisible(); | ||
|
|
||
| await expect( | ||
| po.page.getByRole("menuitem").filter({ hasText: "Free (OpenRouter)" }), |
There was a problem hiding this comment.
Test expects "Free (OpenRouter)" but language_model_constants.ts:234 now shows displayName: "Free" - the test will fail.
| po.page.getByRole("menuitem").filter({ hasText: "Free (OpenRouter)" }), | |
| po.page.getByRole("menuitem").filter({ hasText: "Free" }), |
Prompt To Fix With AI
This is a comment left during a code review.
Path: e2e-tests/openrouter_free_models.spec.ts
Line: 29
Comment:
Test expects "Free (OpenRouter)" but `language_model_constants.ts:234` now shows `displayName: "Free"` - the test will fail.
```suggestion
po.page.getByRole("menuitem").filter({ hasText: "Free" }),
```
How can I resolve this? If you propose a fix, please make it concise.Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
| @@ -280,6 +291,16 @@ export const MODEL_OPTIONS: Record<string, ModelOption[]> = { | |||
| temperature: 0.7, | |||
| dollarSigns: 2, | |||
| }, | |||
| // https://openrouter.ai/mistralai/devstral-2512 | |||
| { | |||
| name: "mistralai/devstral-2512:free", | |||
| displayName: "Devstral 2", | |||
| description: "Use for free (data may be used for training)", | |||
| maxOutputTokens: 32_000, | |||
| contextWindow: 200_000, | |||
| temperature: 0.7, | |||
| dollarSigns: 2, | |||
| }, | |||
There was a problem hiding this comment.
Incorrect dollarSigns on new :free hardcoded models
Both new free-tier models are given non-zero dollarSigns values (1 and 2), but because the deduplication in language_model_helpers.ts uses [...freeModels, ...hardcodedModels], hardcoded entries always overwrite the API-fetched entries with dollarSigns: 0. The result is that these two models end up with dollarSigns: 1 / dollarSigns: 2 in the merged list, so PriceBadge will display $ / $$ for them inside the "Free models" submenu — contradicting the "free" label.
Both entries should use dollarSigns: 0:
| // https://openrouter.ai/qwen/qwen3-coder | |
| { | |
| name: "qwen/qwen3-coder:free", | |
| displayName: "Qwen3 Coder", | |
| description: "Use for free (data may be used for training)", | |
| maxOutputTokens: 32_000, | |
| contextWindow: 196_608, | |
| temperature: 0, | |
| dollarSigns: 0, | |
| }, |
And for mistralai/devstral-2512:free:
| dollarSigns: 0, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ipc/shared/language_model_constants.ts
Line: 276-303
Comment:
**Incorrect `dollarSigns` on new `:free` hardcoded models**
Both new free-tier models are given non-zero `dollarSigns` values (`1` and `2`), but because the deduplication in `language_model_helpers.ts` uses `[...freeModels, ...hardcodedModels]`, hardcoded entries always **overwrite** the API-fetched entries with `dollarSigns: 0`. The result is that these two models end up with `dollarSigns: 1` / `dollarSigns: 2` in the merged list, so `PriceBadge` will display `$` / `$$` for them inside the "Free models" submenu — contradicting the "free" label.
Both entries should use `dollarSigns: 0`:
```suggestion
// https://openrouter.ai/qwen/qwen3-coder
{
name: "qwen/qwen3-coder:free",
displayName: "Qwen3 Coder",
description: "Use for free (data may be used for training)",
maxOutputTokens: 32_000,
contextWindow: 196_608,
temperature: 0,
dollarSigns: 0,
},
```
And for `mistralai/devstral-2512:free`:
```suggestion
dollarSigns: 0,
```
How can I resolve this? If you propose a fix, please make it concise.| if (providerId === "openrouter") { | ||
| const freeModels = getOpenRouterFreeModels(); | ||
| const dedupe = new Map<string, LanguageModel>(); | ||
| [...freeModels, ...hardcodedModels].forEach((model) => { | ||
| dedupe.set(model.apiName, model); | ||
| }); | ||
| hardcodedModels = Array.from(dedupe.values()); | ||
| } |
There was a problem hiding this comment.
Deduplication direction discards API-fetched data
The array spread [...freeModels, ...hardcodedModels] means that when the same apiName exists in both lists, Map.set is called last with the hardcoded entry, so hardcoded values always win. The API-fetched contextWindow, maxOutputTokens, dollarSigns: 0, and tag: "Free" metadata are silently discarded in favour of the static constants file.
This defeats the PR's goal of pulling current metadata from OpenRouter at runtime. The spread order should be reversed so that API data overrides the hardcoded fallback:
| if (providerId === "openrouter") { | |
| const freeModels = getOpenRouterFreeModels(); | |
| const dedupe = new Map<string, LanguageModel>(); | |
| [...freeModels, ...hardcodedModels].forEach((model) => { | |
| dedupe.set(model.apiName, model); | |
| }); | |
| hardcodedModels = Array.from(dedupe.values()); | |
| } | |
| if (providerId === "openrouter") { | |
| const freeModels = getOpenRouterFreeModels(); | |
| const dedupe = new Map<string, LanguageModel>(); | |
| [...hardcodedModels, ...freeModels].forEach((model) => { | |
| dedupe.set(model.apiName, model); | |
| }); | |
| hardcodedModels = Array.from(dedupe.values()); | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ipc/shared/language_model_helpers.ts
Line: 152-159
Comment:
**Deduplication direction discards API-fetched data**
The array spread `[...freeModels, ...hardcodedModels]` means that when the same `apiName` exists in both lists, `Map.set` is called last with the **hardcoded** entry, so hardcoded values always win. The API-fetched `contextWindow`, `maxOutputTokens`, `dollarSigns: 0`, and `tag: "Free"` metadata are silently discarded in favour of the static constants file.
This defeats the PR's goal of pulling current metadata from OpenRouter at runtime. The spread order should be reversed so that API data overrides the hardcoded fallback:
```suggestion
if (providerId === "openrouter") {
const freeModels = getOpenRouterFreeModels();
const dedupe = new Map<string, LanguageModel>();
[...hardcodedModels, ...freeModels].forEach((model) => {
dedupe.set(model.apiName, model);
});
hardcodedModels = Array.from(dedupe.values());
}
```
How can I resolve this? If you propose a fix, please make it concise.| maxOutputTokens: 32_000, | ||
| contextWindow: 196_608, | ||
| temperature: 0, | ||
| dollarSigns: 1, |
There was a problem hiding this comment.
🟡 Free model qwen/qwen3-coder:free has incorrect dollarSigns: 1 instead of 0
The newly added qwen/qwen3-coder:free model has dollarSigns: 1 despite being a free model (:free suffix, description says "Use for free"). This causes the PriceBadge component (src/components/PriceBadge.tsx:10) to render "$" instead of "Free". Furthermore, the dedupe logic in src/ipc/shared/language_model_helpers.ts:155 gives hardcoded models precedence over dynamically fetched free models (which correctly have dollarSigns: 0), so the incorrect value persists even after hydration.
| dollarSigns: 1, | |
| dollarSigns: 0, |
Was this helpful? React with 👍 or 👎 to provide feedback.
| maxOutputTokens: 32_000, | ||
| contextWindow: 200_000, | ||
| temperature: 0.7, | ||
| dollarSigns: 2, |
There was a problem hiding this comment.
🟡 Free model mistralai/devstral-2512:free has incorrect dollarSigns: 2 instead of 0
The newly added mistralai/devstral-2512:free model has dollarSigns: 2 despite being a free model (:free suffix, description says "Use for free"). This causes the PriceBadge component (src/components/PriceBadge.tsx:10) to render "$$" instead of "Free". The same dedupe issue as with qwen/qwen3-coder:free applies: src/ipc/shared/language_model_helpers.ts:155 gives hardcoded models precedence, so the incorrect dollarSigns value overrides the correct 0 from the dynamic free model list.
| dollarSigns: 2, | |
| dollarSigns: 0, |
Was this helpful? React with 👍 or 👎 to provide feedback.
|
hi @coderdylanbates - sorry for the delay, we've updated our language model catalog to be on the server-side so we can push out new LLMs quicker than our binary release so i'm going to try to port this logic over to our server (this is currently not open-source). thanks again for the PR |
Sometimes the free router breaks and sometimes I want to use a specific model but theres just so many free models its a hassle adding them each individually so heres this PR
Summary by cubic
Automatically fetch and cache OpenRouter free models at startup, add a “Free models” submenu in ModelPicker, and build the free router fallback from dynamic free model IDs. The OpenRouter response is schema-validated, sanitized, and token limits are mapped for accurate caps.
New Features
Bug Fixes
Written for commit fcd705f. Summary will update on new commits.