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);
|
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 () => {
|
it('should not add message for FINISH_REASON_UNSPECIFIED', async () => {
|
||||||
// Setup mock to return a stream with FINISH_REASON_UNSPECIFIED
|
// Setup mock to return a stream with FINISH_REASON_UNSPECIFIED
|
||||||
mockSendMessageStream.mockReturnValue(
|
mockSendMessageStream.mockReturnValue(
|
||||||
|
|||||||
@@ -3002,7 +3002,13 @@ export const useLlmStream = (
|
|||||||
'Response stopped due to unexpected tool call.',
|
'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) {
|
if (message) {
|
||||||
addItem(
|
addItem(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -278,6 +278,42 @@ describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => {
|
|||||||
text: '⚠ Response stopped due to image safety violations.',
|
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', () => {
|
describe('error events', () => {
|
||||||
|
|||||||
@@ -953,8 +953,16 @@ export function createEventMapper(
|
|||||||
out.push({ type: 'retry-countdown-clear' });
|
out.push({ type: 'retry-countdown-clear' });
|
||||||
// ink parity: handleFinishedEvent adds `{type: 'info'}` for
|
// ink parity: handleFinishedEvent adds `{type: 'info'}` for
|
||||||
// non-STOP finish reasons.
|
// non-STOP finish reasons.
|
||||||
const reason = (ev.value as { reason?: string } | undefined)?.reason;
|
const value = ev.value as
|
||||||
const message = reason ? FINISH_REASON_NOTICES[reason] : undefined;
|
| { 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}` });
|
if (message) out.push({ type: 'info', text: `⚠ ${message}` });
|
||||||
// Segment marker only — the turn settles when the live generator
|
// Segment marker only — the turn settles when the live generator
|
||||||
// returns (backend emits `done`), NOT here: `finished` arrives
|
// returns (backend emits `done`), NOT here: `finished` arrives
|
||||||
|
|||||||
@@ -191,9 +191,10 @@ const messageDelta = (
|
|||||||
stop_reason: string,
|
stop_reason: string,
|
||||||
output_tokens: number,
|
output_tokens: number,
|
||||||
usage: object = {},
|
usage: object = {},
|
||||||
|
stop_details?: object,
|
||||||
): SseEvent => ({
|
): SseEvent => ({
|
||||||
type: 'message_delta',
|
type: 'message_delta',
|
||||||
delta: { stop_reason },
|
delta: { stop_reason, ...(stop_details ? { stop_details } : {}) },
|
||||||
usage: { output_tokens, ...usage },
|
usage: { output_tokens, ...usage },
|
||||||
});
|
});
|
||||||
// Yields `events`, then fails with `error`.
|
// Yields `events`, then fails with `error`.
|
||||||
@@ -2452,6 +2453,27 @@ describe('AnthropicContentGenerator', () => {
|
|||||||
expect(lastFinish(chunks)).toBe(FinishReason.STOP);
|
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([
|
it.each([
|
||||||
[
|
[
|
||||||
'rejects an open tool-use block after assistant payload at end of stream',
|
'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 completionTokens = 0;
|
||||||
let completionTokensReported = false;
|
let completionTokensReported = false;
|
||||||
let finishReason: string | undefined;
|
let finishReason: string | undefined;
|
||||||
|
let finishMessage: string | undefined;
|
||||||
|
|
||||||
const blocks = new Map<number, StreamingBlockState>();
|
const blocks = new Map<number, StreamingBlockState>();
|
||||||
const deferredToolCalls: GenerateContentResponse[] = [];
|
const deferredToolCalls: GenerateContentResponse[] = [];
|
||||||
@@ -1620,6 +1621,10 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||||||
}
|
}
|
||||||
case 'message_delta': {
|
case 'message_delta': {
|
||||||
const stopReasonValue = event.delta.stop_reason;
|
const stopReasonValue = event.delta.stop_reason;
|
||||||
|
finishMessage ??=
|
||||||
|
this.converter.mapAnthropicStopDetailsToFinishMessage(
|
||||||
|
(event.delta as { stop_details?: unknown }).stop_details,
|
||||||
|
);
|
||||||
if (stopReasonValue) {
|
if (stopReasonValue) {
|
||||||
finishReason = stopReasonValue;
|
finishReason = stopReasonValue;
|
||||||
const hasOpenToolCall = [...blocks.values()].some(
|
const hasOpenToolCall = [...blocks.values()].some(
|
||||||
@@ -1719,6 +1724,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||||||
cacheReadTokensReported: cachedTokensReported,
|
cacheReadTokensReported: cachedTokensReported,
|
||||||
cacheCreationTokensReported,
|
cacheCreationTokensReported,
|
||||||
}),
|
}),
|
||||||
|
finishMessage,
|
||||||
);
|
);
|
||||||
collectedResponses.push(chunk);
|
collectedResponses.push(chunk);
|
||||||
yield chunk;
|
yield chunk;
|
||||||
@@ -1748,6 +1754,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||||||
cacheReadTokensReported: cachedTokensReported,
|
cacheReadTokensReported: cachedTokensReported,
|
||||||
cacheCreationTokensReported,
|
cacheCreationTokensReported,
|
||||||
}),
|
}),
|
||||||
|
finishMessage,
|
||||||
);
|
);
|
||||||
collectedResponses.push(chunk);
|
collectedResponses.push(chunk);
|
||||||
yield chunk;
|
yield chunk;
|
||||||
@@ -1895,6 +1902,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||||||
model?: string,
|
model?: string,
|
||||||
finishReason?: string,
|
finishReason?: string,
|
||||||
usageMetadata?: GenerateContentResponseUsageMetadata,
|
usageMetadata?: GenerateContentResponseUsageMetadata,
|
||||||
|
finishMessage?: string,
|
||||||
): GenerateContentResponse {
|
): GenerateContentResponse {
|
||||||
const response = new GenerateContentResponse();
|
const response = new GenerateContentResponse();
|
||||||
response.responseId = responseId;
|
response.responseId = responseId;
|
||||||
@@ -1916,6 +1924,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
|||||||
index: 0,
|
index: 0,
|
||||||
safetyRatings: [],
|
safetyRatings: [],
|
||||||
...(mappedFinishReason ? { finishReason: mappedFinishReason } : {}),
|
...(mappedFinishReason ? { finishReason: mappedFinishReason } : {}),
|
||||||
|
...(finishMessage ? { finishMessage } : {}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1740,6 +1740,58 @@ describe('AnthropicContentConverter', () => {
|
|||||||
expect(parts).toEqual([fnCall('tool', { x: 1 }, 't1')]);
|
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', () => {
|
it('forwards cache_read_input_tokens and cache_creation_input_tokens through to usageMetadata', () => {
|
||||||
// A real mid-conversation Anthropic response carries all three prompt
|
// A real mid-conversation Anthropic response carries all three prompt
|
||||||
// buckets at once: `input_tokens` (non-cached tail),
|
// buckets at once: `input_tokens` (non-cached tail),
|
||||||
|
|||||||
@@ -571,6 +571,12 @@ export class AnthropicContentConverter {
|
|||||||
if (finishReason) {
|
if (finishReason) {
|
||||||
candidate.finishReason = 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.candidates = [candidate];
|
||||||
llmResponse.responseId = response.id;
|
llmResponse.responseId = response.id;
|
||||||
@@ -1017,6 +1023,34 @@ export class AnthropicContentConverter {
|
|||||||
return mapping[reason] || FinishReason.FINISH_REASON_UNSPECIFIED;
|
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(
|
private isContentObject(
|
||||||
content: unknown,
|
content: unknown,
|
||||||
): content is { role: string; parts: Part[] } {
|
): content is { role: string; parts: Part[] } {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
} from '@google/genai';
|
} from '@google/genai';
|
||||||
import { ApiError } from '@google/genai';
|
import { ApiError } from '@google/genai';
|
||||||
import { AuthType, type ContentGenerator } from './contentGenerator.js';
|
import { AuthType, type ContentGenerator } from './contentGenerator.js';
|
||||||
|
import { FinishReason } from './genai-compat.js';
|
||||||
import { getPlanModeSystemReminder } from './prompts.js';
|
import { getPlanModeSystemReminder } from './prompts.js';
|
||||||
import {
|
import {
|
||||||
LlmChat,
|
LlmChat,
|
||||||
@@ -9697,6 +9698,52 @@ describe('LlmChat', async () => {
|
|||||||
expect(historyText).toBe(recordedText(record));
|
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
|
* '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.
|
* yields only `close` and dies from `error`; `rest` (if given) answers.
|
||||||
|
|||||||
@@ -7011,6 +7011,7 @@ export class LlmChat {
|
|||||||
currentUserTurn?.role === 'user' &&
|
currentUserTurn?.role === 'user' &&
|
||||||
currentUserTurn.parts?.some((part) => part.functionResponse) === true;
|
currentUserTurn.parts?.some((part) => part.functionResponse) === true;
|
||||||
let deferredFinishReason: FinishReason | undefined;
|
let deferredFinishReason: FinishReason | undefined;
|
||||||
|
let deferredFinishMessage: string | undefined;
|
||||||
// Captured if the upstream stream throws mid-iteration (typical on weak
|
// Captured if the upstream stream throws mid-iteration (typical on weak
|
||||||
// networks: SSE drops between `content_block_stop` of a tool_use and the
|
// networks: SSE drops between `content_block_stop` of a tool_use and the
|
||||||
// terminal `message_stop`). We still build / record / push a partial
|
// terminal `message_stop`). We still build / record / push a partial
|
||||||
@@ -7230,7 +7231,11 @@ export class LlmChat {
|
|||||||
for (const candidate of chunk.candidates ?? []) {
|
for (const candidate of chunk.candidates ?? []) {
|
||||||
if (candidate.finishReason) {
|
if (candidate.finishReason) {
|
||||||
deferredFinishReason ??= candidate.finishReason;
|
deferredFinishReason ??= candidate.finishReason;
|
||||||
|
deferredFinishMessage ??= candidate.finishMessage;
|
||||||
delete candidate.finishReason;
|
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();
|
abortSignal?.throwIfAborted();
|
||||||
if (deferredFinishReason) {
|
if (deferredFinishReason) {
|
||||||
yield {
|
yield {
|
||||||
candidates: [{ finishReason: deferredFinishReason }],
|
candidates: [
|
||||||
|
{
|
||||||
|
finishReason: deferredFinishReason,
|
||||||
|
...(deferredFinishMessage
|
||||||
|
? { finishMessage: deferredFinishMessage }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
usageMetadata,
|
usageMetadata,
|
||||||
} as GenerateContentResponse;
|
} 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 () => {
|
it('should yield finished event with undefined reason when there is no finish reason', async () => {
|
||||||
const events = await run([textChunk('Response without finish reason')]);
|
const events = await run([textChunk('Response without finish reason')]);
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export interface SessionTokenLimitExceededValue {
|
|||||||
export interface LlmFinishedEventValue {
|
export interface LlmFinishedEventValue {
|
||||||
reason: FinishReason | undefined;
|
reason: FinishReason | undefined;
|
||||||
usageMetadata: GenerateContentResponseUsageMetadata | undefined;
|
usageMetadata: GenerateContentResponseUsageMetadata | undefined;
|
||||||
|
finishMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -925,6 +926,7 @@ export class Turn {
|
|||||||
|
|
||||||
// Check if response was truncated or stopped for various reasons
|
// Check if response was truncated or stopped for various reasons
|
||||||
const finishReason = resp.candidates?.[0]?.finishReason;
|
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.
|
// This is the key change: Only yield 'Finished' if there is a finishReason.
|
||||||
if (finishReason) {
|
if (finishReason) {
|
||||||
@@ -950,6 +952,7 @@ export class Turn {
|
|||||||
value: {
|
value: {
|
||||||
reason: finishReason,
|
reason: finishReason,
|
||||||
usageMetadata: resp.usageMetadata,
|
usageMetadata: resp.usageMetadata,
|
||||||
|
...(finishMessage ? { finishMessage } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user