Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/_vendor/partial-json-parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,14 +248,15 @@ const _parseJSON = (jsonString: string, allow: number) => {
markPartialJSON('Unterminated number literal');
}

const number = jsonString.substring(start, index);
try {
return JSON.parse(jsonString.substring(start, index));
return JSON.parse(number);
} catch {
if (jsonString.substring(start, index) === '-' && Allow.NUM & allow) {
if (number === '-' && Allow.NUM & allow) {
markPartialJSON("Not sure what '-' is");
}
try {
return JSON.parse(jsonString.substring(start, jsonString.lastIndexOf('e')));
return JSON.parse(number.substring(0, number.lastIndexOf('e')));
} catch (e) {
throwMalformedError(String(e));
}
Expand Down
23 changes: 23 additions & 0 deletions tests/_vendor/partial-json-parser/partial-json-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,29 @@ describe('partial parsing', () => {
expect(partialParse('[1, 2e')).toEqual([1]);
});

test.each<[string, number[]]>([
['[1, 2e', [1]],
['[1, 2e+', [1]],
['[1, 2e-', [1]],
['[1, 2e3]', [1, 2000]],
['[1, 2E3]', [1, 2000]],
['[1, 2e-2 ]', [1, 0.02]],
['[1, 2e]', [1, 2]],
['[1, 2e+, 3]', [1, 2, 3]],
['[1, 2e-, 3]', [1, 2, 3]],
['[1, 2e, 3e2]', [1, 2, 300]],
])('preserves exponent recovery for %s', (input, expected) => {
expect(partialParse(input)).toEqual(expected);
});

test('bounds exponent recovery before a later string', () => {
expect(partialParse('[1, 2e, "else"]')).toEqual([1, 2, 'else']);
});

test('preserves partial arrays while recovering from malformed numeric tokens', () => {
expect(partialParse(`[${'[x,'.repeat(100)}]`)).toEqual(Array.from({ length: 100 }, () => []));
});

test('should only throw errors parsing numbers', () =>
assert(
property(json({ depthSize: 'large', noUnicodeString: false }), (jsonString) => {
Expand Down
60 changes: 60 additions & 0 deletions tests/lib/chat-completion-stream-partial-json-bounds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,66 @@ function createStructuredStream(kind: 'content' | 'tool', fragments: Iterable<st

afterEach(() => vi.restoreAllMocks());

it.each(['content', 'tool'] as const)(
'keeps numeric recovery work linear for malformed serialized %s',
async (kind) => {
const content = `[${'[x,'.repeat(100)}"${'x'.repeat(1024 * 1024)}"]`;
const chunks = kind === 'content' ? contentFragments([content]) : argumentFragments([content]);
const client = createSerializedClient(chunks, vi.fn());
const { lastIndexOf } = String.prototype;
let searchedCharacters = 0;
const search = vi.spyOn(String.prototype, 'lastIndexOf').mockImplementation(function search(
this: string,
value: string,
position?: number,
) {
if (value === 'e') {
searchedCharacters += this.length;
}
return lastIndexOf.call(this, value, position);
});
let failure: unknown;

try {
await client.chat.completions
.stream({
model: 'gpt-test',
messages: [{ role: 'user', content: 'Return structured output' }],
...(kind === 'content' ? { response_format: structuredResponseFormat } : { tools: [strictTool] }),
})
.finalChatCompletion();
} catch (error) {
failure = error;
} finally {
search.mockRestore();
}

expect(failure).toBeInstanceOf(Error);
expect(failure).toMatchObject({ message: 'Error reading response: invalid structured output JSON.' });
expect(searchedCharacters).toBeLessThanOrEqual(content.length);
},
);

it.each(['content', 'tool'] as const)('preserves large valid serialized %s', async (kind) => {
const value = { value: 'x'.repeat(1024 * 1024) };
const content = JSON.stringify(value);
const chunks = kind === 'content' ? contentFragments([content]) : argumentFragments([content]);
const client = createSerializedClient(chunks, vi.fn());
const completion = await client.chat.completions
.stream({
model: 'gpt-test',
messages: [{ role: 'user', content: 'Return structured output' }],
...(kind === 'content' ? { response_format: structuredResponseFormat } : { tools: [strictTool] }),
})
.finalChatCompletion();

expect(completion.choices[0]?.message).toMatchObject(
kind === 'content'
? { parsed: value }
: { tool_calls: [{ type: 'function', function: { parsed_arguments: value } }] },
);
});

it.each(['strict', 'auto-parseable'] as const)(
'captures receiver-bound %s argument accessors exactly once before all accounting and events',
async (kind) => {
Expand Down
Loading