From 9fc0b39730f442152cc92d4ada558188dc77dfa8 Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Fri, 24 Apr 2026 17:08:29 -0700 Subject: [PATCH] AIX: Transmit token stop errors, if provided --- src/modules/aix/client/ContentReassembler.ts | 5 +++- src/modules/aix/server/api/aix.wiretypes.ts | 2 +- .../chatGenerate/ChatGenerateTransmitter.ts | 7 +++-- .../parsers/IParticleTransmitter.ts | 4 +-- .../chatGenerate/parsers/anthropic.parser.ts | 29 +++++++------------ .../dispatch/wiretypes/anthropic.wiretypes.ts | 7 ++--- 6 files changed, 25 insertions(+), 29 deletions(-) diff --git a/src/modules/aix/client/ContentReassembler.ts b/src/modules/aix/client/ContentReassembler.ts index 1919e4b9f..b2a62556e 100644 --- a/src/modules/aix/client/ContentReassembler.ts +++ b/src/modules/aix/client/ContentReassembler.ts @@ -905,9 +905,12 @@ export class ContentReassembler { /** * Stores raw termination data from the wire - classification deferred to finalizeReassembly() */ - private onCGEnd({ terminationReason, tokenStopReason }: Extract): void { + private onCGEnd({ terminationReason, tokenStopReason, tokenStopError }: Extract): void { this.S.terminationReason = terminationReason; this.S.dialectStopReason = tokenStopReason; + // Vendor-composed stop error, surfaced as a complementary error fragment alongside the generic classification message + if (tokenStopError) + this._appendErrorFragment(tokenStopError); } /** diff --git a/src/modules/aix/server/api/aix.wiretypes.ts b/src/modules/aix/server/api/aix.wiretypes.ts index 65fe5e2b1..8ce35af14 100644 --- a/src/modules/aix/server/api/aix.wiretypes.ts +++ b/src/modules/aix/server/api/aix.wiretypes.ts @@ -689,7 +689,7 @@ export namespace AixWire_Particles { export type ChatControlOp = // | { cg: 'start' } // not really used for now - | { cg: 'end', terminationReason: CGEndReason /* we know why we're sending 'end' */, tokenStopReason?: GCTokenStopReason /* we may or not have gotten a logical token stop reason from the dispatch */ } + | { cg: 'end', terminationReason: CGEndReason /* we know why we're sending 'end' */, tokenStopReason?: GCTokenStopReason /* we may or not have gotten a logical token stop reason from the dispatch */, tokenStopError?: string /* optional vendor-composed human-readable detail paired with tokenStopReason */ } | { cg: 'issue', issueId: CGIssueId, issueText: string } | { cg: 'aix-info', ait: 'flow-cont' /* important: establishes a checkpoint */, text: string } | { cg: 'aix-retry-reset', rScope: 'srv-dispatch' | 'srv-op' | 'cli-ll', rClearStrategy: 'none' | 'since-checkpoint' | 'all', reason: string, attempt: number, maxAttempts: number, delayMs: number, causeHttp?: number, causeConn?: string } diff --git a/src/modules/aix/server/dispatch/chatGenerate/ChatGenerateTransmitter.ts b/src/modules/aix/server/dispatch/chatGenerate/ChatGenerateTransmitter.ts index 6dfa21ade..c22088ed6 100644 --- a/src/modules/aix/server/dispatch/chatGenerate/ChatGenerateTransmitter.ts +++ b/src/modules/aix/server/dispatch/chatGenerate/ChatGenerateTransmitter.ts @@ -56,6 +56,7 @@ export class ChatGenerateTransmitter implements IParticleTransmitter { // Token stop reason private tokenStopReason: AixWire_Particles.GCTokenStopReason | undefined = undefined; + private tokenStopError: string | undefined = undefined; // Metrics private accMetrics: AixWire_Particles.CGSelectMetrics | undefined = undefined; @@ -105,6 +106,7 @@ export class ChatGenerateTransmitter implements IParticleTransmitter { cg: 'end', terminationReason: this.terminationReason, tokenStopReason: this.tokenStopReason, // See NOTE above - || (dispatchOrDialectIssue ? 'cg-issue' : 'ok'), + ...(this.tokenStopError && { tokenStopError: this.tokenStopError }), }); // Keep this in a terminated state, so that every subsequent call will yield errors (not implemented) // this.terminationReason = null; @@ -201,12 +203,13 @@ export class ChatGenerateTransmitter implements IParticleTransmitter { this.setDialectEnded('issue-dialect'); } - setTokenStopReason(reason: AixWire_Particles.GCTokenStopReason) { + setTokenStopReason(reason: AixWire_Particles.GCTokenStopReason, errorText?: string) { if (SERVER_DEBUG_WIRE) - console.log('|token-stop|', reason); + console.log('|token-stop|', reason, errorText ?? ''); if (this.tokenStopReason && this.tokenStopReason !== reason) console.warn(`[Aix.${this.prettyDialect}] setTokenStopReason('${reason}'): already has token stop reason '${this.tokenStopReason}' (overriding)`); this.tokenStopReason = reason; + if (errorText) this.tokenStopError = errorText; } diff --git a/src/modules/aix/server/dispatch/chatGenerate/parsers/IParticleTransmitter.ts b/src/modules/aix/server/dispatch/chatGenerate/parsers/IParticleTransmitter.ts index 2a38fe9c4..f3fe21cd6 100644 --- a/src/modules/aix/server/dispatch/chatGenerate/parsers/IParticleTransmitter.ts +++ b/src/modules/aix/server/dispatch/chatGenerate/parsers/IParticleTransmitter.ts @@ -15,8 +15,8 @@ export interface IParticleTransmitter { /** End the current part and flush it, which also calls `setDialectEnded('issue-dialect')` */ setDialectTerminatingIssue(dialectText: string, symbol: string | null, serverLog: ParticleServerLogLevel): void; - /** Communicates the finish reason to the client - Data only, this does not do Control, like the above */ - setTokenStopReason(reason: AixWire_Particles.GCTokenStopReason): void; + /** Communicates the finish reason to the client - Data only. Optional `errorText` is a vendor-composed string rendered as a complementary error fragment alongside the generic classification message. */ + setTokenStopReason(reason: AixWire_Particles.GCTokenStopReason, errorText?: string): void; // Parts data // diff --git a/src/modules/aix/server/dispatch/chatGenerate/parsers/anthropic.parser.ts b/src/modules/aix/server/dispatch/chatGenerate/parsers/anthropic.parser.ts index e06d3d55e..1881a121c 100644 --- a/src/modules/aix/server/dispatch/chatGenerate/parsers/anthropic.parser.ts +++ b/src/modules/aix/server/dispatch/chatGenerate/parsers/anthropic.parser.ts @@ -401,14 +401,10 @@ export function createAnthropicMessageParser(): ChatGenerateParseFunction { if (delta.container) _emitContainerState(pt, delta.container); - // -> Refusal details (structured) - surface category/explanation when stop_reason === 'refusal' - if (delta.stop_reason === 'refusal' && delta.stop_details) - _emitRefusalDetails(pt, delta.stop_details); - // -> Token Stop Reason const tokenStopReason = _fromAnthropicStopReason(delta.stop_reason, 'message_delta'); if (tokenStopReason !== null) - pt.setTokenStopReason(tokenStopReason); + pt.setTokenStopReason(tokenStopReason, _formatAnthropicStopError(delta.stop_details)); // NOTE: we have more fields we're not parsing yet - https://platform.claude.com/docs/en/api/typescript/messages#message_delta_usage if (usage?.output_tokens && messageStartTime) { @@ -655,14 +651,10 @@ export function createAnthropicMessageParserNS(): ChatGenerateParseFunction { _createAnthropicPauseTurnContinuation(content, container?.id), ); - // -> Refusal details (structured) - surface category/explanation when stop_reason === 'refusal' - if (stop_reason === 'refusal' && stop_details) - _emitRefusalDetails(pt, stop_details); - // -> Token Stop Reason (pause_turn already thrown above) const tokenStopReason = _fromAnthropicStopReason(stop_reason, 'parser_NS'); if (tokenStopReason !== null) - pt.setTokenStopReason(tokenStopReason); + pt.setTokenStopReason(tokenStopReason, _formatAnthropicStopError(stop_details)); }; } @@ -690,18 +682,17 @@ function _emitContainerState(pt: IParticleTransmitter, container: { id: string; }); } -/** - * Surface structured refusal details (stop_reason === 'refusal') as inline text. - * Anthropic's streaming classifiers can intervene mid-generation; appending the category + explanation - * as text lets the user see WHY the model refused without touching terminationReason (which message_stop - * will set to 'done-dialect') - avoids a spurious override warning. - */ -function _emitRefusalDetails(pt: IParticleTransmitter, stopDetails: { type: 'refusal'; category?: 'cyber' | 'bio' | null; explanation?: string | null }): void { +/** Compose a human-readable error string from Anthropic's stop_details. Returns undefined when nothing useful to surface. */ +function _formatAnthropicStopError(stopDetails: { type: string; category?: string | null; explanation?: string | null } | null | undefined): string | undefined { + if (!stopDetails) return undefined; + if (stopDetails.type !== 'refusal') { + aixResilientUnknownValue('Anthropic', 'stopDetailsType', stopDetails.type); + return undefined; + } const parts: string[] = []; if (stopDetails.category) parts.push(`[${stopDetails.category}]`); if (stopDetails.explanation) parts.push(stopDetails.explanation); - if (!parts.length) return; - pt.appendText(`\n\n${IssueSymbols.PromptBlocked} **Refusal:** ${parts.join(' ')}`); + return parts.length ? `Refusal: ${parts.join(' ')}` : undefined; } diff --git a/src/modules/aix/server/dispatch/wiretypes/anthropic.wiretypes.ts b/src/modules/aix/server/dispatch/wiretypes/anthropic.wiretypes.ts index 1f363a5aa..8bfeb7e4c 100644 --- a/src/modules/aix/server/dispatch/wiretypes/anthropic.wiretypes.ts +++ b/src/modules/aix/server/dispatch/wiretypes/anthropic.wiretypes.ts @@ -831,12 +831,11 @@ export namespace AnthropicWire_API_Message_Create { /** * Structured stop details, paired with stop_reason. Currently only populated when stop_reason === 'refusal'. - * - category: 'cyber' | 'bio' when the refusal maps to a named policy category, null otherwise - * - explanation: human-readable explanation (NOT guaranteed stable), null when unavailable + * Both `type` and `category` are loosely typed for forward-compat - parser warns on unknown `type`. */ const StopDetails_schema = z.object({ - type: z.literal('refusal'), - category: z.enum(['cyber', 'bio']).nullish(), + type: z.enum(['refusal']).or(z.string()), + category: z.enum(['cyber', 'bio']).or(z.string()).nullish(), explanation: z.string().nullish(), });