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
5 changes: 5 additions & 0 deletions .changeset/provider-spend-caps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workspace/lib": patch
---

Cap per-request spend on the direct API providers: output tokens on Anthropic, OpenAI, OpenRouter, and Mistral, plus web-search budget (Anthropic `max_uses`, OpenAI `maxToolCalls`), so no single tracked run can spend unboundedly.
47 changes: 47 additions & 0 deletions packages/lib/src/providers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,53 @@ import type { ModelConfig } from "./types";
// of truth, shared with the CLI); re-exported here for compatibility.
export { parseScrapeTargets } from "@workspace/config/scrape-targets";

// Per-call output caps for the direct API providers: a worst-case bound on a
// single tracked run, not a target length — sized well above any answer we
// expect, so hitting one means something went wrong (warnIfOutputCapped logs
// it). These are code defaults; per-target overrides are a follow-up and aren't
// threaded through ProviderOptions yet.
//
// anthropic-api stays at 4000 to match its long-standing production cap, so
// nothing changes for Anthropic. The others sit at 8000; note OpenAI's and
// OpenRouter's cap also counts reasoning tokens, so on reasoning-by-default
// targets (gpt-5, grok-4.5, gemini-2.5-flash, deepseek-v3.2) that ceiling
// covers reasoning plus visible output.
export const API_PROVIDER_MAX_OUTPUT_TOKENS: Record<string, number> = {
"anthropic-api": 4000,
"openai-api": 8000,
openrouter: 8000,
"mistral-api": 8000,
};

/**
* A capped response still stores as a normal run, so a clipped answer would
* land as a real-looking result with fewer brand mentions rather than an error.
* Log it — deliberately without failing the run — so the caps above can be
* tuned from evidence.
*/
export function warnIfOutputCapped(provider: string, model: string, finishReason: unknown): void {
if (finishReason === "length" || finishReason === "max_tokens") {
console.warn(
`[${provider}] hit the output cap on "${model}" (finish reason: ${finishReason}) — stored answer may be truncated`,
);
}
}

/** Web-search budget per tracked run. Anthropic bills per search. */
export const ANTHROPIC_WEB_SEARCH_MAX_USES = 1;
/** Caps built-in tool invocations on the OpenAI Responses API per run. */
export const OPENAI_WEB_SEARCH_MAX_TOOL_CALLS = 2;
/** Web-search context tier for OpenAI tracked runs (cheapest tier). */
export const OPENAI_WEB_SEARCH_CONTEXT_SIZE = "low" as const;

// The onboarding structured-research path (runStructuredResearch) is a one-shot,
// non-recurring call, so it searches deeper than a tracked run: recurring tracked
// runs are the cost surface and stay tightly capped above.
/** Web-search budget for the structured-research path (openai maxToolCalls / anthropic maxUses). */
export const RESEARCH_WEB_SEARCH_MAX_USES = 5;
/** Web-search context tier for the structured-research path. */
export const RESEARCH_WEB_SEARCH_CONTEXT_SIZE = "medium" as const;

export function validateScrapeTargets(
configs: ModelConfig[],
getProvider: (
Expand Down
60 changes: 60 additions & 0 deletions packages/lib/src/providers/registry/anthropic-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ANTHROPIC_WEB_SEARCH_MAX_USES, API_PROVIDER_MAX_OUTPUT_TOKENS } from "../config";

const anthropicClient = vi.hoisted(() => ({ create: vi.fn() }));

vi.mock("@anthropic-ai/sdk", () => ({
default: class {
messages = { create: anthropicClient.create };
},
}));

import { anthropicApi } from "./anthropic-api";

const CAP = API_PROVIDER_MAX_OUTPUT_TOKENS["anthropic-api"];

beforeEach(() => {
anthropicClient.create.mockResolvedValue({ content: [], model: "claude-sonnet-4-6" });
});

afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});

function sentArgs(): Record<string, any> {
return anthropicClient.create.mock.calls[0][0] as Record<string, any>;
}

describe("anthropic-api run", () => {
it("caps output tokens and bounds web-search uses when webSearch is on", async () => {
await anthropicApi.run("claude", "prompt", { webSearch: true, version: "claude-sonnet-4-6" });

const args = sentArgs();
expect(args.max_tokens).toBe(CAP);
expect(args.tools).toEqual([
{ type: "web_search_20250305", name: "web_search", max_uses: ANTHROPIC_WEB_SEARCH_MAX_USES },
]);
});

it("caps output tokens and sends no web_search tool when webSearch is off", async () => {
await anthropicApi.run("claude", "prompt", { webSearch: false, version: "claude-sonnet-4-6" });

const args = sentArgs();
expect(args.max_tokens).toBe(CAP);
expect(args).not.toHaveProperty("tools");
});

it("logs a warning when the response stops on the output cap", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
anthropicClient.create.mockResolvedValue({
content: [],
model: "claude-sonnet-4-6",
stop_reason: "max_tokens",
});

await anthropicApi.run("claude", "prompt", { webSearch: false, version: "claude-sonnet-4-6" });

expect(warn).toHaveBeenCalledWith(expect.stringContaining("hit the output cap"));
});
});
21 changes: 15 additions & 6 deletions packages/lib/src/providers/registry/anthropic-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import Anthropic from "@anthropic-ai/sdk";
import { anthropic, createAnthropic } from "@ai-sdk/anthropic";
import { generateText, Output } from "ai";
import { extractTextFromAnthropic } from "../../text-extraction";
import {
ANTHROPIC_WEB_SEARCH_MAX_USES,
API_PROVIDER_MAX_OUTPUT_TOKENS,
RESEARCH_WEB_SEARCH_MAX_USES,
warnIfOutputCapped,
} from "../config";
import type {
Provider,
ScrapeResult,
Expand All @@ -14,9 +20,8 @@ import type { Citation } from "../../text-extraction";
const DEFAULT_RESEARCH_MODEL = "claude-sonnet-4-6";

function getAnthropicLanguageModel(model: string) {
return process.env.ANTHROPIC_API_KEY
? createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY })(model)
: anthropic(model);
const apiKey = process.env.ANTHROPIC_API_KEY;
return apiKey ? createAnthropic({ apiKey })(model) : anthropic(model);
}

function sanitizeForJson(obj: unknown): unknown {
Expand All @@ -34,14 +39,14 @@ async function runAnthropic(prompt: string, model: string, options?: ProviderOpt
tools.push({
type: "web_search_20250305",
name: "web_search",
max_uses: 1,
max_uses: ANTHROPIC_WEB_SEARCH_MAX_USES,
});
}

const makeRequest = () =>
client.messages.create({
model,
max_tokens: 4000,
max_tokens: API_PROVIDER_MAX_OUTPUT_TOKENS["anthropic-api"],
messages: [{ role: "user", content: prompt }],
...(tools.length > 0 ? { tools } : {}),
});
Expand All @@ -59,6 +64,8 @@ async function runAnthropic(prompt: string, model: string, options?: ProviderOpt
}
}

warnIfOutputCapped("anthropic-api", model, response.stop_reason);

const textContent = extractTextFromAnthropic(response);

const webQueries = response.content
Expand Down Expand Up @@ -160,7 +167,9 @@ export const anthropicApi: Provider = {
}: StructuredResearchOptions<T>): Promise<StructuredResearchResult<T>> {
const result = await generateText({
model: getAnthropicLanguageModel(DEFAULT_RESEARCH_MODEL),
...(webSearch ? { tools: { web_search: anthropic.tools.webSearch_20250305({ maxUses: 5 }) } } : {}),
...(webSearch
? { tools: { web_search: anthropic.tools.webSearch_20250305({ maxUses: RESEARCH_WEB_SEARCH_MAX_USES }) } }
: {}),
output: Output.object({ schema }),
prompt,
});
Expand Down
46 changes: 46 additions & 0 deletions packages/lib/src/providers/registry/mistral-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { API_PROVIDER_MAX_OUTPUT_TOKENS } from "../config";
import { mistralApi } from "./mistral-api";

const CAP = API_PROVIDER_MAX_OUTPUT_TOKENS["mistral-api"];

function stubFetch(json: unknown) {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => json });
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}

function sentBody(fetchMock: ReturnType<typeof vi.fn>): Record<string, unknown> {
const [, init] = fetchMock.mock.calls[0];
return JSON.parse((init as RequestInit).body as string);
}

function calledUrl(fetchMock: ReturnType<typeof vi.fn>): string {
return fetchMock.mock.calls[0][0] as string;
}

afterEach(() => {
vi.unstubAllGlobals();
});

describe("mistral-api run", () => {
it("caps output tokens and keeps the web_search tool on the web path", async () => {
const fetchMock = stubFetch({ model: "mistral-medium-latest", outputs: [] });

await mistralApi.run("mistral", "prompt", { webSearch: true, version: "mistral-medium-latest" });

expect(calledUrl(fetchMock)).toContain("/v1/conversations");
const body = sentBody(fetchMock);
expect(body.tools).toEqual([{ type: "web_search" }]);
expect(body.completion_args).toEqual({ max_tokens: CAP });
});

it("caps output tokens on the non-web chat-completions path", async () => {
const fetchMock = stubFetch({ model: "mistral-medium-latest", choices: [{ message: { content: "answer" } }] });

await mistralApi.run("mistral", "prompt", { webSearch: false, version: "mistral-medium-latest" });

expect(calledUrl(fetchMock)).toContain("/v1/chat/completions");
expect(sentBody(fetchMock).max_tokens).toBe(CAP);
});
});
8 changes: 8 additions & 0 deletions packages/lib/src/providers/registry/mistral-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
StructuredResearchResult,
} from "../types";
import type { Citation } from "../../text-extraction";
import { API_PROVIDER_MAX_OUTPUT_TOKENS, warnIfOutputCapped } from "../config";

const MISTRAL_BASE_URL = "https://api.mistral.ai";
const DEFAULT_MODEL = "mistral-medium-latest";
Expand Down Expand Up @@ -90,10 +91,15 @@ export const mistralApi: Provider = {
const version = options?.version ?? DEFAULT_MODEL;

if (options?.webSearch) {
// Mistral's web_search connector has no per-call search-count knob, so the
// token cap (completion_args.max_tokens on this endpoint) is the only budget
// bound. The conversations response carries no finish_reason, so unlike the
// chat-completions path below there's no truncation signal to log here.
const data = await mistralPost("/v1/conversations", {
model: version,
inputs: prompt,
tools: [{ type: "web_search" }],
completion_args: { max_tokens: API_PROVIDER_MAX_OUTPUT_TOKENS["mistral-api"] },
});
const parsed = parseConversationsResponse(data);
return { ...parsed, rawOutput: data, modelVersion: data?.model ?? version };
Expand All @@ -102,7 +108,9 @@ export const mistralApi: Provider = {
const data = await mistralPost("/v1/chat/completions", {
model: version,
messages: [{ role: "user", content: prompt }],
max_tokens: API_PROVIDER_MAX_OUTPUT_TOKENS["mistral-api"],
});
warnIfOutputCapped("mistral-api", version, data?.choices?.[0]?.finish_reason);
return {
rawOutput: data,
textContent: data?.choices?.[0]?.message?.content ?? "",
Expand Down
57 changes: 57 additions & 0 deletions packages/lib/src/providers/registry/openai-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { API_PROVIDER_MAX_OUTPUT_TOKENS, OPENAI_WEB_SEARCH_MAX_TOOL_CALLS } from "../config";

const aiMock = vi.hoisted(() => ({ generateText: vi.fn() }));

vi.mock("ai", () => ({
generateText: aiMock.generateText,
Output: { object: vi.fn() },
}));

import { openaiApi } from "./openai-api";

const CAP = API_PROVIDER_MAX_OUTPUT_TOKENS["openai-api"];

beforeEach(() => {
aiMock.generateText.mockResolvedValue({ text: "answer" });
});

afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});

function sentArgs(): Record<string, any> {
return aiMock.generateText.mock.calls[0][0] as Record<string, any>;
}

describe("openai-api run", () => {
it("caps output tokens and bounds web-search tool calls when webSearch is on", async () => {
await openaiApi.run("chatgpt", "prompt", { webSearch: true, version: "gpt-5-mini" });

const args = sentArgs();
expect(args.maxOutputTokens).toBe(CAP);
expect(args.toolChoice).toBe("auto");
expect(args.tools).toHaveProperty("web_search");
expect(args.providerOptions).toEqual({ openai: { maxToolCalls: OPENAI_WEB_SEARCH_MAX_TOOL_CALLS } });
});

it("caps output tokens and sends no tool-call budget when webSearch is off", async () => {
await openaiApi.run("chatgpt", "prompt", { webSearch: false, version: "gpt-5-mini" });

const args = sentArgs();
expect(args.maxOutputTokens).toBe(CAP);
expect(args.toolChoice).toBe("none");
expect(args).not.toHaveProperty("tools");
expect(args).not.toHaveProperty("providerOptions");
});

it("logs a warning when the response stops on the output cap", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
aiMock.generateText.mockResolvedValue({ text: "clipped", finishReason: "length" });

await openaiApi.run("chatgpt", "prompt", { webSearch: false, version: "gpt-5-mini" });

expect(warn).toHaveBeenCalledWith(expect.stringContaining("hit the output cap"));
});
});
29 changes: 26 additions & 3 deletions packages/lib/src/providers/registry/openai-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { openai, createOpenAI } from "@ai-sdk/openai";
import { generateText, Output } from "ai";
import { extractTextFromOpenAI, extractCitationsFromOpenAI } from "../../text-extraction";
import {
API_PROVIDER_MAX_OUTPUT_TOKENS,
OPENAI_WEB_SEARCH_CONTEXT_SIZE,
OPENAI_WEB_SEARCH_MAX_TOOL_CALLS,
RESEARCH_WEB_SEARCH_CONTEXT_SIZE,
RESEARCH_WEB_SEARCH_MAX_USES,
warnIfOutputCapped,
} from "../config";
import type {
Provider,
ScrapeResult,
Expand All @@ -20,17 +28,25 @@ async function runOpenAI(prompt: string, model: string, options?: ProviderOption
const tools: Record<string, any> = {};
if (options?.webSearch) {
tools.web_search = openai.tools.webSearch({
searchContextSize: "low",
searchContextSize: OPENAI_WEB_SEARCH_CONTEXT_SIZE,
}) as any;
}

const result = await generateText({
model: openai.responses(model),
// Routed through getOpenAIResponsesModel (not the bare `openai` global,
// which reads process.env internally) so overlay credentials apply here.
model: getOpenAIResponsesModel(model),
prompt,
maxOutputTokens: API_PROVIDER_MAX_OUTPUT_TOKENS["openai-api"],
toolChoice: Object.keys(tools).length > 0 ? "auto" : "none",
...(Object.keys(tools).length > 0 ? { tools } : {}),
...(Object.keys(tools).length > 0
? { providerOptions: { openai: { maxToolCalls: OPENAI_WEB_SEARCH_MAX_TOOL_CALLS } } }
: {}),
});

warnIfOutputCapped("openai-api", model, result.finishReason);

// The AI SDK doesn't populate result.response.body for the Responses API, so
// rebuild the raw output from the parsed result (text + web-search sources)
// in the "output" shape the OpenAI extractors expect.
Expand Down Expand Up @@ -84,7 +100,14 @@ export const openaiApi: Provider = {
}: StructuredResearchOptions<T>): Promise<StructuredResearchResult<T>> {
const result = await generateText({
model: getOpenAIResponsesModel(DEFAULT_RESEARCH_MODEL),
...(webSearch ? { tools: { web_search: openai.tools.webSearch({ searchContextSize: "medium" }) as any } } : {}),
...(webSearch
? {
tools: {
web_search: openai.tools.webSearch({ searchContextSize: RESEARCH_WEB_SEARCH_CONTEXT_SIZE }) as any,
},
}
: {}),
...(webSearch ? { providerOptions: { openai: { maxToolCalls: RESEARCH_WEB_SEARCH_MAX_USES } } } : {}),
output: Output.object({ schema }),
prompt,
});
Expand Down
Loading
Loading