fix(cli): forward Claude API error records with null service_tier (#1857) (#2087)

Claude Code writes failed API calls ("Prompt is too long", usage limits,
logged out) as synthetic assistant records whose usage carries
`service_tier: null`. The schema only accepted undefined, so the local
session scanner dropped every one and the web session just looked stalled.

- Accept null for the usage fields the Messages API types as nullable.
- Keep the top-level `error`/`isApiErrorMessage` on assistant records in
  local mode, and `error` in the SDK converter for remote mode.
- Render assistant records carrying `error` as the existing error event,
  so the all-zero synthetic usage no longer resets the context gauge.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SSU-WEI HUANG
2026-10-26 10:00:23 +08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6bf0a633bb
commit d3473f9ed4
6 changed files with 123 additions and 4 deletions
+8 -3
View File
@@ -13,10 +13,12 @@ import { z } from "zod";
// back to a heuristic denominator for local sessions only.
export const UsageSchema = z.object({
input_tokens: z.number().int().nonnegative(),
cache_creation_input_tokens: z.number().int().nonnegative().optional(),
cache_read_input_tokens: z.number().int().nonnegative().optional(),
// The Messages API types these as nullable; Claude Code's synthetic API
// error records write `service_tier: null`, which `.optional()` rejected.
cache_creation_input_tokens: z.number().int().nonnegative().nullish(),
cache_read_input_tokens: z.number().int().nonnegative().nullish(),
output_tokens: z.number().int().nonnegative(),
service_tier: z.string().optional(),
service_tier: z.string().nullish(),
}).passthrough();
// `passthrough` keeps fields the SDK adds going forward (e.g. `model`, future
@@ -68,6 +70,9 @@ export const RawJSONLinesSchema = z.discriminatedUnion("type", [
type: z.literal("assistant"),
message: RawMessageSchema.optional(),
requestId: z.string().optional(),
// Set on Claude Code's synthetic API error messages (e.g. "Prompt is too long").
isApiErrorMessage: z.boolean().optional(),
error: z.unknown().optional(),
}),
// Summary message - validates summary and leafUuid
@@ -159,6 +159,23 @@ describe('SDKToLogConverter', () => {
expect((logMessage as any).requestId).toBe('req_123')
})
it('keeps the top-level error on synthetic API error messages (#1857)', () => {
const sdkMessage: any = {
type: 'assistant',
message: {
model: '<synthetic>',
role: 'assistant',
content: [{ type: 'text', text: 'Prompt is too long' }]
},
error: 'invalid_request',
is_api_error_message: true
}
const logMessage = converter.convert(sdkMessage)
expect((logMessage as any).error).toBe('invalid_request')
})
})
describe('System messages', () => {
+3 -1
View File
@@ -327,7 +327,9 @@ export class SDKToLogConverter {
type: 'assistant',
message: assistantMsg.message,
// Assistant messages often have additional fields
requestId: (assistantMsg as any).requestId
requestId: (assistantMsg as any).requestId,
// Set on synthetic API error messages; the web renders these as errors
error: (assistantMsg as any).error
}
// if (assistantMsg.message.content && Array.isArray(assistantMsg.message.content)) {
// for (const content of assistantMsg.message.content) {
@@ -211,6 +211,41 @@ describe('sessionScanner', () => {
expect(result.nextCursor).toBe(Buffer.byteLength(line1 + '\n' + line2))
})
it('forwards a Claude API error record with null usage fields and keeps its error flags (#1857)', async () => {
const filePath = join(testDir, 'api-error.jsonl')
const line = JSON.stringify({
type: 'assistant',
uuid: 'a1',
isSidechain: false,
isApiErrorMessage: true,
error: 'invalid_request',
message: {
model: '<synthetic>',
role: 'assistant',
content: [{ type: 'text', text: 'Prompt is too long' }],
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
service_tier: null,
inference_geo: null,
speed: null
}
}
})
await writeFile(filePath, line + '\n')
const result = await readSessionLog(filePath, 0)
expect(result.events).toHaveLength(1)
expect(result.events[0].event).toMatchObject({
type: 'assistant',
uuid: 'a1',
isApiErrorMessage: true,
error: 'invalid_request'
})
})
it('forwards a single complete record with no newline at all', async () => {
const filePath = join(testDir, 'single-no-newline.jsonl')
const only = JSON.stringify({ type: 'user', uuid: 'u1', message: { content: 'only' } })