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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"preview": "astro preview",
"deploy": "npm run build && wrangler deploy",
"deploy:www": "wrangler deploy --config wrangler.www.jsonc",
"tokens": "node scripts/build-tokens.mjs"
"tokens": "node scripts/build-tokens.mjs",
"og:posts": "node scripts/render-post-og.mjs"
},
"dependencies": {
"@cloudflare/nimbus-docs": "^0.8.2",
Expand Down
Binary file added public/og/agent-search-api-pricing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/deepseek-web-tools-for-pi.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 32 additions & 3 deletions scripts/check-site.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
Run it as `npm run check`: this reads the generated tokens.css, which
`precheck` writes. */
import { createHash } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";

import sharp from "sharp";

import { SITE } from "../src/site.ts";
import { nimbus, roles, term, themeColor } from "../src/theme.ts";
import { CARD } from "./render-og.mjs";
import { CARD } from "./og-theme.mjs";

const srcDir = new URL("../src/", import.meta.url);
const read = (p) => readFileSync(new URL(p, import.meta.url), "utf8");
Expand Down Expand Up @@ -275,19 +275,48 @@ const pixel = async (file, xFraction, yFraction) => {
};

/* Two committed rasters, two different owners. The OG card is a poster with
its own colors (scripts/render-og.mjs says why), so it answers to CARD; the
its own colors (scripts/og-theme.mjs holds them), so it answers to CARD; the
favicon is a UI surface sitting next to the page in a browser tab, so its
tile answers to the page background. Both are hand-run, both can go stale. */
const baked = [
["social-preview.png", await pixel("social-preview.png", 0.004, 0.004), CARD.ground, "npm run og"],
["favicon.png", await pixel("favicon.png", 0.5, 0.95), themeColor.dark, "re-export it"],
];

/* The per-post cards are committed too, and they are the ones a palette change
strands: scripts/render-post-og.mjs renders them by hand from the same CARD,
so a stale one looks like a design choice. The directory is the list. */
for (const file of readdirSync(new URL("../public/og/", import.meta.url)).filter((f) => f.endsWith(".png"))) {
baked.push([`og/${file}`, await pixel(`og/${file}`, 0.004, 0.004), CARD.ground, "npm run og:posts"]);
}
for (const [file, got, expected, how] of baked) {
if (got !== expected.toLowerCase()) {
problems.push(`${file} is stale: painted ${got}, the palette says ${expected} (${how})`);
}
}

/* ── a post's card is a real file at the size og:image promises ────────── */

/* `image` is the one link in a post's head that nothing else follows: a typo
points og:image at a 404 and the page itself still looks finished. Read that
one field rather than parsing the frontmatter a second time. */
const blogDir = new URL("../src/content/blog/", import.meta.url);
for (const file of readdirSync(blogDir)) {
if (!file.endsWith(".md")) continue;
const declared = readFileSync(new URL(file, blogDir), "utf8").match(/^image:\s*["']?([^"'\s]+)["']?\s*$/m)?.[1];
if (!declared) continue;
const relative = declared.replace(/^\//, "");
const card = new URL(`../public/${relative}`, import.meta.url);
if (!existsSync(card)) {
problems.push(`src/content/blog/${file}: image ${declared} is not a file under public/`);
continue;
}
const { width, height } = await sharp(card.pathname).metadata();
if (width !== 1200 || height !== 630) {
problems.push(`src/content/blog/${file}: image ${declared} is ${width}×${height}, and a post card is 1200×630`);
}
}

/* ── no token is used that nothing defines ────────────────────────────── */

/* Renaming a role is a sweep across CSS and markup, and a missed one is
Expand Down
23 changes: 23 additions & 0 deletions scripts/og-theme.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* Palette and font stack for the social cards. Kept beside the renderers so the
site card and the per-post cards cannot drift apart. */
export const CARD = {
ground: "#11151a",
groundFar: "#080a0d",
glow: "#15383d",
glowFade: "#0d0f12",
accent: "#45cbd3",
headline: "#f1f4f7",
sub: "#9ba3ae",
paneBg: "#0b0e12",
paneBar: "#141920",
paneLine: "#2b323c",
paneText: "#c3cad4",
dim: "#6b7480",
ok: "#8fdc8a",
str: "#e6c68a",
};

export const MONO = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace";

export const escapeXml = (value) =>
value.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" })[c]);
20 changes: 2 additions & 18 deletions scripts/render-og.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import { readFileSync } from "node:fs";
import sharp from "sharp";
import { brandIcons } from "../src/components/brand-icons.ts";
import { CARD, MONO } from "./og-theme.mjs";

/* pi's mark, from the same entry the page's credit lockups draw — one copy of
the path, whichever surface renders it. A brand icon may also be a bare
Expand All @@ -46,24 +47,7 @@ import { brandIcons } from "../src/components/brand-icons.ts";
const pi = brandIcons["pi"];
if (typeof pi === "string") throw new Error("the pi icon is now a bare path — give it a viewBox here");

export const CARD = {
ground: "#11151a",
groundFar: "#080a0d",
glow: "#15383d",
glowFade: "#0d0f12",
accent: "#45cbd3",
headline: "#f1f4f7",
sub: "#9ba3ae",
paneBg: "#0b0e12",
paneBar: "#141920",
paneLine: "#2b323c",
paneText: "#c3cad4",
dim: "#6b7480",
ok: "#8fdc8a",
str: "#e6c68a",
};

const mono = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace";
const mono = MONO;
const dataUri = (file) =>
`data:image/png;base64,${readFileSync(new URL(`../public/${file}`, import.meta.url)).toString("base64")}`;

Expand Down
120 changes: 120 additions & 0 deletions scripts/render-post-og.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/* Renders one social card per post into public/og/, from the post's own title.
Not part of the build — run it after adding or renaming a post:
npm run og:posts

Two shapes on purpose: a card with a title is the post's, and posts without
an `image` frontmatter keep the site card, so nothing silently changes. The
output is committed for the same reason public/social-preview.png is: sharp
resolves fonts against the machine, so the rendering is not host-independent. */
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import { CARD, MONO, escapeXml } from "./og-theme.mjs";

const BLOG_DIR = new URL("../src/content/blog/", import.meta.url);
const OUT_DIR = fileURLToPath(new URL("../public/", import.meta.url));
const WIDTH = 1200;
const HEIGHT = 630;
const PAD = 84;
const TITLE_SIZE = 56;
const MAX_LINES = 4;

function frontmatter(text, file) {
const match = text.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const data = { tags: [] };
let list = null;
for (const line of match[1].split("\n")) {
const item = line.match(/^\s+-\s+(.+)$/);
if (item && list) {
list.push(item[1].trim().replace(/^["']|["']$/g, ""));
continue;
}
const field = line.match(/^(\w+):\s*(.*)$/);
if (!field) continue;
list = null;
const raw = field[2].trim();
/* This reader takes one line per field. A block scalar or an alias arrives
here as the bare character and would be rendered as the title. */
if (/^[>|&*]/.test(raw)) {
throw new Error(
`${file}: ${field[1]} starts with "${raw[0]}", a YAML form this reader does not parse — write it on one line`,
);
}
const value = raw.replace(/^["']|["']$/g, "");
if (value) data[field[1]] = value;
else if (field[1] === "tags") list = data.tags;
}
return data;
}

/* Rough mono advance: 0.6em. Exact enough for wrapping, and the renderer is
checked by eye before the PNGs are committed. */
function wrap(title, size, width) {
const perLine = Math.floor(width / (size * 0.6));
const lines = [];
let line = "";
for (const word of title.split(/\s+/)) {
if (!line) line = word;
else if (`${line} ${word}`.length <= perLine) line += ` ${word}`;
else {
lines.push(line);
line = word;
}
}
if (line) lines.push(line);
/* A card that drops words, or lets a long word run past the edge, ships a
broken image that only shows up in a feed. Fail at render time instead,
while the person adding the post is watching the terminal. */
const overflowing = lines.find((line) => line.length > perLine);
if (overflowing) {
throw new Error(
`${title}: "${overflowing}" is wider than the ${perLine} characters a line holds — shorten it or lower TITLE_SIZE`,
);
}
if (lines.length > MAX_LINES) {
throw new Error(`${title}: wraps to ${lines.length} lines and the card holds ${MAX_LINES} — shorten the title`);
}
return lines;
}

mkdirSync(`${OUT_DIR}og/`, { recursive: true });
const posts = readdirSync(BLOG_DIR)
.filter((file) => file.endsWith(".md"))
.map((file) => ({ file, data: frontmatter(readFileSync(new URL(file, BLOG_DIR), "utf8"), file) }))
.filter(({ data }) => data.title && data.image);

for (const { data } of posts) {
const lines = wrap(data.title, TITLE_SIZE, WIDTH - PAD * 2);
const startY = HEIGHT / 2 - ((lines.length - 1) * TITLE_SIZE * 1.25) / 2 + 10;
const title = lines
.map(
(text, index) =>
`<text x="${PAD}" y="${Math.round(startY + index * TITLE_SIZE * 1.25)}" font-family="${MONO}" font-size="${TITLE_SIZE}" font-weight="600" fill="${CARD.headline}">${escapeXml(text)}</text>`,
)
.join("\n ");

const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop stop-color="${CARD.ground}"/>
<stop offset="1" stop-color="${CARD.groundFar}"/>
</linearGradient>
<radialGradient id="glow" cx="0.82" cy="0.18" r="0.6">
<stop stop-color="${CARD.glow}"/>
<stop offset="1" stop-color="${CARD.glowFade}" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="${WIDTH}" height="${HEIGHT}" fill="url(#g)"/>
<rect width="${WIDTH}" height="${HEIGHT}" fill="url(#glow)"/>
<text x="${PAD}" y="150" font-family="${MONO}" font-size="26" fill="${CARD.accent}"># blog</text>
${title}
<text x="${PAD}" y="${HEIGHT - 64}" font-family="${MONO}" font-size="26" fill="${CARD.sub}">fastagent.sh</text>
<text x="${WIDTH - PAD}" y="${HEIGHT - 64}" text-anchor="end" font-family="${MONO}" font-size="26" fill="${CARD.dim}">${escapeXml(data.tags[0] ?? "blog")}</text>
</svg>
`;

const relative = data.image.replace(/^\//, "");
writeFileSync(`${OUT_DIR}${relative}`, await sharp(Buffer.from(svg)).png().toBuffer());
console.log(`wrote public/${relative} — ${data.title}`);
}
19 changes: 18 additions & 1 deletion src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,24 @@ export const collections = {
reads, so it is what puts a post line in /blog/llms.txt and in the
markdown twin frontmatter. The page and the feed read it too. */
description: z.string(),
/* A named author is a Person in the structured data and a link in the
byline; a post without one is the team, which is an Organization. The
field stays optional rather than defaulted, because "is this a person"
and "is there a profile to link" are different questions: a defaulted
name with no URL would still have to be declared an Organization. */
author: z.string().optional(),
authorUrl: z.url().optional(),
/* Per-post social card, rendered by scripts/render-post-og.mjs. Path is
site-relative; the layout makes it absolute for og:image. */
image: z.string().optional(),
tags: z.array(z.string().regex(/^[a-z0-9-]+$/, "tags must be url-safe slugs")).default([]),
}),
})
/* An authorUrl only means something next to a name; on its own it is
dropped by every reader, which would leave the page quietly
inconsistent with its own frontmatter. */
.refine((data) => !data.authorUrl || data.author, {
message: "authorUrl needs an author: the byline links a name",
path: ["authorUrl"],
}),
}),
};
90 changes: 90 additions & 0 deletions src/content/blog/agent-search-api-pricing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
title: "Search API pricing for AI agents, compared"
date: 2026-10-18
description: "Brave, Tavily, Exa and DeepSeek all quote a price per 1,000 searches. What one request includes, what the plans hide, and what a realistic agent task costs."
tags:
- web-search
- cost
- agents
author: kid7st
authorUrl: https://x.com/kid7st
image: /og/agent-search-api-pricing.png
---

A price per 1,000 requests is where a search API comparison starts, not where it ends. One provider counts a basic query as one request and an advanced one as two. Another bills its result page separately from the page text. A third charges nothing for a query that returns nothing. An agent doing five searches and reading three pages pays a different bill under each of them.

This is the comparison we wanted when wiring search into [pi](https://pi.dev), using list prices read in October 2026. Every provider changes prices; check the link before you budget.

| Provider | List price per 1,000 requests | One request is | Rate limit | Free allowance |
| --- | --- | --- | --- | --- |
| Brave Search | $5 | A query, with optional LLM Context passages | 50 queries/second | $5 monthly credit, about 1,000 requests |
| Tavily | $8 basic, $16 advanced on pay-as-you-go; $5 to $7.50 on plans | A search at `basic` (1 credit) or `advanced` (2 credits) | Plan-dependent | 1,000 credits/month |
| Exa | From $4 for search; $1 per 1,000 pages for contents; $12 to $15 for deep search | A search, or a page of contents | Plan-dependent | $10 in credits per month |
| DeepSeek | ¥5, $0.75 in USD, for search and ¥5 for fetch, billed separately | A successful search or a successful fetch | 5 requests/second per endpoint | None |

## Brave Search API pricing

$5 per 1,000 requests on the Search and Data-for-AI plans, with a $5 monthly credit that covers roughly the first 1,000. The same plan includes the LLM Context endpoint, which returns ranked passages rather than a result list, so an agent can skip a second fetch for a lot of questions. Brave answers from its own index, with no fallback to Google or Bing, and allows 50 queries per second.

Where it bites: the result page is the unit. If your agent then wants full page text, that is either LLM Context (already covered) or your own extraction, and the extraction cost comes out of your infrastructure budget instead.

## Tavily pricing

Tavily sells credits. Pay-as-you-go is $0.008 per credit, and the monthly plans run from $0.0075 down to $0.005 per credit as volume grows. A `basic` search costs one credit, an `advanced` search costs two, so a thousand basic searches are $8 on pay-as-you-go or $5 to $7.50 on a plan, and advanced doubles it. Extraction is priced separately at one credit per five successful URLs, two at `advanced` extraction depth, and a failed extraction is not billed.

Where it bites: the depth flag is the price. An agent that defaults to `advanced` is paying double without a visible difference in the dashboard until the invoice arrives.

## Exa pricing

Exa prices search as a floor and everything else on top: search from $4 per 1,000, page contents at $1 per 1,000 pages, deep search at $12 to $15 per 1,000, and the Answer endpoint at $5 per 1,000. Monitors, which rerun a query on a schedule, are $15 per 1,000, and an Agent run costs $0.012 to $1.00 at fixed effort or is billed by usage. The Free Tier gives $10 in credits each month.

Where it bites: the $4 line covers search results; if the agent needs the text of ten pages for every query, that is a second line on the bill. Deep search is a genuinely different product and priced like one.

## DeepSeek pricing

DeepSeek runs its own index and charges ¥5 per 1,000 successful requests, $0.75 in USD, for search and the same again for fetch. Search returns a rank score, an authority score, and a 600 to 800 character summary per result, so the common case does not need a fetch at all. Each endpoint allows 5 requests per second per account.

Where it bites: the rate limit is 5 QPS rather than 50, so a burst of parallel tool calls needs retries, and some sites are refused by the fetcher outright. Neither is billed, because only successful requests cost anything.

One exception to the billing: server-side search and fetch calls that DeepSeek serves through its own harness, Claude Code or Codex are free until 1 November 2026, when per-request billing starts. A client that calls the API with its own key, as the pi package below does, pays from the first call.

## What the price per 1,000 hides

Three things move a bill more than the headline rate:

1. **Request multipliers.** Tavily's `advanced` counts as two requests. Any provider offering a "deep" or "research" mode charges several times its base rate, and those modes are the ones that look impressive in a demo.
2. **Contents versus results.** A search result is cheap; the text behind ten of them is another product. Exa charges for contents, Tavily charges for extraction, Brave folds some of it into LLM Context, and DeepSeek counts each fetch as a request of its own.
3. **Failures.** DeepSeek bills only successful searches and fetches. Tavily does not bill failed extractions. Others bill the request whether or not it produced anything usable, which matters when scanning a long tail where a third of the queries come back empty.

## One task, four bills

Five searches and the text of three pages, the shape of a small research step:

| Provider | What it bills | Cost |
| --- | --- | --- |
| Brave | 8 requests, LLM Context included | $0.04 |
| Tavily | 5 basic searches + 1 extraction credit | $0.048 pay-as-you-go, about $0.045 on the Project plan |
| Exa | 5 searches + 3 pages of contents | about $0.02 |
| DeepSeek | 8 successful requests, ¥0.005 each | ¥0.04, about $0.006 |

At a hundred tasks a day, that month is about $120 on Brave and $135 to $144 on Tavily, against about $18 on DeepSeek. Exa lands near $60: its search floor is lower, but its contents are metered separately.

## How to choose

- **Already standardized on a provider:** keep it. The integration cost of switching search providers is higher than the per-request difference for most teams.
- **Need an index that is not a Google or Bing pass-through:** Brave.
- **Need advanced or deep research modes:** Tavily `advanced` or Exa deep search, and budget at the advanced rate rather than the headline one.
- **Need page text at volume:** compare Exa contents, Tavily extract, and a provider whose fetch is its own endpoint.
- **Cheapest per successful request, with ranking scores and summaries included:** DeepSeek. It is also reachable from mainland China without a proxy, which rules out several of the others for teams there.

## The pi package

We wrapped the DeepSeek endpoints into two pi tools, `deepseek_search` and `deepseek_fetch`, and wrote up the details in [DeepSeek web search for pi](/blog/deepseek-web-tools-for-pi/). The short version: they register names that do not collide with pi-web-access, the footer shows what the session has spent, and `/deepseek-tools` switches them off when you do not want to pay for a call.

```
pi install npm:@fastagent-sh/pi-deepseek-tools
```

The same team builds [FastAgent](https://github.com/fastagent-sh/fastagent), which serves a file-defined agent directory as a live service in your app, on GitHub, Telegram or Feishu. This post is about the search layer under an agent; that repository is about everything around it. If this comparison saved you a spreadsheet, [a star there](https://github.com/fastagent-sh/fastagent) is how the next person finds it.

Disclosure: the package is published by fastagent, which publishes this blog. Prices above are list prices from each provider's public pricing page, read in October 2026; none of the providers reviewed the post.
Loading
Loading