// StructuredOutputDetector(veridrop detectors/structured_output.py):要一次 get_weather 工具调用,查 // 1 有 tool_use 块 2 id 以 toolu_ 开头 3 工具名对 4 input 合 schema 5 stop_reason == tool_use // 每项 20 分;caller 是字符串且不在已知枚举里扣 10。 // 偏差 5:原仓库一律 tool_choice {type: any}(强制调用)。Fable 5.1 / Opus 5.5 / Sonnet 5.5 的官方 API 对强制调用回 400, // 这些型号改发 {type: auto}(提示本身就在问天气,模型照样调工具)。 import { rejectsForcedToolChoice } from "../config.js"; import type { DetectorResult } from "../models.js"; import { cpSlice } from "../py.js"; import { ActiveDetector, errText, type DetectClient } from "./base.js"; const TOOL_NAME = "get_weather"; const TOOL_DEF = { name: TOOL_NAME, description: "Get the current weather for a city.", input_schema: { type: "object", properties: { city: { type: "string", description: "The city to look up, e.g. 'Tokyo' or 'San Francisco'." }, unit: { type: "string", enum: ["celsius", "fahrenheit"], description: "Temperature unit." }, }, required: ["city", "unit"], }, }; const PROMPT = "What's the current weather in Tokyo? Use celsius."; const MAX_TOKENS = 200; const VALID_CALLERS = new Set(["direct", "code_execution_20250825", "code_execution_20260120"]); export class StructuredOutputDetector extends ActiveDetector { readonly name = "structured_output"; readonly displayName = "结构化输出"; readonly weight = 12; async run(client: DetectClient, model: string): Promise { const toolChoice = rejectsForcedToolChoice(model) ? { type: "auto" } : { type: "any" }; let resp: Record; try { ({ resp } = await client.messagesCreate({ model, max_tokens: MAX_TOKENS, temperature: 0, tools: [TOOL_DEF], tool_choice: toolChoice, messages: [{ role: "user", content: PROMPT }], })); } catch (e) { return this.result("error", 0, null, null, errText(e)); } const sub: Record> = {}; let score = 0; const content = Array.isArray(resp["content"]) ? (resp["content"] as unknown[]) : []; const objs = content.filter((b): b is Record => !!b && typeof b === "object" && !Array.isArray(b)); const toolBlocks = objs.filter((b) => b["type"] === "tool_use"); const blockTypes = objs.map((b) => b["type"] ?? null); const hasBlock = toolBlocks.length > 0; sub["has_tool_use_block"] = { value: hasBlock, pass: hasBlock }; if (hasBlock) score += 20; if (!hasBlock) { // 没调工具时把文字回答记下来,看得出为什么(比如中转站的系统提示说不支持工具)。 const textExcerpt = cpSlice( objs .filter((b) => b["type"] === "text" && typeof b["text"] === "string") .map((b) => b["text"] as string) .join(""), 400, ); return this.result("fail", score, { sub_checks: sub, content_block_types: blockTypes, stop_reason: resp["stop_reason"] ?? null, text_response: textExcerpt, tool_choice_sent: toolChoice, }); } const tool = toolBlocks[0]!; const bid = tool["id"]; let ok = typeof bid === "string" && bid.startsWith("toolu_"); sub["id_prefix"] = { value: bid ?? null, pass: ok }; if (ok) score += 20; const name = tool["name"]; ok = name === TOOL_NAME; sub["name"] = { value: name ?? null, pass: ok }; if (ok) score += 20; const inp = tool["input"]; const inpObj = inp && typeof inp === "object" && !Array.isArray(inp) ? (inp as Record) : null; ok = inpObj !== null && typeof inpObj["city"] === "string" && Boolean(inpObj["city"]) && (inpObj["unit"] === "celsius" || inpObj["unit"] === "fahrenheit"); sub["input_schema"] = { value: inp ?? null, pass: ok }; if (ok) score += 20; const sr = resp["stop_reason"]; ok = sr === "tool_use"; sub["stop_reason"] = { value: sr ?? null, pass: ok }; if (ok) score += 20; // caller 可选。官方实测会回一个对象,所以只在它是字符串且不在枚举里时扣分。 if ("caller" in tool) { const caller = tool["caller"]; if (typeof caller === "string") { const valid = VALID_CALLERS.has(caller); sub["caller"] = { value: caller, pass: valid }; if (!valid) score = Math.max(0, score - 10); } else if (caller && typeof caller === "object" && !Array.isArray(caller)) { sub["caller"] = { value: { "": "dict", "": Object.keys(caller).sort().slice(0, 8) }, pass: true, note: "non-string caller (Anthropic API drift) — recorded, not penalized", }; } else { sub["caller"] = { value: `<${caller === null ? "NoneType" : Array.isArray(caller) ? "list" : typeof caller}>`, pass: true, note: "non-string caller — recorded, not penalized", }; } } return this.result(score >= 70 ? "pass" : "fail", score, { sub_checks: sub, content_block_types: blockTypes, stop_reason: resp["stop_reason"] ?? null, tool_choice_sent: toolChoice, }); } }