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
66 changes: 66 additions & 0 deletions src/__tests__/utils/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Tests for keyless request errors
*
* The API links every keyless prompt to signup tagged `utm_medium=api`. The CLI
* must retag that link as `cli` so signups started from the CLI are attributed
* to it.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import {
keylessGet,
keylessRequest,
withCliSignupTag,
} from '../../utils/client';

const API_LIMIT_MESSAGE = `You've hit Firecrawl's keyless free tier rate limit. To continue now, create a free API key at https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=api

Then authenticate with:
Authorization: Bearer YOUR_API_KEY`;

const CLI_SIGNUP_URL =
'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=cli';

function stubFetch(status: number, body: unknown) {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(body), { status }))
);
}

describe('withCliSignupTag', () => {
it('retags the keyless signup link as cli', () => {
const message = withCliSignupTag(API_LIMIT_MESSAGE);

expect(message).toContain(CLI_SIGNUP_URL);
expect(message).not.toContain('utm_medium=api');
});

it('leaves messages without the keyless signup link unchanged', () => {
expect(withCliSignupTag('Firecrawl request failed (HTTP 500)')).toBe(
'Firecrawl request failed (HTTP 500)'
);
});
});

describe('keyless requests', () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it('reports the keyless limit with the cli signup link', async () => {
stubFetch(429, { success: false, error: API_LIMIT_MESSAGE });

await expect(
keylessRequest('/v2/scrape', { url: 'https://example.com' })
).rejects.toThrow(CLI_SIGNUP_URL);
});

it('reports the keyless limit on GET requests with the cli signup link', async () => {
stubFetch(429, { success: false, error: API_LIMIT_MESSAGE });

await expect(keylessGet('/v2/research/search?q=test')).rejects.toThrow(
CLI_SIGNUP_URL
);
});
});
14 changes: 9 additions & 5 deletions src/commands/interact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Execute AI prompts or code against a scraped page in a live browser session
*/

import { getClient, isKeylessMode } from '../utils/client';
import { getClient, isKeylessMode, withCliSignupTag } from '../utils/client';
import { getConfig, validateConfig } from '../utils/config';
import {
getScrapeId,
Expand Down Expand Up @@ -100,8 +100,10 @@ export async function handleInteractExecute(
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
(errorData as any).error ||
`HTTP ${response.status}: ${response.statusText}`
withCliSignupTag(
(errorData as any).error ||
`HTTP ${response.status}: ${response.statusText}`
)
);
}

Expand Down Expand Up @@ -167,8 +169,10 @@ export async function handleInteractStop(
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
(errorData as any).error ||
`HTTP ${response.status}: ${response.statusText}`
withCliSignupTag(
(errorData as any).error ||
`HTTP ${response.status}: ${response.statusText}`
)
);
}

Expand Down
7 changes: 4 additions & 3 deletions src/commands/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as path from 'path';
import type { FormatOption } from 'firecrawl';
import type { ParseOptions, ParseResult } from '../types/parse';
import type { ScrapeFormat } from '../types/scrape';
import { getClient, isKeylessMode } from '../utils/client';
import { getClient, isKeylessMode, withCliSignupTag } from '../utils/client';
import { getConfig, validateConfig } from '../utils/config';
import { handleScrapeOutput } from '../utils/output';

Expand Down Expand Up @@ -195,9 +195,10 @@ export async function executeParse(
const payload = (await response.json().catch(() => ({}))) as any;

if (!response.ok || payload?.success === false) {
const message =
const message = withCliSignupTag(
payload?.error ||
`HTTP ${response.status}: ${response.statusText || 'Request failed'}`;
`HTTP ${response.status}: ${response.statusText || 'Request failed'}`
);
return { success: false, error: message };
}

Expand Down
19 changes: 17 additions & 2 deletions src/utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ export function isKeylessMode(apiKey?: string, apiUrl?: string): boolean {
return !getApiKey(apiKey) && !isCustomApiUrl(apiUrl);
}

/**
* The API's keyless prompts link to signup tagged `utm_medium=api`. Retag them
* as `cli` so accounts created from the CLI are attributed to the CLI.
*/
export function withCliSignupTag(message: string): string {
return message.replaceAll(
'utm_source=keyless&utm_medium=api',
'utm_source=keyless&utm_medium=cli'
);
}

export async function keylessRequest(
path: string,
body: Record<string, unknown>
Expand All @@ -42,7 +53,9 @@ export async function keylessRequest(
const json: any = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(
json?.error || `Firecrawl request failed (HTTP ${response.status})`
withCliSignupTag(
json?.error || `Firecrawl request failed (HTTP ${response.status})`
)
);
}
return json;
Expand All @@ -57,7 +70,9 @@ export async function keylessGet(path: string): Promise<any> {
const json: any = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(
json?.error || `Firecrawl request failed (HTTP ${response.status})`
withCliSignupTag(
json?.error || `Firecrawl request failed (HTTP ${response.status})`
)
);
}
return json;
Expand Down
Loading