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
7 changes: 7 additions & 0 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import {
} from "../services/tokenRefresh.ts";
import { createRequestLogger } from "../utils/requestLogger.ts";
import { createPreparedRequestLogger, runWithCapture } from "../utils/providerRequestLogging.ts";
import { summarizeToolSources } from "../utils/toolSources.ts";
import { applyResponsesPreviousResponseIdPolicy } from "../utils/responsesStatePolicy.ts";
import { applyClaudeEffortVariant } from "./chatCore/claudeEffortVariant.ts";
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../config/defaultThinkingSignature.ts";
Expand Down Expand Up @@ -659,6 +660,12 @@ export async function handleChatCore({
const noLogEnabled = apiKeyInfo?.noLog === true;
// Consolidate settings reads — fetch once, reuse throughout the request
const settings = cachedSettings ?? (await getCachedSettings());
// Opt-in tool-source diagnostics (#1825): summarize the request's tool definitions
// (count + MCP/hosted/client source breakdown + first names) as a single debug line.
if (settings.logToolSources === true) {
const toolSummary = summarizeToolSources((body as { tools?: unknown }).tools);
if (toolSummary) log?.debug?.("TOOLS", toolSummary);
}
// #1311 (opt-in): echo the client-requested alias/combo name in the response `model`
// field instead of the upstream model, so strict clients (Claude Desktop) that validate
// response.model === request.model stop rejecting alias/combo requests with a 401.
Expand Down
69 changes: 69 additions & 0 deletions open-sse/utils/toolSources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* toolSources.ts — diagnostic classification of request tool definitions.
*
* Pure helpers that summarize the `tools` array of a chat request into a
* single human-readable line (tool count, per-source counts, and the first
* N tool names). Gated behind the `logToolSources` setting and emitted as a
* `log.debug("TOOLS", ...)` line in the chat handler so operators can see, at
* a glance, which MCP servers / hosted tools / client tools a request carries.
*
* No side effects — safe to unit test in isolation.
*/

/** A loosely-typed tool definition as it arrives on a chat request body. */
export interface ToolLike {
name?: string;
type?: string;
function?: { name?: string };
[key: string]: unknown;
}

const MAX_VISIBLE_NAMES = 80;

/**
* Resolve the display name of a tool across the OpenAI/Claude/Gemini shapes:
* top-level `name`, nested `function.name`, or `type` (hosted tools), falling
* back to `"unknown"`.
*/
export function getToolName(tool: ToolLike | null | undefined): string {
return tool?.name || tool?.function?.name || tool?.type || "unknown";
}

/**
* Classify a tool name into its source bucket:
* - `mcp:<server>` / `mcp` for `mcp__<server>__<tool>` names
* - `hosted:web` for `web_search` / `web_fetch`
* - `hosted:computer` for `computer_*` / `str_replace_*`
* - `client` for everything else (client-defined function tools)
*/
export function getToolSource(name: string): string {
if (name.startsWith("mcp__")) {
const parts = name.split("__");
return parts[1] ? `mcp:${parts[1]}` : "mcp";
}
if (name.startsWith("web_search") || name.startsWith("web_fetch")) return "hosted:web";
if (name.startsWith("computer_") || name.startsWith("str_replace_")) return "hosted:computer";
return "client";
}

/**
* Build a one-line diagnostic summary of a request's tools, or `null` when
* there are no tools to report. Shape:
* `<N> tools | sources: <src>=<n>, ... | names: <a>, <b>, ... +<k> more`
*/
export function summarizeToolSources(tools: unknown): string | null {
if (!Array.isArray(tools) || tools.length === 0) return null;
const names = tools.map((tool) => getToolName(tool as ToolLike));
const sourceCounts = new Map<string, number>();
for (const name of names) {
const source = getToolSource(name);
sourceCounts.set(source, (sourceCounts.get(source) || 0) + 1);
}
const sources = Array.from(sourceCounts.entries())
.map(([source, count]) => `${source}=${count}`)
.join(", ");
const visibleNames = names.slice(0, MAX_VISIBLE_NAMES).join(", ");
const suffix =
names.length > MAX_VISIBLE_NAMES ? `, ... +${names.length - MAX_VISIBLE_NAMES} more` : "";
return `${tools.length} tools | sources: ${sources} | names: ${visibleNames}${suffix}`;
}
3 changes: 3 additions & 0 deletions src/lib/db/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ export async function getSettings() {
wsAuth: false,
maxBodySizeMb: requestBodyLimitMbFromEnv(process.env.MAX_BODY_SIZE_BYTES),
debugMode: true,
// Opt-in diagnostic: when true, the chat handler emits a `log.debug("TOOLS", …)`
// line per request summarizing tool count + MCP/hosted/client source breakdown.
logToolSources: false,
// LOCAL_ONLY manage-scope bypass policy defaults (T-011 / spec §Data Model).
// Preserves PR #2473 behaviour on migration — the bypass starts ENABLED
// for `/api/mcp/` so existing manage-scope Bearer clients keep working.
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/tool-source-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import test from "node:test";
import assert from "node:assert/strict";

import {
getToolName,
getToolSource,
summarizeToolSources,
} from "../../open-sse/utils/toolSources.ts";

test("getToolName resolves across OpenAI / Claude / hosted shapes", () => {
assert.equal(getToolName({ name: "search" }), "search");
assert.equal(getToolName({ function: { name: "fn_call" } }), "fn_call");
assert.equal(getToolName({ type: "web_search" }), "web_search");
assert.equal(getToolName({}), "unknown");
assert.equal(getToolName(null), "unknown");
assert.equal(getToolName(undefined), "unknown");
// top-level name wins over nested/type
assert.equal(getToolName({ name: "top", function: { name: "nested" }, type: "x" }), "top");
});

test("getToolSource classifies MCP / hosted / client tools", () => {
assert.equal(getToolSource("mcp__notion__create_page"), "mcp:notion");
assert.equal(getToolSource("mcp__"), "mcp");
assert.equal(getToolSource("web_search"), "hosted:web");
assert.equal(getToolSource("web_fetch_url"), "hosted:web");
assert.equal(getToolSource("computer_use"), "hosted:computer");
assert.equal(getToolSource("str_replace_editor"), "hosted:computer");
assert.equal(getToolSource("get_weather"), "client");
});

test("summarizeToolSources returns null for empty / non-array input", () => {
assert.equal(summarizeToolSources(null), null);
assert.equal(summarizeToolSources(undefined), null);
assert.equal(summarizeToolSources([]), null);
assert.equal(summarizeToolSources("nope"), null);
});

test("summarizeToolSources counts tools and groups by source", () => {
const summary = summarizeToolSources([
{ name: "mcp__notion__create_page" },
{ name: "mcp__notion__search" },
{ type: "web_search" },
{ function: { name: "get_weather" } },
]);
assert.ok(summary);
assert.match(summary as string, /^4 tools \|/);
assert.match(summary as string, /mcp:notion=2/);
assert.match(summary as string, /hosted:web=1/);
assert.match(summary as string, /client=1/);
assert.match(summary as string, /names: mcp__notion__create_page, mcp__notion__search/);
});

test("summarizeToolSources truncates names beyond 80 and reports overflow", () => {
const tools = Array.from({ length: 85 }, (_, i) => ({ name: `tool_${i}` }));
const summary = summarizeToolSources(tools) as string;
assert.match(summary, /^85 tools \|/);
assert.match(summary, /client=85/);
assert.match(summary, /\.\.\. \+5 more$/);
// only the first 80 names should appear before the overflow marker
assert.ok(summary.includes("tool_79"));
assert.ok(!summary.includes("tool_80,"));
});
Loading