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
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,21 +382,45 @@ result = app.agent(
)
```

#### Model Selection
#### Effort Selection

Choose between two models based on your needs:
Set how much reasoning the agent spends on the task:

| Effort | Best For |
|--------|----------|
| `low` | Simple lookups on one site |
| `medium` | Multi-step tasks on a few pages |
| `high` | Deep research, complex navigation, critical data |

```python
result = app.agent(
prompt="Compare enterprise features across Firecrawl, Apify, and ScrapingBee",
effort="high"
)
```

Every effort level runs the `spark-2` model. Effort changes the reasoning
budget, not the model.

#### Model Selection (Legacy)

`model` still works, and it stays supported. Send `model` or `effort`, not
both. A request with both fields returns a 400 error.

| Model | Cost | Best For |
|-------|------|----------|
| `spark-1-mini` | 60% cheaper | Most tasks |
| `spark-1-pro` (default) | Standard | Complex research, critical data gathering |
| `spark-2` | See [pricing](https://docs.firecrawl.dev/features/agent) | The model that `effort` runs |

```python
result = app.agent(
prompt="Compare enterprise features across Firecrawl, Apify, and ScrapingBee",
model="spark-1-pro"
)
```

A request without `model` and without `effort` runs `spark-1-pro`.

**When to use Pro:**
- Comparing data across multiple websites
Expand Down
128 changes: 128 additions & 0 deletions apps/api/src/__tests__/snips/v1/json-extract-prompt-injection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {
ALLOW_TEST_SUITE_WEBSITE,
describeIf,
HAS_AI,
concurrentIf,
TEST_PRODUCTION,
TEST_SUITE_WEBSITE,
} from "../lib";
import {
scrape,
scrapeRaw,
creditUsage,
scrapeTimeout,
idmux,
Identity,
} from "./lib";

const sleep = (ms: number) => new Promise(x => setTimeout(() => x(true), ms));
const sleepForBatchBilling = () => sleep(40000);

let identity: Identity;

beforeAll(async () => {
identity = await idmux({
name: "v1-json-extract-prompt-injection",
concurrency: 100,
credits: 1000000,
});
}, 10000 + scrapeTimeout);

const schema = {
type: "object",
properties: {
title: { type: "string" },
},
required: ["title"],
};

describeIf(TEST_PRODUCTION || (HAS_AI && ALLOW_TEST_SUITE_WEBSITE))(
"V1 JSON prompt-injection check",
() => {
it.concurrent(
"does not change behavior or billing when checkPromptInjection is unset",
async () => {
const response = await scrape(
{
url: `${TEST_SUITE_WEBSITE}/product`,
formats: ["json"],
jsonOptions: { schema },
timeout: scrapeTimeout,
},
identity,
);

expect(response.json).toBeDefined();
expect(response.metadata.creditsUsed).toBe(5);
},
scrapeTimeout,
);

it.concurrent(
"bills 9 credits and succeeds when checkPromptInjection is true and content is clean",
async () => {
const response = await scrape(
{
url: `${TEST_SUITE_WEBSITE}/product`,
formats: ["json"],
jsonOptions: { schema, checkPromptInjection: true },
timeout: scrapeTimeout,
},
identity,
);

expect(response.json).toBeDefined();
expect(response.metadata.creditsUsed).toBe(9);
},
scrapeTimeout,
);

it.concurrent(
"blocks when checkPromptInjection is true and content contains a prompt injection",
async () => {
const response = await scrapeRaw(
{
url: `${TEST_SUITE_WEBSITE}/prompt-injection`,
formats: ["json"],
jsonOptions: { schema, checkPromptInjection: true },
timeout: scrapeTimeout,
},
identity,
);

expect(response.statusCode).toBe(403);
expect(response.body.success).toBe(false);
expect(response.body.code).toBe("SCRAPE_PROMPT_INJECTION_DETECTED");
expect(typeof response.body.error).toBe("string");
},
scrapeTimeout,
);

// Self-hosted idmux has no per-team isolation, like billing.test.ts.
concurrentIf(TEST_PRODUCTION)(
"bills 5 credits when checkPromptInjection blocks a prompt injection",
async () => {
const blockedIdentity = await idmux({
name: "v1-json-extract-prompt-injection/blocked",
credits: 1000,
});
const rc1 = (await creditUsage(blockedIdentity)).remaining_credits;

await scrapeRaw(
{
url: `${TEST_SUITE_WEBSITE}/prompt-injection`,
formats: ["json"],
jsonOptions: { schema, checkPromptInjection: true },
timeout: scrapeTimeout,
},
blockedIdentity,
);

await sleepForBatchBilling();
const rc2 = (await creditUsage(blockedIdentity)).remaining_credits;
expect(rc1 - rc2).toBe(5);
},
scrapeTimeout + 40000,
);
},
);
150 changes: 150 additions & 0 deletions apps/api/src/__tests__/snips/v2/agent-effort.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import request from "supertest";
import { config } from "../../../config";
import {
describeIf,
HAS_AI,
Identity,
idmux,
scrapeTimeout,
TEST_API_URL,
TEST_PRODUCTION,
TEST_SUITE_WEBSITE,
} from "../lib";

// An accepted POST /v2/agent drives a browser and a model, so the cases that
// start a run need fire-engine and AI. AGENTS.md gates fire-engine tests on
// !TEST_SUITE_SELF_HOSTED, and AI tests on !TEST_SUITE_SELF_HOSTED ||
// OPENAI_API_KEY || OLLAMA_BASE_URL. Those cases also forward to the agent beta
// service, so that URL must be configured too.
const REQUIRES_FIRE_ENGINE = TEST_PRODUCTION;
const REQUIRES_AI = TEST_PRODUCTION || HAS_AI;
const HAS_AGENT_BETA = !!config.EXTRACT_V3_BETA_URL;

let identity: Identity;

beforeAll(async () => {
identity = await idmux({
name: "agent-effort",
credits: 1000000,
});
}, scrapeTimeout);

const agentRaw = (body: Record<string, unknown>) =>
request(TEST_API_URL)
.post("/v2/agent")
.set("Authorization", `Bearer ${identity.apiKey}`)
.set("Content-Type", "application/json")
.send(body);

// agentRequestSchema.parse runs at the top of agentController, before the
// controller touches the agent service, a browser or a model. A rejected
// request therefore needs only the API server and an authenticated team, so
// these cases run everywhere instead of skipping with the live runs below.
describe("Agent effort parameter validation", () => {
it(
"rejects model and effort together",
async () => {
const response = await agentRaw({
urls: [TEST_SUITE_WEBSITE],
prompt: "What does this page offer?",
model: "spark-1-pro",
effort: "low",
});

expect(response.statusCode).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toContain("model");
expect(response.body.error).toContain("effort");
},
scrapeTimeout,
);

it(
"rejects an unknown effort level",
async () => {
const response = await agentRaw({
urls: [TEST_SUITE_WEBSITE],
prompt: "What does this page offer?",
effort: "extreme",
});

expect(response.statusCode).toBe(400);
expect(response.body.success).toBe(false);
},
scrapeTimeout,
);
});

describeIf(REQUIRES_FIRE_ENGINE && REQUIRES_AI && HAS_AGENT_BETA)(
"Agent effort parameter",
() => {
// Each accepted request starts a real run. Cancel it so the test does not
// pay for work nobody reads.
const cancel = (jobId: string) =>
request(TEST_API_URL)
.delete(`/v2/agent/${jobId}`)
.set("Authorization", `Bearer ${identity.apiKey}`);

const statusOf = (jobId: string) =>
request(TEST_API_URL)
.get(`/v2/agent/${jobId}`)
.set("Authorization", `Bearer ${identity.apiKey}`);

it(
"accepts effort alone and runs spark-2",
async () => {
const response = await agentRaw({
urls: [TEST_SUITE_WEBSITE],
prompt: "What does this page offer?",
effort: "low",
});

if (response.statusCode !== 200) {
console.warn(
"Agent request with effort did not succeed",
JSON.stringify(response.body, null, 2),
);
}

expect(response.statusCode).toBe(200);
expect(response.body.success).toBe(true);
expect(typeof response.body.id).toBe("string");

const status = await statusOf(response.body.id);
expect(status.statusCode).toBe(200);
expect(status.body.model).toBe("spark-2");

await cancel(response.body.id);
},
scrapeTimeout,
);

it(
"still uses the default model when the caller sends neither field",
async () => {
const response = await agentRaw({
urls: [TEST_SUITE_WEBSITE],
prompt: "What does this page offer?",
});

if (response.statusCode !== 200) {
console.warn(
"Agent request without model or effort did not succeed",
JSON.stringify(response.body, null, 2),
);
}

expect(response.statusCode).toBe(200);
expect(response.body.success).toBe(true);
expect(typeof response.body.id).toBe("string");

const status = await statusOf(response.body.id);
expect(status.statusCode).toBe(200);
expect(status.body.model).toBe("spark-1-pro");

await cancel(response.body.id);
},
scrapeTimeout,
);
},
);
Loading
Loading