Skip to content

Commit 0af22ea

Browse files
Merge pull request #3 from BlocksOrg/feat/stream-json
Add stream-json output format
2 parents 37a8c90 + e527935 commit 0af22ea

File tree

8 files changed

+157
-1
lines changed

8 files changed

+157
-1
lines changed

packages/cli/src/config/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
242242
.option('output-format', {
243243
type: 'string',
244244
description: 'The format of the CLI output.',
245-
choices: ['text', 'json'],
245+
choices: ['text', 'json', 'stream-json'],
246246
})
247247
.option('resume', {
248248
alias: 'r',

packages/cli/src/nonInteractiveCli.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import {
1414
promptIdContext,
1515
OutputFormat,
1616
JsonFormatter,
17+
StreamJsonFormatter,
1718
uiTelemetryService,
19+
streamingTelemetryService,
1820
} from '@blocksuser/gemini-cli-core';
1921
import type { Content, Part } from '@google/genai';
2022

@@ -41,6 +43,18 @@ export async function runNonInteractive(
4143

4244
try {
4345
consolePatcher.patch();
46+
47+
// Set up streaming telemetry for stream-json format
48+
const isStreamJsonFormat = config.getOutputFormat() === OutputFormat.STREAM_JSON;
49+
let streamJsonFormatter: StreamJsonFormatter | undefined;
50+
51+
if (isStreamJsonFormat) {
52+
streamJsonFormatter = new StreamJsonFormatter();
53+
streamingTelemetryService.enable();
54+
streamingTelemetryService.addTelemetryListener((event) => {
55+
process.stdout.write(streamJsonFormatter!.formatTelemetryBlock(event) + '\n');
56+
});
57+
}
4458
// Handle EPIPE errors when the output is piped to a command that closes early.
4559
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
4660
if (err.code === 'EPIPE') {
@@ -123,6 +137,11 @@ export async function runNonInteractive(
123137
if (event.type === GeminiEventType.Content) {
124138
if (config.getOutputFormat() === OutputFormat.JSON) {
125139
responseText += event.value;
140+
} else if (config.getOutputFormat() === OutputFormat.STREAM_JSON) {
141+
responseText += event.value;
142+
if (streamJsonFormatter) {
143+
process.stdout.write(streamJsonFormatter.formatContentBlock(event.value) + '\n');
144+
}
126145
} else {
127146
process.stdout.write(event.value);
128147
}
@@ -162,6 +181,11 @@ export async function runNonInteractive(
162181
const formatter = new JsonFormatter();
163182
const stats = uiTelemetryService.getMetrics();
164183
process.stdout.write(formatter.format(responseText, stats));
184+
} else if (config.getOutputFormat() === OutputFormat.STREAM_JSON) {
185+
if (streamJsonFormatter) {
186+
const stats = uiTelemetryService.getMetrics();
187+
process.stdout.write(streamJsonFormatter.formatFinalBlock(responseText, stats) + '\n');
188+
}
165189
} else {
166190
process.stdout.write('\n'); // Ensure a final newline
167191
}
@@ -172,6 +196,9 @@ export async function runNonInteractive(
172196
handleError(error, config);
173197
} finally {
174198
consolePatcher.cleanup();
199+
if (config.getOutputFormat() === OutputFormat.STREAM_JSON) {
200+
streamingTelemetryService.disable();
201+
}
175202
if (isTelemetrySdkInitialized()) {
176203
await shutdownTelemetry(config);
177204
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
export * from './config/config.js';
99
export * from './output/types.js';
1010
export * from './output/json-formatter.js';
11+
export * from './output/stream-json-formatter.js';
1112

1213
// Export Core Logic
1314
export * from './core/client.js';
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import stripAnsi from 'strip-ansi';
8+
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
9+
import type { JsonError } from './types.js';
10+
import type { TelemetryEvent } from '../telemetry/types.js';
11+
12+
export interface StreamJsonTelemetryBlock {
13+
type: 'telemetry';
14+
event: TelemetryEvent;
15+
}
16+
17+
export interface StreamJsonContentBlock {
18+
type: 'content';
19+
content: string;
20+
}
21+
22+
export interface StreamJsonFinalBlock {
23+
type: 'final';
24+
response?: string;
25+
stats?: SessionMetrics;
26+
error?: JsonError;
27+
}
28+
29+
export type StreamJsonBlock = StreamJsonTelemetryBlock | StreamJsonContentBlock | StreamJsonFinalBlock;
30+
31+
export class StreamJsonFormatter {
32+
formatTelemetryBlock(event: TelemetryEvent): string {
33+
const block: StreamJsonTelemetryBlock = {
34+
type: 'telemetry',
35+
event,
36+
};
37+
return JSON.stringify(block);
38+
}
39+
40+
formatContentBlock(content: string): string {
41+
const block: StreamJsonContentBlock = {
42+
type: 'content',
43+
content: stripAnsi(content),
44+
};
45+
return JSON.stringify(block);
46+
}
47+
48+
formatFinalBlock(response?: string, stats?: SessionMetrics, error?: JsonError): string {
49+
const block: StreamJsonFinalBlock = {
50+
type: 'final',
51+
};
52+
53+
if (response !== undefined) {
54+
block.response = stripAnsi(response);
55+
}
56+
57+
if (stats) {
58+
block.stats = stats;
59+
}
60+
61+
if (error) {
62+
block.error = error;
63+
}
64+
65+
return JSON.stringify(block);
66+
}
67+
68+
formatError(error: Error, code?: string | number): string {
69+
const jsonError: JsonError = {
70+
type: error.constructor.name,
71+
message: stripAnsi(error.message),
72+
...(code && { code }),
73+
};
74+
75+
return this.formatFinalBlock(undefined, undefined, jsonError);
76+
}
77+
}

packages/core/src/output/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
99
export enum OutputFormat {
1010
TEXT = 'text',
1111
JSON = 'json',
12+
STREAM_JSON = 'stream-json',
1213
}
1314

1415
export interface JsonError {

packages/core/src/telemetry/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,5 @@ export { SemanticAttributes } from '@opentelemetry/semantic-conventions';
5454
export * from './uiTelemetry.js';
5555
export { HighWaterMarkTracker } from './high-water-mark-tracker.js';
5656
export { RateLimiter } from './rate-limiter.js';
57+
export { streamingTelemetryService } from './streamingTelemetry.js';
58+
export type { TelemetryStreamListener } from './streamingTelemetry.js';

packages/core/src/telemetry/loggers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
import { isTelemetrySdkInitialized } from './sdk.js';
6767
import type { UiEvent } from './uiTelemetry.js';
6868
import { uiTelemetryService } from './uiTelemetry.js';
69+
import { streamingTelemetryService } from './streamingTelemetry.js';
6970
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
7071
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
7172
import { UserAccountManager } from '../utils/userAccountManager.js';
@@ -119,6 +120,7 @@ export function logCliConfiguration(
119120
}
120121

121122
export function logUserPrompt(config: Config, event: UserPromptEvent): void {
123+
streamingTelemetryService.emitEvent(event);
122124
ClearcutLogger.getInstance(config)?.logNewPromptEvent(event);
123125
if (!isTelemetrySdkInitialized()) return;
124126

@@ -147,6 +149,7 @@ export function logUserPrompt(config: Config, event: UserPromptEvent): void {
147149
}
148150

149151
export function logToolCall(config: Config, event: ToolCallEvent): void {
152+
streamingTelemetryService.emitEvent(event);
150153
const uiEvent = {
151154
...event,
152155
'event.name': EVENT_TOOL_CALL,
@@ -359,6 +362,7 @@ export function logApiError(config: Config, event: ApiErrorEvent): void {
359362
}
360363

361364
export function logApiResponse(config: Config, event: ApiResponseEvent): void {
365+
streamingTelemetryService.emitEvent(event);
362366
const uiEvent = {
363367
...event,
364368
'event.name': EVENT_API_RESPONSE,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { EventEmitter } from 'node:events';
8+
import type { TelemetryEvent } from './types.js';
9+
10+
export interface TelemetryStreamListener {
11+
(event: TelemetryEvent): void;
12+
}
13+
14+
class StreamingTelemetryService extends EventEmitter {
15+
private enabled = false;
16+
17+
enable(): void {
18+
this.enabled = true;
19+
}
20+
21+
disable(): void {
22+
this.enabled = false;
23+
}
24+
25+
isEnabled(): boolean {
26+
return this.enabled;
27+
}
28+
29+
addTelemetryListener(listener: TelemetryStreamListener): void {
30+
this.on('telemetry', listener);
31+
}
32+
33+
removeTelemetryListener(listener: TelemetryStreamListener): void {
34+
this.off('telemetry', listener);
35+
}
36+
37+
emitEvent(event: TelemetryEvent): void {
38+
if (this.enabled) {
39+
this.emit('telemetry', event);
40+
}
41+
}
42+
}
43+
44+
export const streamingTelemetryService = new StreamingTelemetryService();

0 commit comments

Comments
 (0)