Skip to content

Commit 2199e26

Browse files
committed
fix: copolit function call returning infinite line breaks until max_tokens limit
1 parent 6d75a58 commit 2199e26

4 files changed

Lines changed: 236 additions & 5 deletions

File tree

src/lib/json-stream.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
export class JsonStreamValidationError extends Error {
2+
constructor(message: string, options?: ErrorOptions) {
3+
super(message, options)
4+
this.name = "JsonStreamValidationError"
5+
}
6+
}
7+
8+
const FORBIDDEN_OBJECT_SEQUENCE = '","}'
9+
const FORBIDDEN_SEQUENCE_LOOKBACK = FORBIDDEN_OBJECT_SEQUENCE.length - 1
10+
11+
export interface JsonStreamScanState {
12+
braceBalance: number
13+
bracketBalance: number
14+
inString: boolean
15+
isEscaped: boolean
16+
recentChars: string
17+
}
18+
19+
const cloneState = (state?: JsonStreamScanState): JsonStreamScanState => ({
20+
braceBalance: state?.braceBalance ?? 0,
21+
bracketBalance: state?.bracketBalance ?? 0,
22+
inString: state?.inString ?? false,
23+
isEscaped: state?.isEscaped ?? false,
24+
recentChars: state?.recentChars ?? "",
25+
})
26+
27+
export const validateJsonStreamChunk = (
28+
previousState: JsonStreamScanState | undefined,
29+
chunk: string,
30+
): JsonStreamScanState => {
31+
const nextState = cloneState(previousState)
32+
33+
if (!chunk) {
34+
return nextState
35+
}
36+
37+
for (const char of chunk) {
38+
checkForForbiddenObjectSequence(nextState, char)
39+
40+
if (nextState.inString) {
41+
handleInsideStringChar(nextState, char)
42+
} else {
43+
handleOutsideStringChar(nextState, char)
44+
}
45+
}
46+
47+
return nextState
48+
}
49+
50+
const checkForForbiddenObjectSequence = (
51+
state: JsonStreamScanState,
52+
char: string,
53+
): void => {
54+
const previous = state.recentChars
55+
const candidate = `${previous}${char}`
56+
57+
if (candidate.includes(FORBIDDEN_OBJECT_SEQUENCE)) {
58+
throw new JsonStreamValidationError(
59+
`JSON stream contains an unexpected sequence '${FORBIDDEN_OBJECT_SEQUENCE}' indicating malformed tool call arguments.`,
60+
)
61+
}
62+
63+
state.recentChars =
64+
candidate.length > FORBIDDEN_SEQUENCE_LOOKBACK ?
65+
candidate.slice(candidate.length - FORBIDDEN_SEQUENCE_LOOKBACK)
66+
: candidate
67+
}
68+
69+
const handleInsideStringChar = (
70+
state: JsonStreamScanState,
71+
char: string,
72+
): void => {
73+
if (state.isEscaped) {
74+
state.isEscaped = false
75+
return
76+
}
77+
78+
if (char === "\\") {
79+
state.isEscaped = true
80+
return
81+
}
82+
83+
if (char === '"') {
84+
state.inString = false
85+
return
86+
}
87+
88+
if (char === "\n" || char === "\r") {
89+
throw new JsonStreamValidationError(
90+
"JSON strings cannot contain unescaped newline characters.",
91+
)
92+
}
93+
}
94+
95+
const handleOutsideStringChar = (
96+
state: JsonStreamScanState,
97+
char: string,
98+
): void => {
99+
state.isEscaped = false
100+
101+
switch (char) {
102+
case '"': {
103+
state.inString = true
104+
return
105+
}
106+
case "{": {
107+
state.braceBalance += 1
108+
return
109+
}
110+
case "}": {
111+
state.braceBalance -= 1
112+
if (state.braceBalance < 0) {
113+
throw new JsonStreamValidationError(
114+
"JSON stream has an unexpected closing '}' brace.",
115+
)
116+
}
117+
return
118+
}
119+
case "[": {
120+
state.bracketBalance += 1
121+
return
122+
}
123+
case "]": {
124+
state.bracketBalance -= 1
125+
if (state.bracketBalance < 0) {
126+
throw new JsonStreamValidationError(
127+
"JSON stream has an unexpected closing ']' bracket.",
128+
)
129+
}
130+
return
131+
}
132+
default: {
133+
return
134+
}
135+
}
136+
}

src/routes/messages/handler.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createHandlerLogger } from "~/lib/logger"
88
import { checkRateLimit } from "~/lib/rate-limit"
99
import { state } from "~/lib/state"
1010
import {
11+
buildErrorEvent,
1112
createResponsesStreamState,
1213
translateResponsesStreamEvent,
1314
} from "~/routes/messages/responses-stream-translation"
@@ -171,16 +172,23 @@ const handleWithResponsesApi = async (
171172
data: eventData,
172173
})
173174
}
175+
176+
if (streamState.messageCompleted) {
177+
logger.debug("Message completed, ending stream")
178+
break
179+
}
174180
}
175181

176182
if (!streamState.messageCompleted) {
177183
logger.warn(
178-
"Responses stream ended without completion; sending fallback message_stop",
184+
"Responses stream ended without completion; sending erorr event",
185+
)
186+
const errorEvent = buildErrorEvent(
187+
"Responses stream ended without completion",
179188
)
180-
const fallback = { type: "message_stop" as const }
181189
await stream.writeSSE({
182-
event: fallback.type,
183-
data: JSON.stringify(fallback),
190+
event: errorEvent.type,
191+
data: JSON.stringify(errorEvent),
184192
})
185193
}
186194
})

src/routes/messages/responses-stream-translation.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import consola from "consola"
22

3+
import {
4+
JsonStreamValidationError,
5+
validateJsonStreamChunk,
6+
} from "~/lib/json-stream"
7+
import { type JsonStreamScanState } from "~/lib/json-stream"
38
import {
49
type ResponseCompletedEvent,
510
type ResponseCreatedEvent,
@@ -35,6 +40,7 @@ type FunctionCallStreamState = {
3540
blockIndex: number
3641
toolCallId: string
3742
name: string
43+
scanState?: JsonStreamScanState
3844
}
3945

4046
export const createResponsesStreamState = (): ResponsesStreamState => ({
@@ -186,11 +192,39 @@ const handleFunctionCallArgumentsDelta = (
186192
const events = new Array<AnthropicStreamEventData>()
187193
const outputIndex = rawEvent.output_index
188194
const deltaText = rawEvent.delta
195+
196+
if (!deltaText) {
197+
return events
198+
}
199+
189200
const blockIndex = openFunctionCallBlock(state, {
190201
outputIndex,
191202
events,
192203
})
193204

205+
const functionCallState =
206+
state.functionCallStateByOutputIndex.get(outputIndex)
207+
if (!functionCallState) {
208+
return handleJsonValidationError(
209+
new JsonStreamValidationError(
210+
"Received function call arguments delta without an open tool call block.",
211+
),
212+
state,
213+
events,
214+
)
215+
}
216+
217+
// fix: copolit function call returning infinite line breaks until max_tokens limit
218+
// "arguments": "{\"path\":\"xxx",\"pattern\":\"**/*.ts\",\"} }? Wait extra braces. Need correct. I should run? Wait overcame. Need proper JSON with pattern \"\n\n\n\n\n\n\n\n...
219+
try {
220+
functionCallState.scanState = validateJsonStreamChunk(
221+
functionCallState.scanState,
222+
deltaText,
223+
)
224+
} catch (error) {
225+
return handleJsonValidationError(error, state, events)
226+
}
227+
194228
events.push({
195229
type: "content_block_delta",
196230
index: blockIndex,
@@ -394,6 +428,26 @@ const handleErrorEvent = (
394428
return [buildErrorEvent(message)]
395429
}
396430

431+
const handleJsonValidationError = (
432+
error: unknown,
433+
state: ResponsesStreamState,
434+
events: Array<AnthropicStreamEventData> = [],
435+
): Array<AnthropicStreamEventData> => {
436+
const reason =
437+
error instanceof JsonStreamValidationError ?
438+
error.message
439+
: "Received invalid JSON for function call arguments."
440+
441+
consola.error("Invalid function call arguments JSON:", error)
442+
443+
closeAllOpenBlocks(state, events)
444+
state.messageCompleted = true
445+
446+
events.push(buildErrorEvent(reason))
447+
448+
return events
449+
}
450+
397451
const messageStart = (
398452
state: ResponsesStreamState,
399453
response: ResponsesResult,
@@ -521,7 +575,7 @@ const closeAllOpenBlocks = (
521575
state.functionCallStateByOutputIndex.clear()
522576
}
523577

524-
const buildErrorEvent = (message: string): AnthropicStreamEventData => ({
578+
export const buildErrorEvent = (message: string): AnthropicStreamEventData => ({
525579
type: "error",
526580
error: {
527581
type: "api_error",
@@ -556,6 +610,7 @@ const openFunctionCallBlock = (
556610
blockIndex,
557611
toolCallId: resolvedToolCallId,
558612
name: resolvedName,
613+
scanState: undefined,
559614
}
560615

561616
state.functionCallStateByOutputIndex.set(outputIndex, functionCallState)

tests/json-stream.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from "bun:test"
2+
3+
import {
4+
JsonStreamValidationError,
5+
validateJsonStreamChunk,
6+
} from "~/lib/json-stream"
7+
8+
describe("validateJsonStreamChunk", () => {
9+
it("throws when chunk contains unexpected sequence", () => {
10+
expect(() =>
11+
validateJsonStreamChunk(
12+
undefined,
13+
'{"pattern":"**/*.ts","path":".","}??',
14+
),
15+
).toThrow(JsonStreamValidationError)
16+
})
17+
18+
it("throws when unexpected sequence spans multiple chunks", () => {
19+
const state = validateJsonStreamChunk(
20+
undefined,
21+
'{"pattern":"**/*.ts","path":"."',
22+
)
23+
24+
expect(() => validateJsonStreamChunk(state, ',"}')).toThrow(
25+
JsonStreamValidationError,
26+
)
27+
})
28+
29+
it("accepts valid JSON with escaped quote sequence", () => {
30+
validateJsonStreamChunk(undefined, String.raw`{"value":"\",\"}"}`)
31+
})
32+
})

0 commit comments

Comments
 (0)