Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@

<br/>

**~1.5B+ documented free tokens/month** aggregated across the free tiers — and the compression above stretches every one further. ([how we count →](docs/reference/FREE_TIERS.md#tldr--how-much-free-inference-does-omniroute-actually-aggregate))

<br/>

[![177 AI Providers](https://img.shields.io/badge/177-AI_Providers-6C5CE7?style=for-the-badge)](#-177-ai-providers--50-free)
[![50+ Free](https://img.shields.io/badge/50%2B-Free_Tiers-00B894?style=for-the-badge)](#-177-ai-providers--50-free)
[![1.5B+ Free Tokens/mo](https://img.shields.io/badge/1.5B%2B-Free_Tokens%2Fmo-00B894?style=for-the-badge)](docs/reference/FREE_TIERS.md)
[![Token Savings](https://img.shields.io/badge/up_to_95%25-Token_Savings-E17055?style=for-the-badge)](#%EF%B8%8F-save-1595-tokens--automatically)
[![14 Strategies](https://img.shields.io/badge/14-Routing_Strategies-0984E3?style=for-the-badge)](#-combos--the-flagship)
[![$0 to start](https://img.shields.io/badge/%240-To_Start-FDCB6E?style=for-the-badge&logoColor=black)](#-quick-start)
Expand Down
539 changes: 327 additions & 212 deletions docs/reference/FREE_TIERS.md

Large diffs are not rendered by default.

477 changes: 260 additions & 217 deletions docs/reference/PROVIDER_REFERENCE.md

Large diffs are not rendered by default.

99 changes: 99 additions & 0 deletions open-sse/config/freeTierCatalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* Free-tier monthly token budget catalog.
*
* Hand-seeded from the 2026-06-05 per-provider research snapshot. Each value is
* the UPPER-BOUND of a provider's DOCUMENTED recurring monthly free tokens
* (explicit daily/monthly token cap, or documented RPD × ~800 tokens × 30).
*
* Deliberately EXCLUDED (rate-limit-only, no published token cap — theoretical,
* not granted): tencent, siliconflow, nvidia, baidu, publicai, sparkdesk.
* One-time signup credits and discontinued tiers are excluded (do not recur).
*/
export type TosVerdict = "ok" | "caution" | "ambiguous" | "avoid" | "unknown";

export const FREE_TIER_BUDGETS: Record<string, number> = {
mistral: 1_000_000_000,
longcat: 150_000_000,
"cloudflare-ai": 122_000_000,
gemini: 60_000_000,
doubao: 60_000_000,
cerebras: 30_000_000,
"api-airforce": 24_000_000,
"ollama-cloud": 20_000_000,
"github-models": 18_000_000,
groq: 15_000_000,
inclusionai: 15_000_000,
bluesminds: 7_200_000,
sambanova: 6_000_000,
"arcee-ai": 4_800_000,
llm7: 4_300_000,
bazaarlink: 3_600_000,
openrouter: 1_200_000,
cohere: 800_000,
huggingchat: 500_000,
morph: 400_000,
huggingface: 200_000,
kiro: 25_000,
};

/**
* Providers whose terms PROHIBIT routing through a self-hosted proxy or forbid
* non-personal use. Source: ToS attention table in docs/reference/FREE_TIERS.md.
*/
export const FREE_TIER_TOS: Record<string, TosVerdict> = {
opencode: "avoid",
"duckduckgo-web": "avoid",
"gemini-cli": "avoid",
agy: "avoid",
kiro: "avoid",
"amazon-q": "avoid",
"muse-spark-web": "avoid",
"t3-web": "avoid",
"qwen-web": "avoid",
modal: "avoid",
nlpcloud: "avoid",
blackbox: "avoid",
completions: "avoid",
fireworks: "avoid",
"featherless-ai": "avoid",
friendliai: "avoid",
ai21: "avoid",
iflytek: "avoid",
coze: "avoid",
};

export interface FreeTierTotals {
documentedMonthlyTokens: number;
providerCount: number;
byProvider: Array<{ id: string; monthlyTokens: number; tos: TosVerdict }>;
headline: string;
}

function billions(n: number): string {
return n >= 1e9 ? (n / 1e9).toFixed(2) + "B" : Math.round(n / 1e6) + "M";
}
Comment on lines +72 to +74

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

The billions helper function rounds any token count below 1,000,000 to 0M (via Math.round(n / 1e6)). For smaller token budgets (such as kiro with 25,000 tokens or morph with 400,000 tokens), this results in an incorrect or misleading '0M' display if they are ever formatted individually or if the aggregated total falls below 500,000. Improving the helper to handle thousands (K) and fractional millions/billions dynamically ensures robust and accurate formatting across all ranges.

function billions(n: number): string {
  if (n >= 1e9) {
    return parseFloat((n / 1e9).toFixed(2)) + "B";
  }
  if (n >= 1e6) {
    return parseFloat((n / 1e6).toFixed(2)) + "M";
  }
  if (n >= 1e3) {
    return parseFloat((n / 1e3).toFixed(2)) + "K";
  }
  return String(n);
}


/**
* Sum the documented free-tier budgets. `excludeTosAvoid` drops providers whose
* terms prohibit proxy use (not usable headroom).
*/
export function computeFreeTierTotals(
opts: { excludeTosAvoid?: boolean } = {}
): FreeTierTotals {
const byProvider = Object.entries(FREE_TIER_BUDGETS)
.map(([id, monthlyTokens]) => ({
id,
monthlyTokens,
tos: (FREE_TIER_TOS[id] ?? "caution") as TosVerdict,

Check warning on line 87 in open-sse/config/freeTierCatalog.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_OmniRoute&issues=AZ6Zu7UYxuz03-S02v75&open=AZ6Zu7UYxuz03-S02v75&pullRequest=3257
}))
.filter((p) => !(opts.excludeTosAvoid && p.tos === "avoid"))
.sort((a, b) => b.monthlyTokens - a.monthlyTokens);

const documentedMonthlyTokens = byProvider.reduce((s, p) => s + p.monthlyTokens, 0);
return {
documentedMonthlyTokens,
providerCount: byProvider.length,
byProvider,
headline: `over ${billions(documentedMonthlyTokens)} documented free tokens/month across ${byProvider.length}+ providers`,
};
}
21 changes: 21 additions & 0 deletions src/app/api/free-tier/summary/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { computeFreeTierTotals } from "@omniroute/open-sse/config/freeTierCatalog.ts";

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

Omitting the .ts extension in import paths is the established convention in this codebase (e.g., in src/domain/omnirouteResponseMeta.ts). Explicitly including .ts extensions can cause resolution issues with standard TypeScript compilers and bundlers unless specific flags are enabled. Removing the extension ensures consistency and better compatibility.

Suggested change
import { computeFreeTierTotals } from "@omniroute/open-sse/config/freeTierCatalog.ts";
import { computeFreeTierTotals } from "@omniroute/open-sse/config/freeTierCatalog";


const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};

export function OPTIONS(): Response {
return new Response(null, { status: 204, headers: CORS });
}

export function GET(req: Request): Response {
const url = new URL(req.url);
const excludeTosAvoid = url.searchParams.get("excludeTosAvoid") === "1";
const totals = computeFreeTierTotals({ excludeTosAvoid });
return new Response(JSON.stringify(totals), {
status: 200,
headers: { "Content-Type": "application/json", ...CORS },
});
}
7 changes: 7 additions & 0 deletions src/domain/omnirouteResponseMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ export function formatOmniRouteCost(costUsd: unknown): string {
export function buildOmniRouteResponseMetaHeaders({
cacheHit = false,
costUsd = 0,
fallbackAttempts = 0,
latencyMs = 0,
model = null,
provider = null,
usage = null,
}: {
cacheHit?: boolean;
costUsd?: unknown;
fallbackAttempts?: number;
latencyMs?: unknown;
model?: string | null;
provider?: string | null;
Expand All @@ -76,6 +78,11 @@ export function buildOmniRouteResponseMetaHeaders({
headers[OMNIROUTE_RESPONSE_HEADERS.provider] = getProviderAlias(provider);
}

const attempts = toNonNegativeInteger(fallbackAttempts);
if (attempts > 0) {
headers[OMNIROUTE_RESPONSE_HEADERS.fallbackAttempts] = String(attempts);
}

return headers;
}

Expand Down
1 change: 1 addition & 0 deletions src/shared/constants/headers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const OMNIROUTE_RESPONSE_HEADERS = {
cache: "X-OmniRoute-Cache",
cacheHit: "X-OmniRoute-Cache-Hit",
fallbackAttempts: "X-OmniRoute-Fallback-Attempts",
latencyMs: "X-OmniRoute-Latency-Ms",
model: "X-OmniRoute-Model",
progress: "X-OmniRoute-Progress",
Expand Down
6 changes: 3 additions & 3 deletions src/shared/constants/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
subscriptionRisk: true,
riskNoticeVariant: "deprecated",
hasFree: true,
freeNote: "Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use.",
},
"amazon-q": {
id: "amazon-q",
Expand Down Expand Up @@ -990,7 +991,7 @@
textIcon: "CB",
website: "https://inference.cerebras.ai",
hasFree: true,
freeNote: "Free: 1M tokens/day, 60K TPM — world's fastest inference",
freeNote: "Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card.",
},
cohere: {
id: "cohere",
Expand Down Expand Up @@ -1173,8 +1174,7 @@
textIcon: "LC",
website: "https://longcat.chat/platform/docs",
hasFree: true,
freeNote:
"50M tokens/day (Flash-Lite) + 500K/day (Chat/Thinking) — 100% free while public beta",
freeNote: "Free: 5M tokens/day on LongCat-2.0-Preview (Flash models retired 2026-05-29); up to 120M/day via feedback.",
},
pollinations: {
id: "pollinations",
Expand Down Expand Up @@ -2968,7 +2968,7 @@
for (const section of _PROVIDER_SECTIONS) {
for (const provider of Object.values(section)) {
if (provider.alias === alias || provider.id === alias) {
return provider as AiProviderDefinition;

Check warning on line 2971 in src/shared/constants/providers.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_OmniRoute&issues=AZ6Zu7Tixuz03-S02v72&open=AZ6Zu7Tixuz03-S02v72&pullRequest=3257
}
}
}
Expand All @@ -2986,7 +2986,7 @@
return provider?.alias || providerId;
}

export const ALIAS_TO_ID = new Proxy({} as Record<string, string>, {

Check warning on line 2989 in src/shared/constants/providers.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since the receiver accepts the original type of the expression.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_OmniRoute&issues=AZ6Zu7Tixuz03-S02v73&open=AZ6Zu7Tixuz03-S02v73&pullRequest=3257
get(_, key) {
return typeof key === "string" ? getOrCreateAliasToId()[key] : undefined;
},
Expand All @@ -3005,7 +3005,7 @@
},
});

export const ID_TO_ALIAS = new Proxy({} as Record<string, string>, {

Check warning on line 3008 in src/shared/constants/providers.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since the receiver accepts the original type of the expression.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_OmniRoute&issues=AZ6Zu7Tixuz03-S02v74&open=AZ6Zu7Tixuz03-S02v74&pullRequest=3257
get(_, key) {
return typeof key === "string" ? getOrCreateIdToAlias()[key] : undefined;
},
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/free-note-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getProviderById } from "../../src/shared/constants/providers.ts";

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

Omitting the .ts extension in import paths is the established convention in this codebase. Removing the extension ensures consistency and better compatibility with standard TypeScript compilation.

Suggested change
import { getProviderById } from "../../src/shared/constants/providers.ts";
import { getProviderById } from "../../src/shared/constants/providers";


const note = (id: string): string => getProviderById(id)?.freeNote ?? "";

test("kiro freeNote reflects the current 50-credit/month reality + ToS warning", () => {
const n = note("kiro");
assert.match(n, /50 credits\/month/i);
assert.match(n, /ToS|proxy/i);
});

test("longcat freeNote reflects the post-2026-05-29 5M tokens/day reality", () => {
assert.match(note("longcat"), /5M tokens\/day|LongCat-2\.0/i);
});

test("cerebras freeNote reflects the tightened 30K TPM", () => {
assert.match(note("cerebras"), /30K TPM|1M tokens\/day/i);
});
40 changes: 40 additions & 0 deletions tests/unit/free-tier-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
FREE_TIER_BUDGETS,
FREE_TIER_TOS,
computeFreeTierTotals,
} from "../../open-sse/config/freeTierCatalog.ts";
Comment on lines +3 to +7

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

Omitting the .ts extension in import paths is the established convention in this codebase. Removing the extension ensures consistency and better compatibility with standard TypeScript compilation.

Suggested change
import {
FREE_TIER_BUDGETS,
FREE_TIER_TOS,
computeFreeTierTotals,
} from "../../open-sse/config/freeTierCatalog.ts";
import {
FREE_TIER_BUDGETS,
FREE_TIER_TOS,
computeFreeTierTotals,
} from "../../open-sse/config/freeTierCatalog";


test("FREE_TIER_BUDGETS holds positive integer monthly-token budgets", () => {
assert.ok(Object.keys(FREE_TIER_BUDGETS).length >= 20);
for (const [id, tokens] of Object.entries(FREE_TIER_BUDGETS)) {
assert.ok(Number.isInteger(tokens) && tokens > 0, `${id} must be a positive integer`);
}
assert.equal(FREE_TIER_BUDGETS.mistral, 1_000_000_000);
assert.equal(FREE_TIER_BUDGETS.longcat, 150_000_000);
assert.equal(FREE_TIER_BUDGETS["cloudflare-ai"], 122_000_000);
assert.equal(FREE_TIER_BUDGETS.cerebras, 30_000_000);
});

test("FREE_TIER_TOS marks proxy-prohibited providers as avoid", () => {
for (const id of ["kiro", "gemini-cli", "amazon-q", "blackbox", "fireworks"]) {
assert.equal(FREE_TIER_TOS[id], "avoid", `${id} must be flagged avoid`);
}
});

test("computeFreeTierTotals sums the documented budgets", () => {
const t = computeFreeTierTotals();
assert.equal(t.providerCount, 22);
assert.ok(t.documentedMonthlyTokens >= 1_500_000_000);
assert.ok(t.documentedMonthlyTokens <= 1_600_000_000);
assert.equal(typeof t.headline, "string");
assert.match(t.headline, /1\.5/);
});

test("computeFreeTierTotals can exclude ToS-avoid providers", () => {
const all = computeFreeTierTotals();
const clean = computeFreeTierTotals({ excludeTosAvoid: true });
assert.equal(all.documentedMonthlyTokens - clean.documentedMonthlyTokens, 25_000);
assert.equal(clean.providerCount, 21);
});
20 changes: 20 additions & 0 deletions tests/unit/free-tier-summary-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-freetier-route-"));

const { GET } = await import("../../src/app/api/free-tier/summary/route.ts");

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

Omitting the .ts extension in import paths is the established convention in this codebase. Removing the extension ensures consistency and better compatibility with standard TypeScript compilation.

const { GET } = await import("../../src/app/api/free-tier/summary/route");


test("GET /api/free-tier/summary returns the documented total and breakdown", async () => {
const res = await GET(new Request("http://localhost/api/free-tier/summary"));
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(body.documentedMonthlyTokens >= 1_500_000_000);
assert.equal(body.providerCount, 22);
assert.ok(Array.isArray(body.byProvider));
assert.match(body.headline, /free tokens\/month/);
assert.ok(!JSON.stringify(body).includes("at /"));
});
23 changes: 23 additions & 0 deletions tests/unit/omniroute-meta-fallback-attempts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { OMNIROUTE_RESPONSE_HEADERS } from "../../src/shared/constants/headers.ts";
import { buildOmniRouteResponseMetaHeaders } from "../../src/domain/omnirouteResponseMeta.ts";
Comment on lines +3 to +4

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

Omitting the .ts extension in import paths is the established convention in this codebase. Removing the extension ensures consistency and better compatibility with standard TypeScript compilation.

Suggested change
import { OMNIROUTE_RESPONSE_HEADERS } from "../../src/shared/constants/headers.ts";
import { buildOmniRouteResponseMetaHeaders } from "../../src/domain/omnirouteResponseMeta.ts";
import { OMNIROUTE_RESPONSE_HEADERS } from "../../src/shared/constants/headers";
import { buildOmniRouteResponseMetaHeaders } from "../../src/domain/omnirouteResponseMeta";


test("headers constant exposes the fallback-attempts key", () => {
assert.equal(
OMNIROUTE_RESPONSE_HEADERS.fallbackAttempts,
"X-OmniRoute-Fallback-Attempts"
);
});

test("buildOmniRouteResponseMetaHeaders emits the fallback-attempts count when > 0", () => {
const h = buildOmniRouteResponseMetaHeaders({ model: "gpt", provider: "openai", fallbackAttempts: 2 });
assert.equal(h["X-OmniRoute-Fallback-Attempts"], "2");
});

test("buildOmniRouteResponseMetaHeaders omits the header when 0 / absent", () => {
const none = buildOmniRouteResponseMetaHeaders({ model: "gpt" });
assert.equal(none["X-OmniRoute-Fallback-Attempts"], undefined);
const zero = buildOmniRouteResponseMetaHeaders({ model: "gpt", fallbackAttempts: 0 });
assert.equal(zero["X-OmniRoute-Fallback-Attempts"], undefined);
});