AIX: Transmit token stop errors, if provided

This commit is contained in:
Enrico Ros
2026-04-24 17:08:29 -07:00
parent 194bfe23a1
commit 9fc0b39730
6 changed files with 25 additions and 29 deletions
+4 -1
View File
@@ -905,9 +905,12 @@ export class ContentReassembler {
/**
* Stores raw termination data from the wire - classification deferred to finalizeReassembly()
*/
private onCGEnd({ terminationReason, tokenStopReason }: Extract<AixWire_Particles.ChatGenerateOp, { cg: 'end' }>): void {
private onCGEnd({ terminationReason, tokenStopReason, tokenStopError }: Extract<AixWire_Particles.ChatGenerateOp, { cg: 'end' }>): 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);
}
/**
+1 -1
View File
@@ -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 }
@@ -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;
}
@@ -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 //
@@ -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;
}
@@ -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(),
});