auditor + gateway: 2 fixes from kimi_architect's first real run

Acted on 2 of 10 findings Kimi caught when auditing its own integration
on PR #11 head 8d02c7f. Skipped 8 (false positives or out-of-scope).

1. crates/gateway/src/v1/kimi.rs — flatten OpenAI multimodal content
   array to plain string before forwarding to api.kimi.com. The Kimi
   coding endpoint is text-only; passing a [{type,text},...] array
   returns 400. Use Message::text() to concat text-parts and drop
   non-text. Verified with curl using array-shape content: gateway now
   returns "PONG-ARRAY" instead of upstream error.

2. auditor/checks/kimi_architect.ts — computeGrounding switched from
   readFileSync to async readFile inside Promise.all. Doesn't matter
   at 10 findings; would matter at 100+. Removed unused readFileSync
   import.

Skipped findings (with reason):
- drift_report.ts:18 schema bump migration concern: the strict
  schema_version refusal IS the migration boundary (v1 readers
  explicitly fail on v2; not a silent corruption risk).
- replay.ts:383 ISO timestamp precision: Date.toISOString always
  emits "YYYY-MM-DDTHH:mm:ss.sssZ" (ms precision). False positive.
- mode.rs:1035 matrix_corpus deserializer compat: deserialize_string
  _or_vec at mode.rs:175 already accepts both shapes. Confabulation
  from not seeing the deserializer in the input bundle.
- /etc/lakehouse/kimi.env world-readable: actually 0600 root. Real
  concern would be permission-drift; not a code bug.
- callKimi response.json hang: obsolete; we use curl now.
- parseFindings silent-drop: ergonomic concern, not a bug.
- appendMetrics join with "..": works for current path; deferred.
- stubFinding dead-type extension: cosmetic.

Self-audit grounding rate at v1.0.0: 10/10 file:line citations
verified by grep. 2 of 10 actionable bugs landed. The other 8 were
correctly flagged as concerns but didn't earn a code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-04-27 06:16:23 -05:00
parent 3eaac413e6
commit ff5de76241
2 changed files with 22 additions and 11 deletions

View File

@ -27,7 +27,7 @@
// Off by default: caller checks LH_AUDITOR_KIMI=1 before invoking. // Off by default: caller checks LH_AUDITOR_KIMI=1 before invoking.
import { readFile, writeFile, mkdir, appendFile, stat } from "node:fs/promises"; import { readFile, writeFile, mkdir, appendFile, stat } from "node:fs/promises";
import { existsSync, readFileSync } from "node:fs"; import { existsSync } from "node:fs";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import type { Finding, CheckKind } from "../types.ts"; import type { Finding, CheckKind } from "../types.ts";
@ -265,31 +265,36 @@ function parseFindings(content: string): Finding[] {
// is appended into the evidence array so the reader can see which // is appended into the evidence array so the reader can see which
// citations were verified. // citations were verified.
async function computeGrounding(findings: Finding[]): Promise<{ total: number; verified: number; rate: number }> { async function computeGrounding(findings: Finding[]): Promise<{ total: number; verified: number; rate: number }> {
let verified = 0; // readFile (async) instead of readFileSync — caught 2026-04-27 by
for (const f of findings) { // Kimi's self-audit. Sync I/O in an async fn blocks the event loop
// for every cited file; doesn't matter at 10 findings, would matter
// at 100+.
const checks = await Promise.all(findings.map(async (f) => {
const cite = f.evidence[0] ?? ""; const cite = f.evidence[0] ?? "";
const m = /^(\S+?):(\d+)/.exec(cite); const m = /^(\S+?):(\d+)/.exec(cite);
if (!m) continue; if (!m) return false;
const [, relpath, lineStr] = m; const [, relpath, lineStr] = m;
const line = Number(lineStr); const line = Number(lineStr);
if (!line || !relpath) continue; if (!line || !relpath) return false;
const abs = relpath.startsWith("/") ? relpath : resolve(REPO_ROOT, relpath); const abs = relpath.startsWith("/") ? relpath : resolve(REPO_ROOT, relpath);
if (!existsSync(abs)) { if (!existsSync(abs)) {
f.evidence.push("[grounding: file not found]"); f.evidence.push("[grounding: file not found]");
continue; return false;
} }
try { try {
const lines = readFileSync(abs, "utf8").split("\n"); const lines = (await readFile(abs, "utf8")).split("\n");
if (line < 1 || line > lines.length) { if (line < 1 || line > lines.length) {
f.evidence.push(`[grounding: line ${line} > EOF (${lines.length})]`); f.evidence.push(`[grounding: line ${line} > EOF (${lines.length})]`);
continue; return false;
} }
f.evidence.push(`[grounding: verified at ${relpath}:${line}]`); f.evidence.push(`[grounding: verified at ${relpath}:${line}]`);
verified++; return true;
} catch (e) { } catch (e) {
f.evidence.push(`[grounding: read failed: ${(e as Error).message.slice(0, 80)}]`); f.evidence.push(`[grounding: read failed: ${(e as Error).message.slice(0, 80)}]`);
return false;
} }
} }));
const verified = checks.filter(Boolean).length;
const total = findings.length; const total = findings.length;
return { total, verified, rate: total === 0 ? 0 : verified / total }; return { total, verified, rate: total === 0 ? 0 : verified / total };
} }

View File

@ -64,11 +64,17 @@ pub async fn chat(
// upstream API sees the bare model id (e.g. "kimi-for-coding"). // upstream API sees the bare model id (e.g. "kimi-for-coding").
let model = req.model.strip_prefix("kimi/").unwrap_or(&req.model).to_string(); let model = req.model.strip_prefix("kimi/").unwrap_or(&req.model).to_string();
// Flatten content to a plain String. api.kimi.com is text-only on
// the coding endpoint; the OpenAI multimodal array shape
// ([{type:"text",text:"..."},{type:"image_url",...}]) returns 400.
// Message::text() concats text-parts and drops non-text. Caught
// 2026-04-27 by Kimi's self-audit (kimi.rs:137 — content as raw
// serde_json::Value risked upstream rejection).
let body = KimiChatBody { let body = KimiChatBody {
model: model.clone(), model: model.clone(),
messages: req.messages.iter().map(|m| KimiMessage { messages: req.messages.iter().map(|m| KimiMessage {
role: m.role.clone(), role: m.role.clone(),
content: m.content.clone(), content: serde_json::Value::String(m.text()),
}).collect(), }).collect(),
max_tokens: req.max_tokens.unwrap_or(800), max_tokens: req.max_tokens.unwrap_or(800),
temperature: req.temperature.unwrap_or(0.3), temperature: req.temperature.unwrap_or(0.3),