mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(core): surface Anthropic refusal details (#16079)
* fix(core): surface Anthropic refusal details Propagate Anthropic refusal stop_details through candidate finishMessage and the Finished event so both terminal UIs can show the provider's refusal category instead of a generic empty safety stop. Co-authored-by: Qwen-Coder <[email protected]> * style(core): apply Prettier to the refusal-detail diff The incremental lint gate selected 12 changed files and Prettier rejected four of them; this commit only re-wraps those lines, no behaviour change. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmvygjkzk7s * fix(core,cli): scope the refusal notice to safety stops Review follow-up on #16079: - Both renderers now prefer `finishMessage` only for `SAFETY` stops. The field is a provider wire field (`@google/genai` types it), so a Gemini turn carrying it could otherwise replace the curated notice for `MAX_TOKENS` and even add an info line on `STOP`. - The refusal category lookup uses an own-property check: a provider `category` of `__proto__` / `constructor` now falls back to the generic notice instead of returning an object that renders as `[object Object]`. - `llm-chat` deletes `finishMessage` together with the deferred `finishReason`, so the stream no longer yields a notice with no reason. - Tests: category table (all mapped categories + prototype key + unknown) and a non-safety renderer case pinning the curated notice. Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-closeout/jmw651acyim --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
588db8b137
commit
a32684604a
12 files changed
+285
-5
No files matched your search
@@ -20107,6 +20107,35 @@ describe('useLlmStream', () => {
|
||||
expect(infoMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should prefer a provider refusal message', async () => {
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
type: ServerLlmEventType.Finished,
|
||||
value: {
|
||||
reason: 'SAFETY',
|
||||
usageMetadata: undefined,
|
||||
finishMessage:
|
||||
'Response refused due to cyber-related safety policy.',
|
||||
},
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
const { result } = renderTestHook();
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('Test refusal');
|
||||
});
|
||||
|
||||
const infoItems = mockAddItem.mock.calls
|
||||
.map(([item]) => item as HistoryItem)
|
||||
.filter((item) => item.type === 'info');
|
||||
expect(infoItems).toContainEqual({
|
||||
type: 'info',
|
||||
text: '⚠ Response refused due to cyber-related safety policy.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add message for FINISH_REASON_UNSPECIFIED', async () => {
|
||||
// Setup mock to return a stream with FINISH_REASON_UNSPECIFIED
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
|
||||
@@ -3002,7 +3002,13 @@ export const useLlmStream = (
|
||||
'Response stopped due to unexpected tool call.',
|
||||
};
|
||||
|
||||
const message = finishReasonMessages[finishReason];
|
||||
// `finishMessage` is provider-authored free text; only the safety path
|
||||
// curates it (Anthropic refusal details), so other reasons keep their
|
||||
// maintained notice even if a provider fills the field (#9005).
|
||||
const message =
|
||||
(finishReason === FinishReason.SAFETY
|
||||
? event.value.finishMessage
|
||||
: undefined) ?? finishReasonMessages[finishReason];
|
||||
if (message) {
|
||||
addItem(
|
||||
{
|
||||
|
||||
@@ -278,6 +278,42 @@ describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => {
|
||||
text: '⚠ Response stopped due to image safety violations.',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the provider refusal message', () => {
|
||||
const map = createEventMapper();
|
||||
const out = map({
|
||||
type: 'finished',
|
||||
value: {
|
||||
reason: 'SAFETY',
|
||||
finishMessage: 'Response refused due to cyber-related safety policy.',
|
||||
},
|
||||
} as unknown as AnyEv);
|
||||
expect(out).toContainEqual({
|
||||
type: 'info',
|
||||
text: '⚠ Response refused due to cyber-related safety policy.',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the curated notice when a non-safety reason carries provider text', () => {
|
||||
const map = createEventMapper();
|
||||
const out = map({
|
||||
type: 'finished',
|
||||
value: {
|
||||
reason: 'MAX_TOKENS',
|
||||
finishMessage: 'Provider-authored stop detail.',
|
||||
},
|
||||
} as unknown as AnyEv);
|
||||
expect(out).toContainEqual({
|
||||
type: 'info',
|
||||
text: '⚠ Response truncated due to token limits.',
|
||||
});
|
||||
expect(
|
||||
out.some(
|
||||
(item) =>
|
||||
item.type === 'info' && item.text.includes('Provider-authored'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error events', () => {
|
||||
|
||||
@@ -953,8 +953,16 @@ export function createEventMapper(
|
||||
out.push({ type: 'retry-countdown-clear' });
|
||||
// ink parity: handleFinishedEvent adds `{type: 'info'}` for
|
||||
// non-STOP finish reasons.
|
||||
const reason = (ev.value as { reason?: string } | undefined)?.reason;
|
||||
const message = reason ? FINISH_REASON_NOTICES[reason] : undefined;
|
||||
const value = ev.value as
|
||||
| { reason?: string; finishMessage?: string }
|
||||
| undefined;
|
||||
const reason = value?.reason;
|
||||
// `finishMessage` is provider-authored free text; only the safety path
|
||||
// curates it (Anthropic refusal details), so other reasons keep their
|
||||
// maintained notice even if a provider fills the field (#9005).
|
||||
const message =
|
||||
(reason === 'SAFETY' ? value?.finishMessage : undefined) ??
|
||||
(reason ? FINISH_REASON_NOTICES[reason] : undefined);
|
||||
if (message) out.push({ type: 'info', text: `⚠ ${message}` });
|
||||
// Segment marker only — the turn settles when the live generator
|
||||
// returns (backend emits `done`), NOT here: `finished` arrives
|
||||
|
||||
@@ -191,9 +191,10 @@ const messageDelta = (
|
||||
stop_reason: string,
|
||||
output_tokens: number,
|
||||
usage: object = {},
|
||||
stop_details?: object,
|
||||
): SseEvent => ({
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason },
|
||||
delta: { stop_reason, ...(stop_details ? { stop_details } : {}) },
|
||||
usage: { output_tokens, ...usage },
|
||||
});
|
||||
// Yields `events`, then fails with `error`.
|
||||
@@ -2452,6 +2453,27 @@ describe('AnthropicContentGenerator', () => {
|
||||
expect(lastFinish(chunks)).toBe(FinishReason.STOP);
|
||||
});
|
||||
|
||||
it('surfaces a refusal stop-details category', async () => {
|
||||
const { chunks, error } = await collectEvents(
|
||||
messageDelta(
|
||||
'refusal',
|
||||
1,
|
||||
{},
|
||||
{
|
||||
type: 'refusal',
|
||||
category: 'cyber',
|
||||
explanation: 'Provider-authored explanation',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(lastFinish(chunks)).toBe(FinishReason.SAFETY);
|
||||
expect(
|
||||
chunks.flatMap((chunk) => chunk.candidates ?? []).at(-1)?.finishMessage,
|
||||
).toBe('Response refused due to cyber-related safety policy.');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'rejects an open tool-use block after assistant payload at end of stream',
|
||||
|
||||
@@ -1414,6 +1414,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
let completionTokens = 0;
|
||||
let completionTokensReported = false;
|
||||
let finishReason: string | undefined;
|
||||
let finishMessage: string | undefined;
|
||||
|
||||
const blocks = new Map<number, StreamingBlockState>();
|
||||
const deferredToolCalls: GenerateContentResponse[] = [];
|
||||
@@ -1620,6 +1621,10 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
}
|
||||
case 'message_delta': {
|
||||
const stopReasonValue = event.delta.stop_reason;
|
||||
finishMessage ??=
|
||||
this.converter.mapAnthropicStopDetailsToFinishMessage(
|
||||
(event.delta as { stop_details?: unknown }).stop_details,
|
||||
);
|
||||
if (stopReasonValue) {
|
||||
finishReason = stopReasonValue;
|
||||
const hasOpenToolCall = [...blocks.values()].some(
|
||||
@@ -1719,6 +1724,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
cacheReadTokensReported: cachedTokensReported,
|
||||
cacheCreationTokensReported,
|
||||
}),
|
||||
finishMessage,
|
||||
);
|
||||
collectedResponses.push(chunk);
|
||||
yield chunk;
|
||||
@@ -1748,6 +1754,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
cacheReadTokensReported: cachedTokensReported,
|
||||
cacheCreationTokensReported,
|
||||
}),
|
||||
finishMessage,
|
||||
);
|
||||
collectedResponses.push(chunk);
|
||||
yield chunk;
|
||||
@@ -1895,6 +1902,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
model?: string,
|
||||
finishReason?: string,
|
||||
usageMetadata?: GenerateContentResponseUsageMetadata,
|
||||
finishMessage?: string,
|
||||
): GenerateContentResponse {
|
||||
const response = new GenerateContentResponse();
|
||||
response.responseId = responseId;
|
||||
@@ -1916,6 +1924,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
index: 0,
|
||||
safetyRatings: [],
|
||||
...(mappedFinishReason ? { finishReason: mappedFinishReason } : {}),
|
||||
...(finishMessage ? { finishMessage } : {}),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1740,6 +1740,58 @@ describe('AnthropicContentConverter', () => {
|
||||
expect(parts).toEqual([fnCall('tool', { x: 1 }, 't1')]);
|
||||
});
|
||||
|
||||
it('surfaces a refusal stop-details category', () => {
|
||||
const response = respond({
|
||||
stop_reason: 'refusal',
|
||||
stop_details: {
|
||||
type: 'refusal',
|
||||
category: 'reasoning_extraction',
|
||||
explanation: 'Provider-authored explanation',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.candidates?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
finishReason: FinishReason.SAFETY,
|
||||
finishMessage:
|
||||
'Response refused because it requested hidden reasoning.',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['cyber', 'Response refused due to cyber-related safety policy.'],
|
||||
['bio', 'Response refused due to biological safety policy.'],
|
||||
[
|
||||
'frontier_llm',
|
||||
'Response refused due to frontier model development policy.',
|
||||
],
|
||||
['general_harms', 'Response refused due to a general safety policy.'],
|
||||
// Provider text can be any string, including prototype keys: those must
|
||||
// land on the generic notice instead of leaking `[object Object]`.
|
||||
['__proto__', 'Response refused due to safety reasons.'],
|
||||
['unmapped_category', 'Response refused due to safety reasons.'],
|
||||
])(
|
||||
'maps the %s refusal category to its curated notice',
|
||||
(category, expected) => {
|
||||
const response = respond({
|
||||
stop_reason: 'refusal',
|
||||
stop_details: { type: 'refusal', category },
|
||||
});
|
||||
|
||||
expect(response.candidates?.[0]?.finishMessage).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('ignores stop_details that is not a refusal', () => {
|
||||
const response = respond({
|
||||
stop_reason: 'end_turn',
|
||||
stop_details: { type: 'something_else', category: 'cyber' },
|
||||
});
|
||||
|
||||
expect(response.candidates?.[0]?.finishMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards cache_read_input_tokens and cache_creation_input_tokens through to usageMetadata', () => {
|
||||
// A real mid-conversation Anthropic response carries all three prompt
|
||||
// buckets at once: `input_tokens` (non-cached tail),
|
||||
|
||||
@@ -571,6 +571,12 @@ export class AnthropicContentConverter {
|
||||
if (finishReason) {
|
||||
candidate.finishReason = finishReason;
|
||||
}
|
||||
const finishMessage = this.mapAnthropicStopDetailsToFinishMessage(
|
||||
(response as Anthropic.Message & { stop_details?: unknown }).stop_details,
|
||||
);
|
||||
if (finishMessage) {
|
||||
candidate.finishMessage = finishMessage;
|
||||
}
|
||||
|
||||
llmResponse.candidates = [candidate];
|
||||
llmResponse.responseId = response.id;
|
||||
@@ -1017,6 +1023,34 @@ export class AnthropicContentConverter {
|
||||
return mapping[reason] || FinishReason.FINISH_REASON_UNSPECIFIED;
|
||||
}
|
||||
|
||||
mapAnthropicStopDetailsToFinishMessage(
|
||||
stopDetails?: unknown,
|
||||
): string | undefined {
|
||||
if (!stopDetails || typeof stopDetails !== 'object') return undefined;
|
||||
const details = stopDetails as Record<string, unknown>;
|
||||
if (details['type'] !== 'refusal') return undefined;
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
cyber: 'Response refused due to cyber-related safety policy.',
|
||||
bio: 'Response refused due to biological safety policy.',
|
||||
frontier_llm:
|
||||
'Response refused due to frontier model development policy.',
|
||||
reasoning_extraction:
|
||||
'Response refused because it requested hidden reasoning.',
|
||||
general_harms: 'Response refused due to a general safety policy.',
|
||||
};
|
||||
const category = details['category'];
|
||||
if (typeof category !== 'string') {
|
||||
return 'Response refused due to safety reasons.';
|
||||
}
|
||||
// `category` is provider text: an own-property check keeps `__proto__` /
|
||||
// `constructor` on the unknown-category fallback instead of returning an
|
||||
// object that would render as `[object Object]`.
|
||||
return Object.prototype.hasOwnProperty.call(messages, category)
|
||||
? messages[category]
|
||||
: 'Response refused due to safety reasons.';
|
||||
}
|
||||
|
||||
private isContentObject(
|
||||
content: unknown,
|
||||
): content is { role: string; parts: Part[] } {
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
} from '@google/genai';
|
||||
import { ApiError } from '@google/genai';
|
||||
import { AuthType, type ContentGenerator } from './contentGenerator.js';
|
||||
import { FinishReason } from './genai-compat.js';
|
||||
import { getPlanModeSystemReminder } from './prompts.js';
|
||||
import {
|
||||
LlmChat,
|
||||
@@ -9697,6 +9698,52 @@ describe('LlmChat', async () => {
|
||||
expect(historyText).toBe(recordedText(record));
|
||||
});
|
||||
|
||||
it('preserves finishMessage on the deferred tool-result finish chunk', async () => {
|
||||
vi.useFakeTimers();
|
||||
chat.setHistory([
|
||||
userText('read the file'),
|
||||
content(
|
||||
'model',
|
||||
fnCall('read_file', { path: '/tmp/x' }, 'call_refusal'),
|
||||
),
|
||||
]);
|
||||
mockStreamsOnce(
|
||||
streamOf(textChunk('Request refused.'), {
|
||||
candidates: [
|
||||
{
|
||||
finishReason: FinishReason.SAFETY,
|
||||
finishMessage:
|
||||
'Response refused due to cyber-related safety policy.',
|
||||
},
|
||||
],
|
||||
} as GenerateContentResponse),
|
||||
);
|
||||
|
||||
const events = await collectStreamWithFakeTimers(
|
||||
await sendToolResult(
|
||||
'call_refusal',
|
||||
'prompt-deferred-finish-message',
|
||||
),
|
||||
10_000,
|
||||
);
|
||||
const finish = events
|
||||
.filter((event) => event.type === StreamEventType.CHUNK)
|
||||
.map(
|
||||
(event) =>
|
||||
(event as { value: GenerateContentResponse }).value
|
||||
.candidates?.[0],
|
||||
)
|
||||
.find((candidate) => candidate?.finishReason);
|
||||
|
||||
expect(finish).toEqual(
|
||||
expect.objectContaining({
|
||||
finishReason: FinishReason.SAFETY,
|
||||
finishMessage:
|
||||
'Response refused due to cyber-related safety policy.',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 'write a game': attempt 1 is cut after 'Here is the game: ', attempt 2
|
||||
* yields only `close` and dies from `error`; `rest` (if given) answers.
|
||||
|
||||
@@ -7011,6 +7011,7 @@ export class LlmChat {
|
||||
currentUserTurn?.role === 'user' &&
|
||||
currentUserTurn.parts?.some((part) => part.functionResponse) === true;
|
||||
let deferredFinishReason: FinishReason | undefined;
|
||||
let deferredFinishMessage: string | undefined;
|
||||
// Captured if the upstream stream throws mid-iteration (typical on weak
|
||||
// networks: SSE drops between `content_block_stop` of a tool_use and the
|
||||
// terminal `message_stop`). We still build / record / push a partial
|
||||
@@ -7230,7 +7231,11 @@ export class LlmChat {
|
||||
for (const candidate of chunk.candidates ?? []) {
|
||||
if (candidate.finishReason) {
|
||||
deferredFinishReason ??= candidate.finishReason;
|
||||
deferredFinishMessage ??= candidate.finishMessage;
|
||||
delete candidate.finishReason;
|
||||
// The message is replayed with the deferred reason at the end of
|
||||
// the stream; keeping it here would ship a notice with no reason.
|
||||
delete candidate.finishMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7867,7 +7872,14 @@ export class LlmChat {
|
||||
abortSignal?.throwIfAborted();
|
||||
if (deferredFinishReason) {
|
||||
yield {
|
||||
candidates: [{ finishReason: deferredFinishReason }],
|
||||
candidates: [
|
||||
{
|
||||
finishReason: deferredFinishReason,
|
||||
...(deferredFinishMessage
|
||||
? { finishMessage: deferredFinishMessage }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
usageMetadata,
|
||||
} as GenerateContentResponse;
|
||||
}
|
||||
|
||||
@@ -926,6 +926,28 @@ describe('Turn', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include a provider finish message', async () => {
|
||||
const events = await run([
|
||||
textChunk('Content blocked', {
|
||||
finishReason: 'SAFETY',
|
||||
finishMessage: 'Response refused due to cyber-related safety policy.',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(events).toEqual([
|
||||
contentEvent('Content blocked'),
|
||||
{
|
||||
type: LlmEventType.Finished,
|
||||
value: {
|
||||
reason: 'SAFETY',
|
||||
usageMetadata: undefined,
|
||||
finishMessage:
|
||||
'Response refused due to cyber-related safety policy.',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should yield finished event with undefined reason when there is no finish reason', async () => {
|
||||
const events = await run([textChunk('Response without finish reason')]);
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ export interface SessionTokenLimitExceededValue {
|
||||
export interface LlmFinishedEventValue {
|
||||
reason: FinishReason | undefined;
|
||||
usageMetadata: GenerateContentResponseUsageMetadata | undefined;
|
||||
finishMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -925,6 +926,7 @@ export class Turn {
|
||||
|
||||
// Check if response was truncated or stopped for various reasons
|
||||
const finishReason = resp.candidates?.[0]?.finishReason;
|
||||
const finishMessage = resp.candidates?.[0]?.finishMessage;
|
||||
|
||||
// This is the key change: Only yield 'Finished' if there is a finishReason.
|
||||
if (finishReason) {
|
||||
@@ -950,6 +952,7 @@ export class Turn {
|
||||
value: {
|
||||
reason: finishReason,
|
||||
usageMetadata: resp.usageMetadata,
|
||||
...(finishMessage ? { finishMessage } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user