Skip to content

Commit 37715ca

Browse files
feat: automatic tool-count trimming to stay within per-provider limits
Claude Code with many MCP servers easily exceeds Anthropic's 128-tool limit, returning a hard 400. Bildy now trims the tool list to fit the resolved provider's ceiling before forwarding, using a smart strategy: tools that appear in recent conversation history (tool_use blocks) are kept first so the most relevant tools survive the cut. Per-provider limits (conservative where undocumented): anthropic/openai/groq: 128 cloudflare: 100 cerebras/nvidia: 64 Override globally via bildy.config.json: { "routing": { "maxTools": N } } Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9b69efe commit 37715ca

4 files changed

Lines changed: 119 additions & 6 deletions

File tree

packages/llm-gateway/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export function resolveConfig(options?: { port?: number; cwd?: string }): Gatewa
8686
shadowMode: fileConfig.routing?.shadowMode ?? defaultConfig.routing.shadowMode,
8787
shadowRoutes: { ...defaultConfig.routing.shadowRoutes, ...fileConfig.routing?.shadowRoutes },
8888
anthropicDirectProxy: fileConfig.routing?.anthropicDirectProxy ?? defaultConfig.routing.anthropicDirectProxy,
89+
maxTools: fileConfig.routing?.maxTools ?? defaultConfig.routing.maxTools,
8990
},
9091
cache: {
9192
...defaultConfig.cache,
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { LLMMessage, LLMRequest, LLMTool } from "../types.js";
2+
3+
// Per-provider tool count ceilings.
4+
// Anthropic/OpenAI: 128 (documented). Others: conservative estimates from
5+
// empirical testing — lower limits fail with cryptic 400/422 errors.
6+
const PROVIDER_TOOL_LIMITS: Record<string, number> = {
7+
anthropic: 128,
8+
openai: 128,
9+
groq: 128,
10+
cerebras: 64,
11+
cloudflare: 100,
12+
nvidia: 64,
13+
};
14+
15+
export const DEFAULT_TOOL_LIMIT = 128;
16+
17+
export function getProviderToolLimit(provider: string): number {
18+
return PROVIDER_TOOL_LIMITS[provider.toLowerCase()] ?? DEFAULT_TOOL_LIMIT;
19+
}
20+
21+
export interface ToolTrimResult {
22+
request: LLMRequest;
23+
trimmed: number;
24+
originalCount: number;
25+
}
26+
27+
/**
28+
* Trim tool list to fit the provider's maximum.
29+
* Prioritizes tools that appear in recent conversation history (most recently
30+
* called first), then fills remaining slots in declaration order.
31+
* Returns the original request unchanged if no trimming is needed.
32+
*/
33+
export function trimToolsForProvider(
34+
request: LLMRequest,
35+
provider: string,
36+
configLimit?: number,
37+
): ToolTrimResult {
38+
const tools = request.tools;
39+
const limit = configLimit ?? getProviderToolLimit(provider);
40+
41+
if (!tools || tools.length <= limit) {
42+
return { request, trimmed: 0, originalCount: tools?.length ?? 0 };
43+
}
44+
45+
const originalCount = tools.length;
46+
const recentlyUsed = extractRecentlyUsedToolNames(request.messages);
47+
const toolByName = new Map<string, LLMTool>(tools.map((t) => [t.function.name, t]));
48+
const kept = new Set<string>();
49+
const result: LLMTool[] = [];
50+
51+
// First pass: recently-used tools in recency order (most recent first)
52+
for (const name of recentlyUsed) {
53+
if (result.length >= limit) break;
54+
const tool = toolByName.get(name);
55+
if (tool && !kept.has(name)) {
56+
result.push(tool);
57+
kept.add(name);
58+
}
59+
}
60+
61+
// Second pass: fill remaining slots in declaration order
62+
for (const tool of tools) {
63+
if (result.length >= limit) break;
64+
const name = tool.function.name;
65+
if (!kept.has(name)) {
66+
result.push(tool);
67+
kept.add(name);
68+
}
69+
}
70+
71+
return {
72+
request: { ...request, tools: result },
73+
trimmed: originalCount - result.length,
74+
originalCount,
75+
};
76+
}
77+
78+
/**
79+
* Walk messages in reverse order collecting tool names from tool_use blocks.
80+
* Returns names in reverse-chronological order (most recently called first).
81+
*/
82+
function extractRecentlyUsedToolNames(messages: LLMMessage[]): string[] {
83+
const seen = new Set<string>();
84+
const result: string[] = [];
85+
86+
for (let i = messages.length - 1; i >= 0; i--) {
87+
const msg = messages[i];
88+
if (!msg.content || typeof msg.content === "string") continue;
89+
90+
const blocks = msg.content as Array<{ type?: string; name?: string }>;
91+
for (const block of blocks) {
92+
if (block.type === "tool_use" && typeof block.name === "string" && !seen.has(block.name)) {
93+
result.push(block.name);
94+
seen.add(block.name);
95+
}
96+
}
97+
}
98+
99+
return result;
100+
}

packages/llm-gateway/src/server.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { openAIResponsesAdapter } from "./adapters/openai-responses.js";
1111
import { buildResponseCacheKey } from "./cache/keys.js";
1212
import { FileCache } from "./cache/sqlite-cache.js";
1313
import { ANTHROPIC_INPUT_COST_PER_TOKEN, ANTHROPIC_OUTPUT_COST_PER_TOKEN, classifyRequest, computeShadowDecision, type ShadowDecision } from "./policy/classify.js";
14+
import { trimToolsForProvider } from "./policy/tool-trim.js";
1415
import { defaultCompatibilityRegistry, selectCompatibleProvider } from "./policy/compatibility.js";
1516
import {
1617
buildGatewayModelAliases,
@@ -449,16 +450,25 @@ async function executeRequest(params: {
449450
const provider = shadowMode ? "anthropic" : resolvedProvider;
450451
const modelOverride = shadowMode ? undefined : modelResolution.model;
451452

453+
// Trim tool list to the resolved provider's maximum before forwarding.
454+
// Prioritizes recently-used tools from conversation history so the most
455+
// relevant tools survive the cut.
456+
const toolTrim = trimToolsForProvider(requestToForward, provider, params.config.routing.maxTools);
457+
const requestToSend = toolTrim.trimmed > 0 ? toolTrim.request : requestToForward;
458+
if (toolTrim.trimmed > 0) {
459+
console.log(`[gateway] ${params.context.requestId} tool-trim: ${toolTrim.originalCount}${toolTrim.originalCount - toolTrim.trimmed} tools (provider=${provider} limit=${toolTrim.originalCount - toolTrim.trimmed})`);
460+
}
461+
452462
const safeResponseCacheRoute = activeRoute === "summary";
453463
const shouldCheckResponseCache =
454464
params.config.cache.enabled
455465
&& params.config.cache.responseCache
456466
&& safeResponseCacheRoute
457-
&& !requestToForward.stream
458-
&& (requestToForward.sampling?.temperature ?? 0) <= 0.2;
467+
&& !requestToSend.stream
468+
&& (requestToSend.sampling?.temperature ?? 0) <= 0.2;
459469

460470
if (shouldCheckResponseCache) {
461-
const responseKey = buildResponseCacheKey(requestToForward);
471+
const responseKey = buildResponseCacheKey(requestToSend);
462472
const cachedPayload = params.cache.getResponse(responseKey);
463473
if (cachedPayload) {
464474
const cachedResponse = JSON.parse(cachedPayload) as LLMResponse;
@@ -481,11 +491,11 @@ async function executeRequest(params: {
481491

482492
let shadow: ShadowDecision | undefined;
483493
if (shadowMode) {
484-
shadow = computeShadowDecision(requestToForward, activeRoute, resolvedProvider);
494+
shadow = computeShadowDecision(requestToSend, activeRoute, resolvedProvider);
485495
}
486496

487497
const result = await routeViaProviders(
488-
requestToForward,
498+
requestToSend,
489499
activeRoute,
490500
provider,
491501
params.context.requestId,
@@ -494,7 +504,7 @@ async function executeRequest(params: {
494504
);
495505

496506
if (shouldCheckResponseCache && result.response.outputText) {
497-
const responseKey = buildResponseCacheKey(requestToForward);
507+
const responseKey = buildResponseCacheKey(requestToSend);
498508
params.cache.setResponse(responseKey, JSON.stringify(result.response));
499509
}
500510

packages/llm-gateway/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ export interface RoutingConfig {
116116
shadowRoutes?: Partial<Record<RouteClass, boolean>>;
117117
anthropicDirectProxy?: boolean;
118118
routes: Record<RouteClass, string[]>;
119+
/** Override per-provider tool limits. Set to a number to cap all providers at that value. */
120+
maxTools?: number;
119121
}
120122

121123
export interface GatewayConfig {

0 commit comments

Comments
 (0)