-
Notifications
You must be signed in to change notification settings - Fork 104
fix: surface MaxTokensError when max_tokens truncates tool input JSON #1065
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -323,23 +323,45 @@ describe('Model', () => { | |
| ) | ||
| }) | ||
|
|
||
| it('preserves SyntaxError instead of overwriting with MaxTokensError when tool input JSON is malformed', async () => { | ||
| it('throws MaxTokenError when contentBlockStop arrives with truncated tool input JSON and stopReason is maxTokens', async () => { | ||
| const provider = new TestModelProvider(async function* () { | ||
| yield { type: 'modelMessageStartEvent', role: 'assistant' } | ||
| yield { | ||
| type: 'modelContentBlockStartEvent', | ||
| start: { type: 'toolUseStart', toolUseId: 'tool1', name: 'get_weather' }, | ||
| start: { type: 'toolUseStart', toolUseId: 't', name: 'tool' }, | ||
| } | ||
| yield { | ||
| type: 'modelContentBlockDeltaEvent', | ||
| delta: { type: 'toolUseInputDelta', input: '{invalid json' }, | ||
| delta: { type: 'toolUseInputDelta', input: '{"field": "value"' }, | ||
| } | ||
| yield { type: 'modelContentBlockStopEvent' } | ||
| yield { type: 'modelMessageStopEvent', stopReason: 'maxTokens' } | ||
| }) | ||
|
|
||
| const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })] | ||
|
|
||
| await expect(async () => await collectGenerator(provider.streamAggregated(messages))).rejects.toThrow( | ||
| MaxTokensError | ||
| ) | ||
| }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Issue: Missing test coverage for the path where the stream ends without a Suggestion: Add a test case where the generator yields a tool input delta +
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test added |
||
|
|
||
| it('surfaces SyntaxError as cause when tool input JSON is malformed and stopReason is not maxTokens', async () => { | ||
| const provider = new TestModelProvider(async function* () { | ||
| yield { type: 'modelMessageStartEvent', role: 'assistant' } | ||
| yield { | ||
| type: 'modelContentBlockStartEvent', | ||
| start: { type: 'toolUseStart', toolUseId: 't', name: 'tool' }, | ||
| } | ||
| yield { | ||
| type: 'modelContentBlockDeltaEvent', | ||
| delta: { type: 'toolUseInputDelta', input: '{invalid json' }, | ||
| } | ||
| yield { type: 'modelContentBlockStopEvent' } | ||
| yield { type: 'modelMessageStopEvent', stopReason: 'toolUse' } | ||
| }) | ||
|
|
||
| const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })] | ||
|
|
||
| try { | ||
| await collectGenerator(provider.streamAggregated(messages)) | ||
| expect.fail('Expected error to be thrown') | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -349,6 +349,7 @@ export abstract class Model<T extends BaseModelConfig = BaseModelConfig> { | |
| let finalStopReason: StopReason | null = null | ||
| let metadata: ModelMetadataEvent | undefined = undefined | ||
| let redactionMessage: string | undefined = undefined | ||
| let deferredToolInputParseError: SyntaxError | undefined = undefined | ||
|
|
||
| for await (const event_data of this.stream(messages, options)) { | ||
| const event = this._convert_to_class_event(event_data) | ||
|
|
@@ -425,8 +426,8 @@ export abstract class Model<T extends BaseModelConfig = BaseModelConfig> { | |
| yield block | ||
| } catch (e: unknown) { | ||
| if (e instanceof SyntaxError) { | ||
| logger.error('unable to parse JSON string', e) | ||
| throw e | ||
| logger.error('unable to parse tool input JSON', e) | ||
| deferredToolInputParseError = e | ||
| } | ||
| } | ||
| break | ||
|
|
@@ -471,7 +472,10 @@ export abstract class Model<T extends BaseModelConfig = BaseModelConfig> { | |
|
|
||
| if (!stoppedMessage || !finalStopReason) { | ||
| // If we exit the loop without completing a message or stop reason, throw an error | ||
| throw new ModelError('Stream ended without completing a message') | ||
| throw new ModelError( | ||
| 'Stream ended without completing a message', | ||
| deferredToolInputParseError ? { cause: deferredToolInputParseError } : undefined | ||
| ) | ||
| } | ||
|
|
||
| // Attach metadata after redaction so it applies to the final message. | ||
|
|
@@ -495,6 +499,10 @@ export abstract class Model<T extends BaseModelConfig = BaseModelConfig> { | |
| ) | ||
| } | ||
|
|
||
| if (deferredToolInputParseError !== undefined) { | ||
| throw deferredToolInputParseError | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Issue: When Suggestion: Consider wrapping it explicitly to preserve the diagnostic context already in the log message: if (deferredToolInputParseError !== undefined) {
throw new ModelError('unable to parse tool input JSON', { cause: deferredToolInputParseError })
}This makes the error message self-descriptive and avoids depending on the outer catch's generic wrapping behavior.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Error explicitly wrapped and test updated |
||
| } | ||
|
|
||
| // Return the final message with stop reason and optional metadata | ||
| const result: StreamAggregatedResult = { | ||
| message: stoppedMessage, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: typo in the test description —
MaxTokenErrorshould beMaxTokensError(plural, matching the class inerrors.ts). The assertion itself is correct; just theit(...)string.