From 151bffec0f2afa9caf77c817f0e11d39b4d963ba Mon Sep 17 00:00:00 2001 From: George Pickett Date: Sat, 22 Aug 2026 16:57:33 -0700 Subject: [PATCH] feat(typescript-recipes): measure Groq search agent economics --- README.md | 2 +- .../parallel-search-agent-groq/.env.example | 11 +- .../parallel-search-agent-groq/README.md | 99 ++++++++-- .../economics.test.mjs | 177 ++++++++++++++++++ .../parallel-search-agent-groq/economics.ts | 167 +++++++++++++++++ .../parallel-search-agent-groq/index.html | 46 ++++- .../parallel-search-agent-groq/package.json | 3 +- .../parallel-search-agent-groq/worker.ts | 42 +++-- 8 files changed, 511 insertions(+), 36 deletions(-) create mode 100644 typescript-recipes/parallel-search-agent-groq/economics.test.mjs create mode 100644 typescript-recipes/parallel-search-agent-groq/economics.ts diff --git a/README.md b/README.md index c45b222..54cd520 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ LLM agents that use Parallel's Search API as a tool with the Vercel AI SDK. | Recipe | Description | APIs | Stack | Demo | | --- | --- | --- | --- | --- | | [**Search Agent (Cerebras)**](typescript-recipes/parallel-search-agent-cerebras) | Multi-turn web research agent backed by Cerebras (GPT-OSS / Qwen). Iterative multi-angle searches, full-stack with vanilla JS frontend. | `Search` | Cloudflare Workers · Cerebras · AI SDK | [Live](https://oss.parallel.ai/agent/) | -| [**Search Agent (Groq)**](typescript-recipes/parallel-search-agent-groq) | Same agent shape, Llama 4 Maverick on Groq with 128k context for long sessions. | `Search` | Cloudflare Workers · Groq · AI SDK | [Live](https://oss.parallel.ai/agent/) | +| [**Search Agent (Groq)**](typescript-recipes/parallel-search-agent-groq) | Llama 4 Maverick agent with measured Search latency, retrieved context, provider-reported tokens, and configurable cost estimates. | `Search` | Cloudflare Workers · Groq · AI SDK | [Live](https://oss.parallel.ai/agent/) | ### Data Enrichment diff --git a/typescript-recipes/parallel-search-agent-groq/.env.example b/typescript-recipes/parallel-search-agent-groq/.env.example index c82c577..a1ddf10 100644 --- a/typescript-recipes/parallel-search-agent-groq/.env.example +++ b/typescript-recipes/parallel-search-agent-groq/.env.example @@ -1,2 +1,11 @@ GROQ_API_KEY= -PARALLEL_API_KEY= \ No newline at end of file +PARALLEL_API_KEY= + +# GA Search mode: turbo, fast, basic, or advanced. +PARALLEL_SEARCH_MODE=basic + +# Optional overrides. Leave blank to use published Search defaults or to +# report model cost as unavailable until you provide your actual rates. +PARALLEL_SEARCH_USD_PER_1K= +MODEL_INPUT_USD_PER_1M= +MODEL_OUTPUT_USD_PER_1M= diff --git a/typescript-recipes/parallel-search-agent-groq/README.md b/typescript-recipes/parallel-search-agent-groq/README.md index 472a486..d6c296c 100644 --- a/typescript-recipes/parallel-search-agent-groq/README.md +++ b/typescript-recipes/parallel-search-agent-groq/README.md @@ -2,7 +2,7 @@ [![janwilmake/parallel-search-agent context](https://badge.forgithub.com/janwilmake/parallel-search-agent?lines=false)](https://uithub.com/janwilmake/parallel-search-agent?lines=false) [![](https://remix.forgithub.com/badge)](https://remix.forgithub.com/janwilmake/parallel-search-agent) -This guide demonstrates how to build a web research agent that combines Parallel's Search API with streaming AI inference. By the end, you'll have a complete search agent with a simple frontend that shows searches, results, and AI responses as they stream in real-time. +This guide demonstrates how to build a web research agent that combines Parallel's GA Search API with streaming AI inference. By the end, you'll have a complete search agent that shows searches, results, AI responses, measured retrieval and model usage, and clearly labeled cost estimates as they stream in real time. Complete app available at: https://oss.parallel.ai/agent/ @@ -14,6 +14,7 @@ The search agent we're building includes: - User-editable system prompt in config modal - Agent connection through Parallel Search API tool use - Streaming searches, search results, AI reasoning, and AI responses +- Measured Search latency and context volume, provider-reported model tokens, and configurable cost assumptions - Clean rendering of results as they arrive Our technology stack: @@ -50,15 +51,36 @@ Now that we understand the architectural advantages, let's walk through building ### Dependencies and Setup ```bash -npm i ai zod @ai-sdk/groq +cd typescript-recipes/parallel-search-agent-groq +npm install +cp .env.example .dev.vars ``` +Add your `PARALLEL_API_KEY` and `GROQ_API_KEY` to `.dev.vars`. Then run the deterministic economics tests and start the existing local worker: + +```bash +npm test +npm run dev +``` + +Open the Wrangler URL printed in your terminal, normally `http://localhost:8787`, or inspect the complete event stream directly: + +```bash +curl -N http://localhost:8787/ \ + -H 'Content-Type: application/json' \ + -d '{"query":"What recent product changes should an AI developer know about Parallel Search?"}' +``` + +The final `finish` event includes an `economics` object. Each invocation makes real model and Search API calls, so latency, returned context, token usage, and estimated cost vary. The tests require no API keys, network access, or model calls. + To prevent TypeScript's "Type instantiation is excessively deep" error, zod requires a version suffix. Import the required functions: ```typescript +import Parallel from "parallel-web"; import { createGroq } from "@ai-sdk/groq"; import { streamText, tool, stepCountIs } from "ai"; import { z } from "zod/v4"; +import { createEconomicsTracker, readEconomicsConfig } from "./economics"; ``` ### Defining the Search Tool @@ -66,20 +88,27 @@ import { z } from "zod/v4"; This section covers setting up the core search functionality that will power our AI agent: ```typescript -//define execution of the tool +const economicsConfig = readEconomicsConfig(env); +const economics = createEconomicsTracker(economicsConfig); + const execute = async ({ objective }) => { const parallel = new Parallel({ apiKey: env.PARALLEL_API_KEY, }); - const searchResult = await parallel.beta.search({ + const startedAt = performance.now(); + const searchResult = await parallel.search({ objective, - search_queries: undefined, - processor: "base", - // Keep reasonable to balance context and token usage - max_results: 10, - max_chars_per_result: 1000, + search_queries: [objective], + mode: economicsConfig.searchMode, + max_chars_total: 25_000, + advanced_settings: { + max_results: 10, + excerpt_settings: { max_chars_per_result: 2_500 }, + }, }); + + economics.recordSearch(searchResult, performance.now() - startedAt); return searchResult; }; @@ -99,6 +128,7 @@ const searchTool = tool({ inputSchema: z.object({ objective: z .string() + .max(200) .describe( "Natural-language description of your research goal (max 200 characters)" ), @@ -109,9 +139,9 @@ const searchTool = tool({ ### Key implementation choices: -- We choose "objective" over "search_queries" because it allows for natural language description of research goals, making the tool more intuitive for the AI to use -- The "base" processor prioritizes speed while "pro" focuses on freshness and quality - choose based on your use case requirements -- Token limits are balanced to provide sufficient context without overwhelming the model +- The model writes a natural-language objective, and the Search tool also supplies it as the nonempty `search_queries` array required by GA Search. +- `basic` preserves the closest GA equivalent to the legacy one-shot integration. Set `PARALLEL_SEARCH_MODE=turbo`, `fast`, `basic`, or `advanced` to compare modes on your own workload. +- The existing limit of ten sources with at most 2,500 excerpt characters each remains in place. Character and byte measurements describe retrieved context, not tokenizer output. ## Creating the Streaming Agent @@ -157,13 +187,42 @@ The `stepCountIs(25)` parameter allows the agent to make multiple search calls a The system prompt guides the agent to conduct multiple searches from different perspectives, which is crucial for comprehensive research. -`.env` +`.dev.vars` ```bash GROQ_API_KEY=YOUR_KEY PARALLEL_API_KEY=YOUR_KEY +PARALLEL_SEARCH_MODE=basic +``` + +## Measure the actual integration economics + +Every completed agent response reports: + +- **Measured:** successful Search calls, Search mode, per-call and aggregate client-side Search latency, returned source and excerpt counts, Unicode excerpt characters, serialized tool-result bytes, and total wall-clock workflow duration. +- **Provider-reported:** aggregated model input, output, and total token counts from the AI SDK's final `totalUsage` event. A missing count remains `null`; excerpt characters and bytes are never presented as tokens. +- **Assumed and estimated:** Search and model costs derived from your configured per-unit rates. The total remains `null` until both model rates and actual input/output token counts are available. + +Search prices default to the [published Parallel pricing](https://docs.parallel.ai/getting-started/pricing): `turbo` and `fast` are `$1 / 1,000 requests`, while `basic` and `advanced` are `$5 / 1,000 requests`, each including ten results. These defaults are pricing assumptions, not billing receipts. Verify current pricing and override them if your plan differs: + +```bash +# Example configuration only. Replace the model rates with your actual plan. +PARALLEL_SEARCH_MODE=turbo +PARALLEL_SEARCH_USD_PER_1K=1 +MODEL_INPUT_USD_PER_1M=YOUR_INPUT_PRICE_PER_MILLION +MODEL_OUTPUT_USD_PER_1M=YOUR_OUTPUT_PRICE_PER_MILLION ``` +The estimate uses `search calls × Search price / 1,000` and `(input tokens × input price + output tokens × output price) / 1,000,000`. This recipe requests at most the ten results included in the published Search price. It does not estimate cache discounts, reasoning-specific token rates, taxes, provider credits, unsuccessful requests, or contractual discounts. No benchmark, competitor comparison, or representative latency is implied: run the workflow with your own keys and inputs to collect real measurements. + +For a credential-free, reproducible check of Unicode context measurement, multi-call aggregation, GA mode pricing, missing provider usage, invalid configuration, and model-cost arithmetic: + +```bash +npm test +``` + +The fixture values in those tests are deterministic arithmetic examples, not live benchmark results. + ## Streaming Response Handler This section handles the real-time streaming of agent responses to the frontend: @@ -175,7 +234,11 @@ const stream = new ReadableStream({ async start(controller) { try { for await (const chunk of result.fullStream) { - const data = `data: ${JSON.stringify(chunk)}\n\n`; + const event = + chunk.type === "finish" + ? { ...chunk, economics: economics.summarize(chunk.totalUsage) } + : chunk; + const data = `data: ${JSON.stringify(event)}\n\n`; controller.enqueue(encoder.encode(data)); } controller.enqueue(encoder.encode("data: [DONE]\n\n")); @@ -366,7 +429,7 @@ function handleStreamChunk(chunk) { break; case "finish": finalizeCurrentSection(); - addFinishIndicator(chunk.finishReason); + addFinishIndicator(chunk.finishReason, chunk.economics); console.log("Research completed with reason:", chunk.finishReason); break; } @@ -384,6 +447,8 @@ The complete source files provide essential context for both backend logic and f Essential source files: - `worker.ts` - Complete backend implementation +- `economics.ts` - Measured retrieval, provider usage, and explicit pricing assumptions +- `economics.test.mjs` - Deterministic, credential-free regression coverage - `index.html` - Frontend with streaming UI These files contain the complete TypeScript definitions and HTML implementation that are essential for understanding the full integration between the Parallel Search API and the streaming frontend. @@ -401,7 +466,7 @@ The guide uses Llama 4 Maverick 17B on Groq, which provides excellent speed and This demonstration omits several production requirements: Authentication: No user authentication is implemented -- Rate limiting: Currently limited only by API budgets +- Rate limiting: Uses the configured Cloudflare KV namespace; configure your own namespace for deployment - Error handling: Basic error handling is shown but could be expanded - Monitoring: No observability or logging beyond basic console output @@ -413,4 +478,6 @@ Resources: - [Complete source code](https://github.com/parallel-web/parallel-cookbook/tree/main/typescript-recipes/parallel-search-agent) - [Parallel API documentation](https://docs.parallel.ai/) +- [Search migration guide](https://docs.parallel.ai/search/search-migration-guide) +- [Current Search pricing](https://docs.parallel.ai/getting-started/pricing) - [Get Parallel API keys](https://platform.parallel.ai/) diff --git a/typescript-recipes/parallel-search-agent-groq/economics.test.mjs b/typescript-recipes/parallel-search-agent-groq/economics.test.mjs new file mode 100644 index 0000000..fcd5b5b --- /dev/null +++ b/typescript-recipes/parallel-search-agent-groq/economics.test.mjs @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import ts from "typescript"; + +const { outputText } = ts.transpileModule( + readFileSync(new URL("./economics.ts", import.meta.url), "utf8"), + { compilerOptions: { module: ts.ModuleKind.ESNext } } +); +const { createEconomicsTracker, readEconomicsConfig } = await import( + `data:text/javascript,${encodeURIComponent(outputText)}` +); + +test("GA search modes use their documented, overridable pricing assumptions", () => { + for (const [mode, price] of [ + ["turbo", 1], + ["fast", 1], + ["basic", 5], + ["advanced", 5], + ]) { + assert.equal( + readEconomicsConfig({ PARALLEL_SEARCH_MODE: mode }).searchUsdPer1k, + price + ); + } + + const overridden = readEconomicsConfig({ + PARALLEL_SEARCH_USD_PER_1K: "2.25", + MODEL_INPUT_USD_PER_1M: "0", + MODEL_OUTPUT_USD_PER_1M: "1.50", + }); + + assert.deepEqual(overridden, { + searchMode: "basic", + searchUsdPer1k: 2.25, + modelInputUsdPer1m: 0, + modelOutputUsdPer1m: 1.5, + }); +}); + +test("invalid modes and pricing fail without silently misreporting costs", () => { + assert.throws( + () => readEconomicsConfig({ PARALLEL_SEARCH_MODE: "one-shot" }), + /PARALLEL_SEARCH_MODE/ + ); + + for (const invalidPrice of ["-1", "NaN", "Infinity"]) { + assert.throws( + () => + readEconomicsConfig({ PARALLEL_SEARCH_USD_PER_1K: invalidPrice }), + /non-negative, finite number/ + ); + } +}); + +test("reports measured retrieval, provider token usage, and configured costs", () => { + let now = 1_000; + const tracker = createEconomicsTracker( + readEconomicsConfig({ + PARALLEL_SEARCH_MODE: "turbo", + MODEL_INPUT_USD_PER_1M: "2", + MODEL_OUTPUT_USD_PER_1M: "8", + }), + () => now + ); + const firstSearch = { + search_id: "fixture-search-1", + results: [ + { url: "https://example.com/one", excerpts: ["hello", "🌍"] }, + { url: "https://example.com/two", excerpts: [] }, + ], + }; + const secondSearch = { + search_id: "fixture-search-2", + results: [{ url: "https://example.com/three", excerpts: ["context"] }], + }; + + tracker.recordSearch(firstSearch, 120.4); + tracker.recordSearch(secondSearch, 79.6); + now = 1_450; + + assert.deepEqual( + tracker.summarize({ + inputTokens: 1_200, + outputTokens: 300, + totalTokens: 1_500, + }), + { + search: { + mode: "turbo", + calls: 2, + sources: 3, + excerpts: 3, + excerptCharacters: 13, + serializedResultBytes: + new TextEncoder().encode(JSON.stringify(firstSearch)).length + + new TextEncoder().encode(JSON.stringify(secondSearch)).length, + latenciesMs: [120, 80], + totalLatencyMs: 200, + assumedUsdPer1k: 1, + estimatedCostUsd: 0.002, + }, + inference: { + inputTokens: 1_200, + outputTokens: 300, + totalTokens: 1_500, + assumedInputUsdPer1m: 2, + assumedOutputUsdPer1m: 8, + estimatedCostUsd: 0.0048, + }, + workflow: { + elapsedMs: 450, + estimatedTotalCostUsd: 0.0068, + }, + } + ); +}); + +test("leaves unavailable usage and model pricing explicitly unavailable", () => { + const tracker = createEconomicsTracker(readEconomicsConfig({}), () => 25); + tracker.recordSearch({ results: [{ excerpts: null }] }, 0); + + assert.deepEqual(tracker.summarize(), { + search: { + mode: "basic", + calls: 1, + sources: 1, + excerpts: 0, + excerptCharacters: 0, + serializedResultBytes: new TextEncoder().encode( + JSON.stringify({ results: [{ excerpts: null }] }) + ).length, + latenciesMs: [0], + totalLatencyMs: 0, + assumedUsdPer1k: 5, + estimatedCostUsd: 0.005, + }, + inference: { + inputTokens: null, + outputTokens: null, + totalTokens: null, + assumedInputUsdPer1m: null, + assumedOutputUsdPer1m: null, + estimatedCostUsd: null, + }, + workflow: { + elapsedMs: 0, + estimatedTotalCostUsd: null, + }, + }); +}); + +test("rejects impossible search timings and unavailable provider token counts", () => { + const tracker = createEconomicsTracker( + readEconomicsConfig({ + MODEL_INPUT_USD_PER_1M: "1", + MODEL_OUTPUT_USD_PER_1M: "1", + }), + () => 0 + ); + + assert.throws( + () => tracker.recordSearch({ results: [] }, -1), + /Search latency/ + ); + + const result = tracker.summarize({ + inputTokens: -1, + outputTokens: 10, + totalTokens: Number.NaN, + }); + + assert.equal(result.inference.inputTokens, null); + assert.equal(result.inference.totalTokens, null); + assert.equal(result.inference.estimatedCostUsd, null); +}); diff --git a/typescript-recipes/parallel-search-agent-groq/economics.ts b/typescript-recipes/parallel-search-agent-groq/economics.ts new file mode 100644 index 0000000..54bf1dd --- /dev/null +++ b/typescript-recipes/parallel-search-agent-groq/economics.ts @@ -0,0 +1,167 @@ +export type SearchMode = "turbo" | "fast" | "basic" | "advanced"; + +const DEFAULT_SEARCH_PRICES_USD_PER_1K: Record = { + turbo: 1, + fast: 1, + basic: 5, + advanced: 5, +}; + +interface EconomicsEnvironment { + PARALLEL_SEARCH_MODE?: string; + PARALLEL_SEARCH_USD_PER_1K?: string; + MODEL_INPUT_USD_PER_1M?: string; + MODEL_OUTPUT_USD_PER_1M?: string; +} + +export interface EconomicsConfig { + searchMode: SearchMode; + searchUsdPer1k: number; + modelInputUsdPer1m: number | null; + modelOutputUsdPer1m: number | null; +} + +interface SearchResult { + results: Array<{ excerpts?: string[] | null }>; +} + +interface ModelUsage { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; +} + +function readOptionalPrice(value: string | undefined, name: string) { + if (value === undefined || value.trim() === "") return null; + + const price = Number(value); + if (!Number.isFinite(price) || price < 0) { + throw new Error(`${name} must be a non-negative, finite number`); + } + + return price; +} + +function roundUsd(amount: number) { + return Math.round(amount * 1_000_000_000) / 1_000_000_000; +} + +function readTokenCount(count: number | undefined) { + return typeof count === "number" && Number.isSafeInteger(count) && count >= 0 + ? count + : null; +} + +export function readEconomicsConfig(env: EconomicsEnvironment): EconomicsConfig { + const searchMode = env.PARALLEL_SEARCH_MODE?.trim() || "basic"; + if (!Object.hasOwn(DEFAULT_SEARCH_PRICES_USD_PER_1K, searchMode)) { + throw new Error( + "PARALLEL_SEARCH_MODE must be turbo, fast, basic, or advanced" + ); + } + + const mode = searchMode as SearchMode; + + return { + searchMode: mode, + searchUsdPer1k: + readOptionalPrice( + env.PARALLEL_SEARCH_USD_PER_1K, + "PARALLEL_SEARCH_USD_PER_1K" + ) ?? DEFAULT_SEARCH_PRICES_USD_PER_1K[mode], + modelInputUsdPer1m: readOptionalPrice( + env.MODEL_INPUT_USD_PER_1M, + "MODEL_INPUT_USD_PER_1M" + ), + modelOutputUsdPer1m: readOptionalPrice( + env.MODEL_OUTPUT_USD_PER_1M, + "MODEL_OUTPUT_USD_PER_1M" + ), + }; +} + +/** Tracks observed retrieval and provider-reported inference without estimating tokens. */ +export function createEconomicsTracker( + config: EconomicsConfig, + now: () => number = () => performance.now() +) { + const startedAt = now(); + const encoder = new TextEncoder(); + const searchLatenciesMs: number[] = []; + let sourceCount = 0; + let excerptCount = 0; + let excerptCharacters = 0; + let serializedResultBytes = 0; + + return { + recordSearch(searchResult: SearchResult, elapsedMs: number) { + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { + throw new Error("Search latency must be a non-negative, finite number"); + } + + searchLatenciesMs.push(Math.round(elapsedMs)); + sourceCount += searchResult.results.length; + serializedResultBytes += encoder.encode( + JSON.stringify(searchResult) + ).length; + + for (const result of searchResult.results) { + for (const excerpt of result.excerpts ?? []) { + excerptCount += 1; + excerptCharacters += Array.from(excerpt).length; + } + } + }, + + summarize(usage?: ModelUsage) { + const inputTokens = readTokenCount(usage?.inputTokens); + const outputTokens = readTokenCount(usage?.outputTokens); + const totalTokens = readTokenCount(usage?.totalTokens); + const searchCostUsd = roundUsd( + (searchLatenciesMs.length * config.searchUsdPer1k) / 1_000 + ); + const modelCostUsd = + inputTokens !== null && + outputTokens !== null && + config.modelInputUsdPer1m !== null && + config.modelOutputUsdPer1m !== null + ? roundUsd( + (inputTokens * config.modelInputUsdPer1m + + outputTokens * config.modelOutputUsdPer1m) / + 1_000_000 + ) + : null; + + return { + search: { + mode: config.searchMode, + calls: searchLatenciesMs.length, + sources: sourceCount, + excerpts: excerptCount, + excerptCharacters, + serializedResultBytes, + latenciesMs: [...searchLatenciesMs], + totalLatencyMs: searchLatenciesMs.reduce( + (total, latency) => total + latency, + 0 + ), + assumedUsdPer1k: config.searchUsdPer1k, + estimatedCostUsd: searchCostUsd, + }, + inference: { + inputTokens, + outputTokens, + totalTokens, + assumedInputUsdPer1m: config.modelInputUsdPer1m, + assumedOutputUsdPer1m: config.modelOutputUsdPer1m, + estimatedCostUsd: modelCostUsd, + }, + workflow: { + elapsedMs: Math.max(0, Math.round(now() - startedAt)), + estimatedTotalCostUsd: + modelCostUsd === null ? null : roundUsd(searchCostUsd + modelCostUsd), + }, + }; + }, + }; +} diff --git a/typescript-recipes/parallel-search-agent-groq/index.html b/typescript-recipes/parallel-search-agent-groq/index.html index de5044b..a602f31 100644 --- a/typescript-recipes/parallel-search-agent-groq/index.html +++ b/typescript-recipes/parallel-search-agent-groq/index.html @@ -752,7 +752,7 @@

System Prompt Confi case 'finish': finalizeCurrentSection(); removeLoadingIndicator(); - addFinishIndicator(chunk.finishReason); + addFinishIndicator(chunk.finishReason, chunk.economics); console.log('Research completed with reason:', chunk.finishReason); break; } @@ -987,7 +987,7 @@

System Prompt Confi return div.innerHTML; } - function addFinishIndicator(finishReason) { + function addFinishIndicator(finishReason, economics) { const finishElement = document.createElement('div'); finishElement.className = 'finish-indicator p-4 sm:p-5 rounded mb-4 search-result'; @@ -1001,6 +1001,46 @@

System Prompt Confi • ${reasonDisplay} `; + + if (economics) { + const details = document.createElement('dl'); + details.className = 'mt-3 grid gap-2 text-xs sm:text-sm text-light-secondary'; + + const measuredTokens = economics.inference.inputTokens === null || economics.inference.outputTokens === null + ? 'Unavailable from the model provider' + : `${economics.inference.inputTokens.toLocaleString()} input / ${economics.inference.outputTokens.toLocaleString()} output`; + const modelCost = economics.inference.estimatedCostUsd === null + ? 'Unavailable until model rates and token usage are provided' + : `$${economics.inference.estimatedCostUsd.toFixed(6)}`; + const totalCost = economics.workflow.estimatedTotalCostUsd === null + ? 'Unavailable until model rates and token usage are provided' + : `$${economics.workflow.estimatedTotalCostUsd.toFixed(6)}`; + const rows = [ + ['Measured search calls', `${economics.search.calls} (${economics.search.mode})`], + ['Measured search latency', `${economics.search.totalLatencyMs.toLocaleString()} ms across all calls`], + ['Measured retrieved context', `${economics.search.excerptCharacters.toLocaleString()} excerpt characters; ${economics.search.serializedResultBytes.toLocaleString()} tool-result JSON bytes`], + ['Provider-reported model tokens', measuredTokens], + ['Estimated search cost', `$${economics.search.estimatedCostUsd.toFixed(6)} at $${economics.search.assumedUsdPer1k}/1,000 calls`], + ['Estimated model cost', modelCost], + ['Measured workflow duration', `${economics.workflow.elapsedMs.toLocaleString()} ms`], + ['Estimated total cost', totalCost], + ]; + + for (const [label, value] of rows) { + const row = document.createElement('div'); + const term = document.createElement('dt'); + const description = document.createElement('dd'); + + row.className = 'flex flex-wrap justify-between gap-x-4'; + term.textContent = label; + description.textContent = value; + row.append(term, description); + details.appendChild(row); + } + + finishElement.appendChild(details); + } + resultsContent.appendChild(finishElement); } @@ -1019,4 +1059,4 @@

System Prompt Confi - \ No newline at end of file + diff --git a/typescript-recipes/parallel-search-agent-groq/package.json b/typescript-recipes/parallel-search-agent-groq/package.json index 58d026a..425ac4c 100644 --- a/typescript-recipes/parallel-search-agent-groq/package.json +++ b/typescript-recipes/parallel-search-agent-groq/package.json @@ -3,12 +3,13 @@ "version": "1.0.0", "scripts": { "dev": "wrangler dev", + "test": "node --test economics.test.mjs", "deploy": "read -p \"Enter Cloudflare Account ID: \" account_id && CLOUDFLARE_ACCOUNT_ID=\"$account_id\" wrangler deploy" }, "dependencies": { "@ai-sdk/groq": "^2.0.20", "ai": "^5.0.35", - "parallel-web": "^0.1.0", + "parallel-web": "^1.3.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/typescript-recipes/parallel-search-agent-groq/worker.ts b/typescript-recipes/parallel-search-agent-groq/worker.ts index 231104e..40111e8 100644 --- a/typescript-recipes/parallel-search-agent-groq/worker.ts +++ b/typescript-recipes/parallel-search-agent-groq/worker.ts @@ -1,8 +1,9 @@ /// -import { Parallel } from "parallel-web"; +import Parallel from "parallel-web"; import { createGroq } from "@ai-sdk/groq"; import { streamText, tool, stepCountIs } from "ai"; import { z } from "zod/v4"; +import { createEconomicsTracker, readEconomicsConfig } from "./economics"; import { rateLimitMiddleware } from "./ratelimit"; //@ts-ignore import indexHtml from "./index.html"; @@ -11,6 +12,10 @@ export interface Env { PARALLEL_API_KEY: string; GROQ_API_KEY: string; RATE_LIMIT_KV: KVNamespace; + PARALLEL_SEARCH_MODE?: string; + PARALLEL_SEARCH_USD_PER_1K?: string; + MODEL_INPUT_USD_PER_1M?: string; + MODEL_OUTPUT_USD_PER_1M?: string; } function getClientIP(request: Request): string { @@ -81,26 +86,27 @@ export default { return new Response("Query is required", { status: 400 }); } + const economicsConfig = readEconomicsConfig(env); + const economics = createEconomicsTracker(economicsConfig); + const execute = async ({ objective }) => { const parallel = new Parallel({ apiKey: env.PARALLEL_API_KEY, }); - const searchResult = await parallel.beta.search({ - // Choose objective or search queries. We choose objective because it allows natural language way of describing what you're looking for + const searchStartedAt = performance.now(); + const searchResult = await parallel.search({ objective, - search_queries: undefined, - // "base" works best for apps where speed is important, while "pro" is better when freshness and content-quality is critical - processor: "base", - - source_policy: { - exclude_domains: undefined, - include_domains: undefined, + search_queries: [objective], + mode: economicsConfig.searchMode, + max_chars_total: 25_000, + advanced_settings: { + max_results: 10, + excerpt_settings: { max_chars_per_result: 2_500 }, }, - max_results: 10, - // Keep low to save tokens - max_chars_per_result: 2500, }); + economics.recordSearch(searchResult, performance.now() - searchStartedAt); + return searchResult; }; @@ -120,6 +126,7 @@ export default { inputSchema: z.object({ objective: z .string() + .max(200) .describe( "Natural-language description of your research goal (max 200 characters)" ), @@ -160,7 +167,14 @@ IMPORTANT: Always start by using the search tool - do not provide answers withou async start(controller) { try { for await (const chunk of result.fullStream) { - const data = `data: ${JSON.stringify(chunk)}\n\n`; + const event = + chunk.type === "finish" + ? { + ...chunk, + economics: economics.summarize(chunk.totalUsage), + } + : chunk; + const data = `data: ${JSON.stringify(event)}\n\n`; controller.enqueue(encoder.encode(data)); } controller.enqueue(encoder.encode("data: [DONE]\n\n"));