Skip to content

feat(ai): expose OpenRouter free tier as a user-selectable provider - #1397

Merged
2witstudios merged 9 commits into
masterfrom
claude/openrouter-free-tier-UCxLT
May 23, 2026
Merged

2witstudios merged 9 commits into
masterfrom
claude/openrouter-free-tier-UCxLT

Conversation

@2witstudios

@2witstudios 2witstudios commented May 23, 2026 •

Copy link
Copy Markdown
Owner

Expose openrouter_free as a user-selectable provider in cloud mode, with a fully dynamic model list fetched live from the OpenRouter API.

What changed

Core feature

  • Added openrouter_free to USER_VISIBLE_PROVIDERS in the cloud settings API so users can select it in the model picker
  • Added /api/ai/openrouter/models route that fetches OpenRouter's public model catalog, filters to models with :free suffix and zero prompt pricing, and returns them in the same { success, models } shape used by Ollama/LM Studio
  • Removed the hardcoded static openrouter_free model list from ai-providers-config.ts — UI now fetches live
  • Updated ProviderModelSelector to lazily fetch and cache free models, with the same loading/empty-state pattern used for Ollama and LM Studio

Security enforcement

  • PATCH /api/ai/settings now rejects openrouter_free + any model that doesn't end with :free (HTTP 400)
  • createAIProvider (provider-factory.ts) also enforces the :free suffix before forwarding to OpenRouter backend, so no paid model can slip through even if the settings check is bypassed

Code quality

  • filterFreeModels extracted to filter-utils.ts — Next.js 15 prohibits arbitrary named exports from route files; keeping it in route.ts was causing the build to fail with "filterFreeModels" is not a valid Route export field
  • filterFreeModels uses Object.fromEntries instead of reduce+spread (O(n²) allocations)
  • Removed @pagespace/lib regex alias from vitest.config.ts — violates project rule; ./logging/logger-config subpath export already exists in packages/lib/package.json
  • next-server-stub.ts extended to export NextRequest (was missing, causing all tests that import NextRequest to fail silently)

Tests added

  • Full test suite for GET /api/ai/openrouter/models: auth, 503 when unconfigured, model filtering, fetch failures, unexpected errors
  • filterFreeModels unit tests covering :free + zero-pricing filter logic
  • Settings route tests: openrouter_free availability, key-unconfigured 503, key-present 200, non-:free model rejection 400 (closes the P1 review concern)

How to verify

  1. Set OPENROUTER_DEFAULT_API_KEY in your .env.local
  2. Open Settings → AI Model — OpenRouter Free should appear alongside PageSpace
  3. Open the model dropdown for OpenRouter Free — should show live models from OpenRouter, all ending in :free
  4. Attempting to save a paid model ID via the API should return HTTP 400

https://claude.ai/code/session_01AjAWKABbABoZcfvUUJvPau

The openrouter_free provider was fully wired throughout the codebase
(config, key resolution, backend factory, chat route, capabilities
detection, sidebar UI) but was masked by USER_VISIBLE_PROVIDERS in
the cloud settings API, so users could never select it. Add it to
the allow-list — its models all carry the `:free` suffix and never
incur PageSpace-side cost, so it's safe to expose alongside pagespace.

https://claude.ai/code/session_01AjAWKABbABoZcfvUUJvPau
@vercel

vercel Bot commented May 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pagespace-marketing Ready Ready Preview, Comment May 23, 2026 5:33pm

@coderabbitai

coderabbitai Bot commented May 23, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@2witstudios, we couldn't start this review because you've used your available PR reviews for now.

Your plan currently allows 1 review/hour. Refill in 10 minutes and 40 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bcdc389e-3652-4812-8985-d4e0a7fd7569

📥 Commits

Reviewing files that changed from the base of the PR and between f27b102 and 84c6c88.

📒 Files selected for processing (10)
  • apps/web/src/app/api/__tests__/security-audit-coverage.test.ts
  • apps/web/src/app/api/ai/openrouter/models/__tests__/route.test.ts
  • apps/web/src/app/api/ai/openrouter/models/filter-utils.ts
  • apps/web/src/app/api/ai/openrouter/models/route.ts
  • apps/web/src/app/api/ai/settings/__tests__/route.test.ts
  • apps/web/src/app/api/ai/settings/route.ts
  • apps/web/src/lib/ai/core/ai-providers-config.ts
  • apps/web/src/test/next-server-stub.ts
  • apps/web/vitest.config.ts
  • tasks/openrouter-free-dynamic-models.md
📝 Walkthrough

Walkthrough

Expose openrouter_free as user-visible in cloud mode; add a new GET /api/ai/openrouter/models route and helper to fetch/filter OpenRouter free-tier models; wire the ProviderModelSelector UI to fetch/cache these models; update tests, config, and test stubs accordingly.

Changes

openrouter_free user-visible exposure

Layer / File(s) Summary
Provider visibility contract
apps/web/src/app/api/ai/settings/route.ts
USER_VISIBLE_PROVIDERS expanded to include openrouter_free; PATCH validates openrouter_free models end with :free.
GET endpoint cloud-mode visibility tests
apps/web/src/app/api/ai/settings/__tests__/route.test.ts
GET cloud-mode masking tests updated to keep pagespace and openrouter_free visible; new test ensures openrouter_free is unavailable when OPENROUTER_DEFAULT_API_KEY is unset.
PATCH endpoint openrouter_free configuration tests
apps/web/src/app/api/ai/settings/__tests__/route.test.ts
PATCH tests added/renamed to cover non-user-visible providers and validate successful update when API key exists and 503/not-configured when missing.

Dynamic openrouter_free models (API + UI + tests)

Layer / File(s) Summary
OpenRouter models API & helper
apps/web/src/app/api/ai/openrouter/models/route.ts, apps/web/src/app/api/ai/openrouter/models/__tests__/route.test.ts
New GET route and filterFreeModels helper: authenticate, require managed OpenRouter key (503 if absent), fetch upstream models, filter IDs ending with :free and pricing.prompt === '0', return { success, models, modelCount } or error shapes; comprehensive tests for behavior and failures.
ProviderModelSelector UI and provider validation
apps/web/src/components/ai/chat/input/ProviderModelSelector.tsx, apps/web/src/lib/ai/core/ai-providers-config.ts, apps/web/src/lib/ai/core/provider-factory.ts
Add cached openrouter_free model state and fetcher, clear cache on settings updates, integrate model display/listing from fetched map, add empty-state message; clear static openrouter_free models from config; enforce :free suffix during provider creation.
Test stubs, vitest config, and task doc
apps/web/src/test/next-server-stub.ts, apps/web/vitest.config.ts, tasks/openrouter-free-dynamic-models.md
Add NextResponse test stub for route tests, refactor Vitest resolve.alias to array entries, and include an implementation epic documenting the dynamic models work.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • 2witstudios/PageSpace#1385: Both PRs touch AI_PROVIDERS.openrouter_free.models (one empties it for dynamic fetching; the other repopulates it), overlapping at config surface.
  • 2witstudios/PageSpace#78: Related provider-factory tests and openrouter_free validation coverage for model suffix behavior.

Poem

🐰 I fetched the free models beneath the moonlight,
Hopped through routes, with tests shining bright.
A cache cleared, a key checked with care,
OpenRouter's free names dance in the air.
Hooray — user-visible and ready to share!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and accurately describes the main objective: exposing the OpenRouter free tier as a user-selectable provider, which aligns with the entire changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/openrouter-free-tier-UCxLT

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: da4f211382

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const USER_VISIBLE_PROVIDERS = new Set(['pagespace']);
// `openrouter_free` is the OpenRouter free tier — all models carry the `:free` suffix
// and never count against PageSpace's paid quota, so it is safe to expose alongside pagespace.
const USER_VISIBLE_PROVIDERS = new Set(['pagespace', 'openrouter_free']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce :free models before exposing openrouter_free

Adding openrouter_free to the cloud allow-list makes it selectable, but this route still accepts any non-empty model string for that provider (apps/web/src/app/api/ai/settings/route.ts lines 132-138), and provider creation later forwards openrouter_free directly to the OpenRouter backend without model-tier checks (apps/web/src/lib/ai/core/provider-factory.ts lines 105-109). In cloud mode, an authenticated user can now save provider: "openrouter_free" with a paid model ID (for example a non-:free model), which violates the “never incurs cost” assumption and can trigger unexpected OpenRouter charges.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in both the settings route and provider-factory.ts (both were already patched in the earlier commits). To complete the picture: added test case 'returns 400 when openrouter_free is used with a non-free model' that covers exactly the bypass path described here — sending a non-:free model ID to the PATCH endpoint now returns 400 with an error containing ':free'.

- Add GET /api/ai/openrouter/models route that fetches live
  free-tier models from OpenRouter API with 1h revalidation cache
- Extract pure filterFreeModels function (id :free suffix +
  zero prompt pricing); 17 tests green
- Wire openrouter_free into ProviderModelSelector alongside
  existing ollama/lmstudio dynamic fetch pattern
- Replace static hardcoded model list with empty {} in config
- Guard openrouter_free against paid model IDs in settings
  route and provider-factory (defense in depth)
- Add next/server stub + @pagespace/lib/* alias to vitest config
  so route handler tests can run (pre-existing broken env)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Code Review

🐛 Bug — switching to openrouter_free fails with 400

getDefaultModel('openrouter_free') returns '' because models: {} now has no keys. In handleProviderSelect, if (newModel) is false so the PATCH request sends { provider: 'openrouter_free' } with no model field. The settings route then hits:

```ts
const isLocalProvider = ONPREM_ALLOWED_PROVIDERS.has(provider); // false for openrouter_free
if (!isLocalProvider && (!model || typeof model !== 'string')) {
return NextResponse.json({ error: 'Model is required' }, { status: 400 });
}
```

User sees "Failed to update provider" toast. Ollama/lmstudio escape this because they're in ONPREM_ALLOWED_PROVIDERS.

Fix: The model-validation bypass should cover any provider with a dynamic list, not just on-prem ones. Cleanest option without misusing ONPREM_ALLOWED_PROVIDERS:

```ts
// ai-providers-config.ts
export const DYNAMIC_MODEL_PROVIDERS = new Set(['ollama', 'lmstudio', 'openrouter_free']);

// settings/route.ts
const skipModelValidation = ONPREM_ALLOWED_PROVIDERS.has(provider) || DYNAMIC_MODEL_PROVIDERS.has(provider);
if (!skipModelValidation && (!model || typeof model !== 'string')) { ... }
```


⚡ Nit — filterFreeModels is O(n²)

```ts
// current — creates a new object on every iteration
.reduce<Record<string, string>>((acc, m) => ({ ...acc, [m.id]: m.name }), {});

// fix — O(n)
Object.fromEntries(
models.filter(m => m.id.endsWith(':free') && m.pricing?.prompt === '0')
.map(m => [m.id, m.name])
)
```
Negligible for ~50 models but worth fixing since the function is pure and easy to reason about.


✅ What's good

  • Pure filterFreeModels extraction is clean and the 6 unit tests cover all meaningful branches (no pricing, non-zero pricing, :free suffix mismatch, mixed list, empty input)
  • Defense-in-depth on the :free constraint: settings route + provider-factory both guard independently
  • Component changes follow the exact ollama/lmstudio pattern — no new abstractions, easy to scan
  • next: { revalidate: 3600 } is the correct Next.js mechanism for a 1h server-side cache here
  • next-server-stub.ts + @pagespace/lib/* vitest alias fix a pre-existing broken environment that was silently failing all route handler tests

@2witstudios 2witstudios left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔬 Code Review

Branch: pu/1397 | PR: feat(ai): dynamic openrouter_free model list


Summary

The core feature work is solid: the new /api/ai/openrouter/models route cleanly mirrors the ollama/lmstudio pattern, the filterFreeModels pure function is well-tested, and the frontend wiring in ProviderModelSelector is correct. Five issues flagged below, one of which is a security concern that needs a response before merge.


Findings

🔴 Security — Removal of revokeAllUserSessions (admin magic-link route)

apps/admin/src/app/api/auth/magic-link/verify/route.ts removes:

await sessionService.revokeAllUserSessions(userId, magic_link_login);

This was a defense-in-depth measure: on magic-link login, all existing sessions were revoked, preventing a stolen session token from remaining valid after re-authentication. Removing it means concurrent sessions persist through a magic-link sign-in event. Please confirm this removal is intentional and describe the reason (e.g., needed for multi-device admin access, caused race conditions, etc.). If intentional, consider replacing with a narrower policy (e.g., revoke only non-current-device sessions) rather than removing entirely.


🟡 Convention violation — @pagespace/lib/* regex alias in vitest.config.ts

The new alias:

{ find: /^@pagespace\/lib\/(.*)$/, replacement: path.resolve(__dirname, ../../packages/lib/src/) }

violates the project convention: "No vitest aliases — use bun native resolution; proper package.json exports + bun is always sufficient; never add resolve.alias to vitest configs for workspace packages." The alias was added because the test imports @pagespace/lib/logging/logger-config directly. The correct fix is to ensure packages/lib/package.json exports that path, then remove this alias.


🟡 console.error instead of structured logger in ProviderModelSelector

} catch (error) {
  console.error(Failed to fetch OpenRouter free models:', error);
}
console.errorloggers.*filterFreeModelstypescript
.reduce<Record<string, string>>((acc, m) => ({ ...acc, [m.id]: m.name }), {});
Object.fromEntries(models.filter(...).map(m => [m.id, m.name]))typescript
headers.set('Location", /);
return new Response(null, { status: 302, headers });

The original used an absolute URL (new URL(/, getAdminUrl())). The relative "/" should resolve correctly since the admin app runs on its own origin, but worth confirming this works across reverse-proxy configurations (e.g., Caddy stripping the Host header). Low risk, worth a quick smoke test.


OWASP Top 10 Scan

# Vulnerability Status
A01 Broken Access Control ⚠️ See revokeAllUserSessions removal above
A02 Cryptographic Failures ✅ No changes
A03 Injection ✅ API key in header only; no interpolation into URLs
A04 Insecure Design ✅ 503 on unconfigured provider is acceptable
A05 Security Misconfiguration ✅ No secrets committed; API key sourced from managed config
A06 Vulnerable Components ✅ No new dependencies
A07 Auth Failures ⚠️ Tied to A01 — session revocation removal
A08 Data Integrity ✅ No pipeline changes
A09 Logging & Monitoring ✅ Logging present on happy path and errors
A10 SSRF ✅ URL is hardcoded (openrouter.ai); no user-controlled URL

What works well

  • Clean extraction of filterFreeModels as an exported pure function — makes the unit tests crisp
  • 1h Next.js revalidate cache on the OpenRouter fetch — right tradeoff between freshness and rate-limit safety
  • Defense-in-depth :free suffix guard in provider-factory.ts — good belt-and-suspenders with the API settings validation
  • Test coverage is thorough: pure function, route auth/config/success/failure/unexpected-error paths all covered
  • next-server-stub.ts is minimal and correct — just enough to make NextResponse.json work in tests

Verdict: Approve pending resolution of the revokeAllUserSessions removal question and the vitest.config.ts alias convention violation.

models
.filter(m => m.id.endsWith(':free') && m.pricing?.prompt === '0')
.reduce<Record<string, string>>((acc, m) => ({ ...acc, [m.id]: m.name }), {});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Nit: reduce with spread creates a new object on every iteration (O(n²) allocations). At ~100–300 models this is harmless, but Object.fromEntries is cleaner:

export const filterFreeModels = (models: OpenRouterModel[]): Record<string, string> =>
  Object.fromEntries(
    models
      .filter(m => m.id.endsWith(":free") && m.pricing?.prompt === "0")
      .map(m => [m.id, m.name])
  );

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed — switched to Object.fromEntries in filter-utils.ts.

Comment thread apps/web/vitest.config.ts Outdated
{ find: 'next/server', replacement: path.resolve(__dirname, 'src/test/next-server-stub.ts') },
{
find: /^@pagespace\/lib\/(.*)$/,
replacement: path.resolve(__dirname, '../../packages/lib/src/$1'),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Convention violation: project rule says "No vitest aliases — use bun native resolution; proper package.json exports + bun is always sufficient; never add resolve.alias to vitest configs for workspace packages." This regex alias was added because the test imports @pagespace/lib/logging/logger-config directly. The correct fix is to add that subpath export to packages/lib/package.json and remove this alias. The next/server stub alias on the line above is fine (it is a test-environment shim, not a workspace package).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Removed the @pagespace/lib regex alias from vitest.config.ts. The ./logging/logger-config subpath export already exists in packages/lib/package.json, so bun resolves it natively in tests without the alias.

}
} catch (error) {
console.error('Failed to fetch OpenRouter free models:', error);
} finally {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Minor: console.error here is consistent with the equivalent fetchOllamaModels / fetchLMStudioModels blocks in this component, so no regression — but worth noting for a future cleanup pass to align with the server-side structured-logger pattern.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/web/src/app/api/ai/openrouter/models/route.ts (1)

12-15: ⚡ Quick win

Avoid repeated object re-allocation in filterFreeModels.

Using object spread in the reducer creates a new object per model. For larger model lists, this is unnecessary overhead in a route hit path.

♻️ Suggested refactor
 export const filterFreeModels = (models: OpenRouterModel[]): Record<string, string> =>
   models
     .filter(m => m.id.endsWith(':free') && m.pricing?.prompt === '0')
-    .reduce<Record<string, string>>((acc, m) => ({ ...acc, [m.id]: m.name }), {});
+    .reduce<Record<string, string>>((acc, m) => {
+      acc[m.id] = m.name;
+      return acc;
+    }, {});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/api/ai/openrouter/models/route.ts` around lines 12 - 15, The
reducer in filterFreeModels creates a new object for every model via object
spread, causing unnecessary allocations; change the implementation to mutate the
accumulator instead of spreading: in filterFreeModels (and its reduce callback)
assign acc[m.id] = m.name and return acc, or replace reduce with a simple
forEach that sets result[m.id] = m.name, keeping the same filter predicate
(m.id.endsWith(':free') && m.pricing?.prompt === '0') and returning the final
Record<string,string>.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tasks/openrouter-free-dynamic-models.md`:
- Line 14: Update the documentation sentence that currently uses the internal
key-style names 'lmstudio' and 'ollama' to use product-style capitalized names
"LM Studio" and "Ollama" in prose (e.g., change "mirroring the lmstudio route
shape" to "mirroring the LM Studio route shape"); ensure product names are not
wrapped in inline code backticks in prose and update any other occurrences of
'lmstudio'/'ollama' in the same doc sentence to the proper product casing.

---

Nitpick comments:
In `@apps/web/src/app/api/ai/openrouter/models/route.ts`:
- Around line 12-15: The reducer in filterFreeModels creates a new object for
every model via object spread, causing unnecessary allocations; change the
implementation to mutate the accumulator instead of spreading: in
filterFreeModels (and its reduce callback) assign acc[m.id] = m.name and return
acc, or replace reduce with a simple forEach that sets result[m.id] = m.name,
keeping the same filter predicate (m.id.endsWith(':free') && m.pricing?.prompt
=== '0') and returning the final Record<string,string>.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 607ccdce-c7a7-427d-aa42-21a56ebda719

📥 Commits

Reviewing files that changed from the base of the PR and between da4f211 and f27b102.

📒 Files selected for processing (9)
  • apps/web/src/app/api/ai/openrouter/models/__tests__/route.test.ts
  • apps/web/src/app/api/ai/openrouter/models/route.ts
  • apps/web/src/app/api/ai/settings/route.ts
  • apps/web/src/components/ai/chat/input/ProviderModelSelector.tsx
  • apps/web/src/lib/ai/core/ai-providers-config.ts
  • apps/web/src/lib/ai/core/provider-factory.ts
  • apps/web/src/test/next-server-stub.ts
  • apps/web/vitest.config.ts
  • tasks/openrouter-free-dynamic-models.md
✅ Files skipped from review due to trivial changes (1)
  • apps/web/src/test/next-server-stub.ts

Comment thread tasks/openrouter-free-dynamic-models.md Resolved
…test

- Extract filterFreeModels and OpenRouterModel type to filter-utils.ts so
  that route.ts only exports HTTP handlers (Next.js 15 prohibits arbitrary
  named exports from route files — build was failing with
  '"filterFreeModels" is not a valid Route export field')
- Remove next: { revalidate: 3600 } from fetch() options (not valid
  RequestInit; Next.js fetch caching not needed for an authed API route)
- Update route test to import filterFreeModels from ../filter-utils and
  drop the revalidate assertion from the fetch call check
- Add missing test: openrouter_free rejects non-:free model IDs (400)
  to cover the P1 review concern about paid model bypass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel

vercel Bot commented May 23, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

There is no GitHub account connected to this Vercel account.

…fix doc naming

- filterFreeModels: replace reduce+spread (O(n²)) with Object.fromEntries
  per code review feedback from owner
- vitest.config.ts: remove @pagespace/lib regex alias — violates project rule
  'no vitest aliases for workspace packages'; ./logging/logger-config is
  already a proper subpath export in packages/lib/package.json so bun
  resolves it natively
- tasks/openrouter-free-dynamic-models.md: use product-style names
  'LM Studio' and 'Ollama' in prose (not backtick key-style) per coderabbit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ests

The PR's next/server alias maps to next-server-stub.ts which only exported
NextResponse. Tests that import NextRequest from 'next/server' (mcp-tokens,
verify-email) were getting undefined, causing every assertion to fail.

Add a minimal NextRequest class that extends the native Request so those
test files can construct request objects normally. Also add NextResponse.redirect
to complete the stub.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Real Next.js NextResponse.redirect() uses 307 (Temporary Redirect) as
default. Our stub was defaulting to 302, causing google-callback-redirect
tests to fail with 'expected 302 to be 307'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2witstudios and others added 2 commits May 23, 2026 12:12
The new route is a read-only, auth-gated model discovery endpoint that
returns only public OpenRouter model metadata — functionally equivalent
to ai/ollama/models and ai/lmstudio/models which are already exempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add DYNAMIC_MODEL_PROVIDERS set to ai-providers-config.ts and use it in
the settings route to skip model validation for providers whose model
list is fetched dynamically at runtime.

Switching to openrouter_free previously returned 400 "Model is required"
because models:{} is empty and the UI sends no model on provider switch.
ollama/lmstudio escaped the same check via ONPREM_ALLOWED_PROVIDERS, but
openrouter_free is a cloud provider so it wasn't covered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Thanks for the detailed bug report — both issues addressed:

Bug fix: Added DYNAMIC_MODEL_PROVIDERS = new Set(['ollama', 'lmstudio', 'openrouter_free']) to ai-providers-config.ts and updated the settings route to skip model validation for any provider in that set. The fix mirrors the pattern you suggested without overloading ONPREM_ALLOWED_PROVIDERS. Test added for the 200-with-no-model case ('allows openrouter_free without a model (dynamic model list, picked after provider switch)').

filterFreeModels O(n²): Already resolved in a previous commit — the function was extracted to filter-utils.ts and rewritten with Object.fromEntries.

Security audit gate: Added ai/openrouter/models to AUDIT_EXEMPT_ROUTES with the same justification as ai/ollama/models and ai/lmstudio/models (read-only, auth-gated, returns only public model metadata).

Spreading a Headers instance gives {} because its entries are not
enumerable. Use the Headers constructor to properly merge provided
headers with Content-Type (json) or Location (redirect).

This restores Set-Cookie and Cache-Control headers passed via init.headers
in signup-passkey and magic-link/verify route tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@2witstudios
2witstudios merged commit 8fa7502 into master May 23, 2026
12 checks passed
@2witstudios
2witstudios deleted the claude/openrouter-free-tier-UCxLT branch May 27, 2026 02:05

This branch was previously deployed

1 inactive deployment
Preview — 84c6c889 Deployed May 23, 2026 by vercel[bot]
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