Skip to content
Merged
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
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,8 @@
"uuid@<11.1.1": "11.1.1",
"js-cookie@<3.0.7": "3.0.7",
"shell-quote@<1.8.4": "1.8.4",
"esbuild@>=0.17.0 <0.28.1": "0.28.1"
"esbuild@>=0.17.0 <0.28.1": "0.28.1",
"@opentelemetry/core@>=2.0.0 <2.8.0": "2.8.0"
}
},
"lint-staged": {
Expand Down
70 changes: 27 additions & 43 deletions apps/api/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 58 additions & 2 deletions apps/api/src/__tests__/snips/v2/keyless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ const KEYLESS_ENABLED =
Number.isFinite(KEYLESS_CREDITS_PER_DAY) &&
KEYLESS_CREDITS_PER_DAY > 0;

// Keyless free tier: scrape, search, and interact work without an API key from
// the official MCP server (origin "mcp*"), CLI (integration "cli"), or SDKs
// Keyless free tier: scrape, parse, search, and interact work without an API key
// from the official MCP server (origin "mcp*"), CLI (integration "cli"), or SDKs
// (origin "<lang>-sdk@..."). Gated per-IP/day by a request cap AND a credit cap.
// These tests are the only ones that exercise keyless mode, so they exclusively
// own the `keyless_*` rate-limit keyspace — we flush it before each test (and run
Expand Down Expand Up @@ -104,6 +104,35 @@ describeIf(KEYLESS_ENABLED)("Keyless free tier", () => {
expect(response.body.error).toContain("not available without an API key");
});

it(
"allows keyless parse upload and charges the keyless credit cap (200)",
async () => {
const response = await request(TEST_API_URL)
.post("/v2/parse")
.field(
"options",
JSON.stringify({ formats: ["markdown"], origin: "mcp" }),
)
.attach(
"file",
Buffer.from("<h1>Keyless Parse Upload</h1><p>Hello.</p>"),
{ filename: "keyless.html", contentType: "text/html" },
);

expect(response.statusCode).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.markdown).toContain("Keyless Parse Upload");
expect(response.body.data.metadata.creditsUsed).toBe(1);

const ip = await currentKeylessIp();
const creditsUsed = Number(
await redisRateLimitClient.get(`keyless_credits:${ip}`),
);
expect(creditsUsed).toBeGreaterThanOrEqual(1);
},
scrapeTimeout,
);

it("enforces the daily request cap with the request signup message (429)", async () => {
// Each request consumes a slot during auth, then fails fast in the controller
// (no url) — so we exhaust the cap without real scrapes, and 0 credits.
Expand Down Expand Up @@ -147,6 +176,33 @@ describeIf(KEYLESS_ENABLED)("Keyless free tier", () => {
expect(blocked.body.error).toContain("unauthenticated credits");
});

it("enforces the daily credit cap on parse (429)", async () => {
// Materialize the loopback IP, then seed its credit counter to the cap.
await request(TEST_API_URL)
.post("/v2/scrape")
.set("Content-Type", "application/json")
.send({ origin: "mcp" });
const ip = await currentKeylessIp();
await redisRateLimitClient.set(
`keyless_credits:${ip}`,
String(KEYLESS_CREDITS_PER_DAY),
);

const blocked = await request(TEST_API_URL)
.post("/v2/parse")
.field(
"options",
JSON.stringify({ formats: ["markdown"], origin: "mcp" }),
)
.attach("file", Buffer.from("<h1>Blocked</h1>"), {
filename: "blocked.html",
contentType: "text/html",
});

expect(blocked.statusCode).toBe(429);
expect(blocked.body.error).toContain("unauthenticated credits");
});

itIf(!!process.env.KEYLESS_PROXY_SECRET)(
"rate-limits per forwarded client IP when the proxy secret is provided",
async () => {
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/controllers/v2/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { getErrorContactMessage } from "../../lib/deployment";
import { captureExceptionWithZdrCheck } from "../../services/sentry";
import type { BillingMetadata } from "../../services/billing/types";
import { getScrapeZDR } from "../../lib/zdr-helpers";
import { chargeKeylessCredits } from "../../lib/keyless";
import path from "node:path";

const AGENT_INTEROP_CONCURRENCY_BOOST = 3;
Expand Down Expand Up @@ -662,6 +663,11 @@ export async function parseController(
concurrencyQueueDurationMs: lockTime || undefined,
});

chargeKeylessCredits(
req.auth.team_id,
doc?.metadata?.creditsUsed ?? 0,
).catch(() => {});

return res.status(200).json({
success: true,
data: {
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/routes/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ v2Router.post(

v2Router.post(
"/parse",
authMiddleware(RateLimiterMode.Scrape),
authMiddleware(RateLimiterMode.Scrape, { allowKeyless: true }),
countryCheck,
parseUploadMiddleware,
parseMultipartPayloadMiddleware,
Expand Down
3 changes: 2 additions & 1 deletion apps/test-suite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"protobufjs@>=7.0.0 <7.6.3": "7.6.4",
"uuid@<11.1.1": "11.1.1",
"postcss@<8.5.10": ">=8.5.10 <9.0.0",
"@grpc/grpc-js@>=1.14.0 <2.0.0": "1.14.4"
"@grpc/grpc-js@>=1.14.0 <2.0.0": "1.14.4",
"@opentelemetry/core@>=2.0.0 <2.8.0": "2.8.0"
}
}
}
Loading
Loading