Skip to content

Commit ea6065f

Browse files
committed
fix(aistudio): strip usage metadata from non-final stream chunks
1 parent 8aaed4c commit ea6065f

File tree

1 file changed

+66
-5
lines changed

1 file changed

+66
-5
lines changed

internal/runtime/executor/aistudio_executor.go

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,13 +150,15 @@ func (e *AistudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
150150
case wsrelay.MessageTypeStreamChunk:
151151
if len(event.Payload) > 0 {
152152
appendAPIResponseChunk(ctx, e.cfg, bytes.Clone(event.Payload))
153-
if detail, ok := parseGeminiStreamUsage(event.Payload); ok {
153+
filtered := filterAistudioUsageMetadata(event.Payload)
154+
if detail, ok := parseGeminiStreamUsage(filtered); ok {
154155
reporter.publish(ctx, detail)
155156
}
156-
}
157-
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, bytes.Clone(opts.OriginalRequest), translatedReq, bytes.Clone(event.Payload), &param)
158-
for i := range lines {
159-
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
157+
lines := sdktranslator.TranslateStream(ctx, body.toFormat, opts.SourceFormat, req.Model, bytes.Clone(opts.OriginalRequest), translatedReq, bytes.Clone(filtered), &param)
158+
for i := range lines {
159+
out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
160+
}
161+
break
160162
}
161163
case wsrelay.MessageTypeStreamEnd:
162164
return
@@ -281,3 +283,62 @@ func (e *AistudioExecutor) buildEndpoint(model, action, alt string) string {
281283
}
282284
return base
283285
}
286+
287+
// filterAistudioUsageMetadata removes usageMetadata from intermediate SSE events so that
288+
// only the terminal chunk retains token statistics.
289+
func filterAistudioUsageMetadata(payload []byte) []byte {
290+
if len(payload) == 0 {
291+
return payload
292+
}
293+
294+
lines := bytes.Split(payload, []byte("\n"))
295+
modified := false
296+
for idx, line := range lines {
297+
trimmed := bytes.TrimSpace(line)
298+
if len(trimmed) == 0 || !bytes.HasPrefix(trimmed, []byte("data:")) {
299+
continue
300+
}
301+
dataIdx := bytes.Index(line, []byte("data:"))
302+
if dataIdx < 0 {
303+
continue
304+
}
305+
rawJSON := bytes.TrimSpace(line[dataIdx+5:])
306+
cleaned, changed := stripUsageMetadataFromJSON(rawJSON)
307+
if !changed {
308+
continue
309+
}
310+
var rebuilt []byte
311+
rebuilt = append(rebuilt, line[:dataIdx]...)
312+
rebuilt = append(rebuilt, []byte("data:")...)
313+
if len(cleaned) > 0 {
314+
rebuilt = append(rebuilt, ' ')
315+
rebuilt = append(rebuilt, cleaned...)
316+
}
317+
lines[idx] = rebuilt
318+
modified = true
319+
}
320+
if !modified {
321+
return payload
322+
}
323+
return bytes.Join(lines, []byte("\n"))
324+
}
325+
326+
// stripUsageMetadataFromJSON drops usageMetadata when no finishReason is present.
327+
func stripUsageMetadataFromJSON(rawJSON []byte) ([]byte, bool) {
328+
jsonBytes := bytes.TrimSpace(rawJSON)
329+
if len(jsonBytes) == 0 || !gjson.ValidBytes(jsonBytes) {
330+
return rawJSON, false
331+
}
332+
finishReason := gjson.GetBytes(jsonBytes, "candidates.0.finishReason")
333+
if finishReason.Exists() && finishReason.String() != "" {
334+
return rawJSON, false
335+
}
336+
if !gjson.GetBytes(jsonBytes, "usageMetadata").Exists() {
337+
return rawJSON, false
338+
}
339+
cleaned, err := sjson.DeleteBytes(jsonBytes, "usageMetadata")
340+
if err != nil {
341+
return rawJSON, false
342+
}
343+
return cleaned, true
344+
}

0 commit comments

Comments
 (0)