Skip to content

Commit 72a64da

Browse files
authored
fix: diagnose content review failures without blaming connectivity (#226)
1 parent 939121d commit 72a64da

14 files changed

Lines changed: 429 additions & 113 deletions

File tree

‎packages/local-runtime-v2/src/service/turn-system/agent-host/runner/local-agent-turn-runner.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { logger } from '../../../../infra/logging/index.js';
12
import {
23
PiTurnRunner,
34
type LLMModelConfig,
@@ -633,6 +634,16 @@ async function settleNetworkStop(
633634
safetyWriter: LocalOutputSafetyEventWriter,
634635
): Promise<void> {
635636
if (safetyWriter.terminalFailed) return;
637+
logger.warn(
638+
{
639+
sessionId: input.sessionId,
640+
turnId: input.turnId,
641+
reviewOutcome: safetyWriter.reviewStopVariant === 'auth' ? 'auth_error' : 'unavailable',
642+
outputSuppressed: true,
643+
terminalStatus: 'completed',
644+
},
645+
'[content-safety] output review stopped turn',
646+
);
636647
state.networkStopped = true;
637648
state.retractionVariant = safetyWriter.reviewStopVariant;
638649
state.approvedPartial = safetyWriter.getApprovedPartial();

‎packages/local-runtime/src/content-safety/api-v2.ts‎

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
managedBackendRoutingHeaders,
1717
type LocalRuntimeRoutingContext,
1818
} from '../runtime/routing-headers.js';
19+
import { createSafetyFailureReporter } from './diagnostics.js';
1920
import { resolveSafetyApiBase } from './api-base.js';
2021

2122
/** biz-gateway uses V2 numeric scenes with its desktop HTTP envelope. */
@@ -50,26 +51,44 @@ export async function callLocalSafetyCheckV2(input: {
5051
const buildEnv = (input.buildEnv ?? getRuntimeBuildEnv)();
5152
const region = (input.region ?? getRuntimeRegion)();
5253
const token = input.authContext?.accessToken?.trim() || process.env.MAVIS_ACCESS_TOKEN?.trim();
53-
const result = await postSafetyCheckV2({
54-
url: `${resolveSafetyApiBase(region, buildEnv, input.testBaseURL)}/mavis/api/v2/content?require_auth=true`,
55-
headers: {
56-
'User-Agent': 'MiniMaxAgent',
57-
...(token ? { Authorization: `Bearer ${token}` } : {}),
58-
...managedBackendRoutingHeaders(input.routingContext, input.buildEnv ? buildEnv : undefined),
59-
},
60-
body: {
61-
scene: input.request.scene,
62-
...(input.request.content_text !== undefined
63-
? { content_text: input.request.content_text }
64-
: {}),
65-
...(input.request.sessionId !== undefined ? { sessionId: input.request.sessionId } : {}),
66-
...(input.request.image_url !== undefined ? { image_url: input.request.image_url } : {}),
67-
...(input.request.files !== undefined ? { files: input.request.files } : {}),
68-
},
69-
fetchImpl: input.fetchImpl,
70-
signal: input.signal,
71-
});
72-
return parseLocalSafetyCheckV2Result(result);
54+
const url = `${resolveSafetyApiBase(region, buildEnv, input.testBaseURL)}/mavis/api/v2/content?require_auth=true`;
55+
const reportFailure = createSafetyFailureReporter(url, 'v2', input.request.scene);
56+
try {
57+
const result = await postSafetyCheckV2({
58+
url,
59+
headers: {
60+
'User-Agent': 'MiniMaxAgent',
61+
...(token ? { Authorization: `Bearer ${token}` } : {}),
62+
...managedBackendRoutingHeaders(
63+
input.routingContext,
64+
input.buildEnv ? buildEnv : undefined,
65+
),
66+
},
67+
body: {
68+
scene: input.request.scene,
69+
...(input.request.content_text !== undefined
70+
? { content_text: input.request.content_text }
71+
: {}),
72+
...(input.request.sessionId !== undefined ? { sessionId: input.request.sessionId } : {}),
73+
...(input.request.image_url !== undefined ? { image_url: input.request.image_url } : {}),
74+
...(input.request.files !== undefined ? { files: input.request.files } : {}),
75+
},
76+
fetchImpl: input.fetchImpl,
77+
signal: input.signal,
78+
});
79+
return parseLocalSafetyCheckV2Result(result);
80+
} catch (error) {
81+
reportFailure(
82+
error instanceof SafetyCheckV2Error
83+
? {
84+
failureKind: error.kind,
85+
statusCode: error.statusCode,
86+
transportKind: error.transportKind,
87+
}
88+
: { failureKind: 'internal' },
89+
);
90+
throw error;
91+
}
7392
}
7493

7594
function parseLocalSafetyCheckV2Result(body: Record<string, unknown>): LocalSafetyCheckV2Result {

‎packages/local-runtime/src/content-safety/api.ts‎

Lines changed: 34 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import {
2+
classifySafetyTransportError,
3+
isSafetyCheckV2Record,
4+
} from "@mavis/shared/safety-check-v2";
5+
import { createSafetyFailureReporter } from "./diagnostics.js";
16
import {
27
getRuntimeBuildEnv,
38
getRuntimeRegion,
@@ -8,7 +13,6 @@ import {
813
import { callTurnSafetyApi } from "./turn-api.js";
914
import { resolveSafetyApiBase } from "./api-base.js";
1015

11-
import { logger } from "../common/logger.js";
1216
import type { LocalRuntimeAuthContext } from "../runtime/model-resolver.js";
1317
import {
1418
managedBackendRoutingHeaders,
@@ -126,12 +130,7 @@ export async function callSafetyApi(input: {
126130
const accessToken =
127131
input.authContext?.accessToken?.trim() ||
128132
process.env.MAVIS_ACCESS_TOKEN?.trim();
129-
// TODO(shared-oauth): Remove this local-only diagnostic after the content-safety
130-
// resource server accepts mcode-public Bearer tokens in the joint test environment.
131-
// Never add the token, request body/content, or response body to these fields.
132-
const logLocalUpstream =
133-
resolvedBuildEnv === "dev" || resolvedBuildEnv === "test";
134-
const startedAt = Date.now();
133+
const reportFailure = createSafetyFailureReporter(url, "v1", input.scene);
135134
let response: Response;
136135
try {
137136
response = await input.fetchImpl(url, {
@@ -162,20 +161,10 @@ export async function callSafetyApi(input: {
162161
signal: AbortSignal.timeout(CONTENT_REVIEW_TIMEOUT_MS),
163162
});
164163
} catch (error) {
165-
if (logLocalUpstream) {
166-
logger.info(
167-
{
168-
url,
169-
method: "POST",
170-
status: null,
171-
scene: input.scene,
172-
hasBearer: Boolean(accessToken),
173-
durationMs: Date.now() - startedAt,
174-
errorType: error instanceof Error ? error.name : typeof error,
175-
},
176-
"[content-safety] upstream transport failure",
177-
);
178-
}
164+
reportFailure({
165+
failureKind: "transport",
166+
transportKind: classifySafetyTransportError(error),
167+
});
179168
// fetch threw (offline / DNS / connection refused / TLS) or the 10s timeout
180169
// fired: we never reached a verdict. No gateway confirmation → fail closed.
181170
return {
@@ -185,26 +174,21 @@ export async function callSafetyApi(input: {
185174
};
186175
}
187176

188-
if (logLocalUpstream) {
189-
logger.info(
190-
{
191-
url,
192-
method: "POST",
193-
status: response.status,
194-
scene: input.scene,
195-
hasBearer: Boolean(accessToken),
196-
durationMs: Date.now() - startedAt,
197-
upstreamTraceId:
198-
response.headers.get("trace-id") ??
199-
response.headers.get("x-trace-id") ??
200-
response.headers.get("x-request-id") ??
201-
null,
202-
},
203-
"[content-safety] upstream response",
204-
);
177+
let text: string;
178+
try {
179+
text = await response.text();
180+
} catch (error) {
181+
reportFailure({
182+
failureKind: "transport",
183+
transportKind: classifySafetyTransportError(error),
184+
statusCode: response.status,
185+
});
186+
return {
187+
pass: false,
188+
reason: "Service response could not be read",
189+
errorKind: "local_error",
190+
};
205191
}
206-
207-
const text = await response.text();
208192
if (!response.ok) {
209193
// Non-2xx from the gateway, split by class so an auth failure can never be a
210194
// bypass:
@@ -215,6 +199,10 @@ export async function callSafetyApi(input: {
215199
// acceptable, so there is NO valid verdict on our side → `local_error`
216200
// (fail closed). Treating 401/403 as `api_error` would let an
217201
// unauthenticated user bypass every content gate.
202+
reportFailure({
203+
failureKind: response.status === 401 || response.status === 403 ? "auth" : "http",
204+
statusCode: response.status,
205+
});
218206
const error = readErrorText(text);
219207
return {
220208
pass: false,
@@ -240,6 +228,7 @@ export async function callSafetyApi(input: {
240228
try {
241229
data = JSON.parse(text) as typeof data;
242230
} catch {
231+
reportFailure({ failureKind: "response", statusCode: response.status });
243232
// 2xx but the body is not JSON — we cannot read a verdict. Treat as no
244233
// gateway confirmation → fail closed on the input path.
245234
return {
@@ -249,8 +238,9 @@ export async function callSafetyApi(input: {
249238
};
250239
}
251240

252-
const pass = data.pass ?? data.safe;
241+
const pass = isSafetyCheckV2Record(data) ? data.pass ?? data.safe : undefined;
253242
if (typeof pass !== "boolean") {
243+
reportFailure({ failureKind: "response", statusCode: response.status });
254244
// 2xx JSON but no boolean pass/safe field — again no usable verdict → fail
255245
// closed on the input path.
256246
return {
@@ -262,6 +252,9 @@ export async function callSafetyApi(input: {
262252
const activeRegion = region();
263253
const upstreamCode =
264254
typeof data.errorCode === "number" ? data.errorCode : undefined;
255+
if (!pass && upstreamCode === UPSTREAM_CODE.SERVICE_UNAVAILABLE) {
256+
reportFailure({ failureKind: "upstream", statusCode: upstreamCode });
257+
}
265258
return {
266259
pass,
267260
reason:
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { randomUUID } from 'node:crypto';
2+
import type { SafetyTransportKind } from '@mavis/shared/safety-check-v2';
3+
import { logger } from '../common/logger.js';
4+
5+
/** One bounded record per failed check, in every build. Never log raw upstream data. */
6+
export function createSafetyFailureReporter(url: string, apiVersion: 'v1' | 'v2', scene: number) {
7+
const startedAt = Date.now();
8+
const reviewId = randomUUID();
9+
const endpointHost = new URL(url).hostname;
10+
return (failure: {
11+
failureKind: 'transport' | 'auth' | 'http' | 'response' | 'upstream' | 'internal';
12+
transportKind?: SafetyTransportKind;
13+
statusCode?: number;
14+
}): void => {
15+
logger.warn(
16+
{
17+
reviewId,
18+
endpointHost,
19+
apiVersion,
20+
scene,
21+
durationMs: Date.now() - startedAt,
22+
...failure,
23+
},
24+
'[content-safety] review failed',
25+
);
26+
};
27+
}

‎packages/local-runtime/src/runtime/host.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { logger } from "../common/logger.js";
12
import type { LocalEventWriter } from "../events/sink.js";
23
import { PiTurnRunner } from "@mavis/agent-core/pi-turn-runner";
34
import { RuntimeEventStatus, RuntimeEventType } from "@mavis/protocol";
@@ -437,6 +438,16 @@ export class LocalRuntimeHost {
437438
// writer, so keep it authoritative and do not append a synthetic
438439
// COMPLETED/network-notice outcome.
439440
if (safetyWriter.terminalFailed) break;
441+
logger.warn(
442+
{
443+
sessionId: input.sessionId,
444+
turnId: input.turnId,
445+
reviewOutcome: "unavailable",
446+
outputSuppressed: true,
447+
terminalStatus: "completed",
448+
},
449+
"[content-safety] output review stopped turn",
450+
);
440451
networkStopped = true;
441452
retractionVariant = "network";
442453
// Capture the approved prefix so the orchestration can rewrite pi history

‎packages/local-runtime/test/unit/content-safety-api-fail-policy.test.ts‎

Lines changed: 62 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -276,46 +276,70 @@ describe('callSafetyApi fail-policy classification', () => {
276276
expect(headers.get('Token')).toBeNull();
277277
});
278278

279-
it('logs a sanitized upstream response in local test environments', async () => {
280-
const log = vi.spyOn(logger, 'info').mockImplementation(() => {});
281-
const fetchImpl = vi.fn(async () =>
282-
new Response(JSON.stringify({ code: 401, message: 'auth failed' }), {
283-
status: 401,
284-
headers: {
285-
'Content-Type': 'application/json',
286-
'Trace-Id': 'trace-content-safety-test',
287-
},
288-
}),
289-
) as unknown as typeof fetch;
290-
291-
await callSafetyApi({
292-
content: 'must-not-appear-in-log',
293-
scene: SAFETY_SCENE.UserInput,
294-
authContext: { accessToken: 'must-not-appear-token' },
295-
fetchImpl,
296-
region,
297-
buildEnv,
298-
});
299-
300-
expect(log).toHaveBeenCalledWith(
301-
expect.objectContaining({
302-
url: 'https://matrix-test.example.invalid/mavis/api/v1/content',
303-
method: 'POST',
304-
status: 401,
279+
it.each([
280+
['auth', () => jsonResponse({ error: 'private-response' }, 401), 401],
281+
['http', () => jsonResponse({ error: 'private-response' }, 503), 503],
282+
['response', () => new Response('private-response'), 200],
283+
['response', () => jsonResponse(null), 200],
284+
['response', () => jsonResponse({ secret: 'private-response' }), 200],
285+
[
286+
'transport',
287+
() => {
288+
throw new TypeError('private-error https://secret.invalid/?token=private-token', {
289+
cause: { code: 'ENOTFOUND' },
290+
});
291+
},
292+
undefined,
293+
],
294+
[
295+
'transport',
296+
() =>
297+
new Response(
298+
new ReadableStream({
299+
start(controller) {
300+
controller.error(new DOMException('private-error', 'TimeoutError'));
301+
},
302+
}),
303+
),
304+
200,
305+
],
306+
] as const)(
307+
'logs redacted %s failures in production',
308+
async (failureKind, response, statusCode) => {
309+
const log = vi.spyOn(logger, 'warn').mockImplementation(() => {});
310+
const result = await callSafetyApi({
311+
content: 'private-content',
305312
scene: SAFETY_SCENE.UserInput,
306-
hasBearer: true,
307-
durationMs: expect.any(Number),
308-
upstreamTraceId: 'trace-content-safety-test',
309-
}),
310-
'[content-safety] upstream response',
311-
);
312-
const serialized = JSON.stringify(log.mock.calls);
313-
expect(serialized).not.toContain('must-not-appear-token');
314-
expect(serialized).not.toContain('must-not-appear-in-log');
315-
});
313+
authContext: { accessToken: 'private-token' },
314+
attachments: [{ fileUrl: 'https://secret.invalid/private-file', fileName: 'private-name' }],
315+
fetchImpl: async () => response(),
316+
region,
317+
buildEnv: () => 'prod',
318+
});
319+
expect(result.pass).toBe(false);
320+
expect(reviewBlocks(result)).toBe(failureKind !== 'http');
321+
expect(log).toHaveBeenCalledOnce();
322+
expect(log).toHaveBeenCalledWith(
323+
expect.objectContaining({
324+
reviewId: expect.any(String),
325+
endpointHost: 'agent.minimax.cn',
326+
apiVersion: 'v1',
327+
scene: SAFETY_SCENE.UserInput,
328+
durationMs: expect.any(Number),
329+
failureKind,
330+
...(statusCode === undefined ? { transportKind: 'dns' } : { statusCode }),
331+
}),
332+
'[content-safety] review failed',
333+
);
334+
const serialized = JSON.stringify(log.mock.calls);
335+
expect(serialized).not.toContain('private-');
336+
expect(serialized).not.toContain('secret.invalid');
337+
expect(serialized).not.toContain('https://');
338+
},
339+
);
316340

317-
it('does not emit the temporary upstream diagnostic in production', async () => {
318-
const log = vi.spyOn(logger, 'info').mockImplementation(() => {});
341+
it('does not log successful reviews in production', async () => {
342+
const log = vi.spyOn(logger, 'warn').mockImplementation(() => {});
319343

320344
await callSafetyApi({
321345
content: 'hello',

0 commit comments

Comments
 (0)