Structured JSON Output (#8119)

This commit is contained in:
Jerop Kipruto
2025-09-11 05:19:47 +09:00
committed by GitHub
parent db99fc70b6
commit 514767c88b
20 changed files with 1526 additions and 23 deletions

View File

@@ -62,6 +62,7 @@ import {
RipgrepFallbackEvent,
} from '../telemetry/types.js';
import type { FallbackModelHandler } from '../fallback/types.js';
import { OutputFormat } from '../output/types.js';
// Re-export OAuth config type
export type { MCPOAuthConfig, AnyToolInvocation };
@@ -105,6 +106,10 @@ export interface TelemetrySettings {
outfile?: string;
}
export interface OutputSettings {
format?: OutputFormat;
}
export interface GeminiCLIExtension {
name: string;
version: string;
@@ -228,6 +233,7 @@ export interface ConfigParameters {
enableToolOutputTruncation?: boolean;
eventEmitter?: EventEmitter;
useSmartEdit?: boolean;
output?: OutputSettings;
}
export class Config {
@@ -310,6 +316,7 @@ export class Config {
private readonly fileExclusions: FileExclusions;
private readonly eventEmitter?: EventEmitter;
private readonly useSmartEdit: boolean;
private readonly outputSettings: OutputSettings;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
@@ -393,6 +400,9 @@ export class Config {
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
this.outputSettings = {
format: params.output?.format ?? OutputFormat.TEXT,
};
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
@@ -880,6 +890,12 @@ export class Config {
return this.useSmartEdit;
}
getOutputFormat(): OutputFormat {
return this.outputSettings?.format
? this.outputSettings.format
: OutputFormat.TEXT;
}
async getGitService(): Promise<GitService> {
if (!this.gitService) {
this.gitService = new GitService(this.targetDir, this.storage);

View File

@@ -6,6 +6,8 @@
// Export config
export * from './config/config.js';
export * from './output/types.js';
export * from './output/json-formatter.js';
// Export Core Logic
export * from './core/client.js';

View File

@@ -0,0 +1,301 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { expect, describe, it } from 'vitest';
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
import { JsonFormatter } from './json-formatter.js';
import type { JsonError } from './types.js';
describe('JsonFormatter', () => {
it('should format the response as JSON', () => {
const formatter = new JsonFormatter();
const response = 'This is a test response.';
const formatted = formatter.format(response);
const expected = {
response,
};
expect(JSON.parse(formatted)).toEqual(expected);
});
it('should strip ANSI escape sequences from response text', () => {
const formatter = new JsonFormatter();
const responseWithAnsi =
'\x1B[31mRed text\x1B[0m and \x1B[32mGreen text\x1B[0m';
const formatted = formatter.format(responseWithAnsi);
const parsed = JSON.parse(formatted);
expect(parsed.response).toBe('Red text and Green text');
});
it('should strip control characters from response text', () => {
const formatter = new JsonFormatter();
const responseWithControlChars =
'Text with\x07 bell\x08 and\x0B vertical tab';
const formatted = formatter.format(responseWithControlChars);
const parsed = JSON.parse(formatted);
// Only ANSI codes are stripped, other control chars are preserved
expect(parsed.response).toBe('Text with\x07 bell\x08 and\x0B vertical tab');
});
it('should preserve newlines and tabs in response text', () => {
const formatter = new JsonFormatter();
const responseWithWhitespace = 'Line 1\nLine 2\r\nLine 3\twith tab';
const formatted = formatter.format(responseWithWhitespace);
const parsed = JSON.parse(formatted);
expect(parsed.response).toBe('Line 1\nLine 2\r\nLine 3\twith tab');
});
it('should format the response as JSON with stats', () => {
const formatter = new JsonFormatter();
const response = 'This is a test response.';
const stats: SessionMetrics = {
models: {
'gemini-2.5-pro': {
api: {
totalRequests: 2,
totalErrors: 0,
totalLatencyMs: 5672,
},
tokens: {
prompt: 24401,
candidates: 215,
total: 24719,
cached: 10656,
thoughts: 103,
tool: 0,
},
},
'gemini-2.5-flash': {
api: {
totalRequests: 2,
totalErrors: 0,
totalLatencyMs: 5914,
},
tokens: {
prompt: 20803,
candidates: 716,
total: 21657,
cached: 0,
thoughts: 138,
tool: 0,
},
},
},
tools: {
totalCalls: 1,
totalSuccess: 1,
totalFail: 0,
totalDurationMs: 4582,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
auto_accept: 1,
},
byName: {
google_web_search: {
count: 1,
success: 1,
fail: 0,
durationMs: 4582,
decisions: {
accept: 0,
reject: 0,
modify: 0,
auto_accept: 1,
},
},
},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const formatted = formatter.format(response, stats);
const expected = {
response,
stats,
};
expect(JSON.parse(formatted)).toEqual(expected);
});
it('should format error as JSON', () => {
const formatter = new JsonFormatter();
const error: JsonError = {
type: 'ValidationError',
message: 'Invalid input provided',
code: 400,
};
const formatted = formatter.format(undefined, undefined, error);
const expected = {
error,
};
expect(JSON.parse(formatted)).toEqual(expected);
});
it('should format response with error as JSON', () => {
const formatter = new JsonFormatter();
const response = 'Partial response';
const error: JsonError = {
type: 'TimeoutError',
message: 'Request timed out',
code: 'TIMEOUT',
};
const formatted = formatter.format(response, undefined, error);
const expected = {
response,
error,
};
expect(JSON.parse(formatted)).toEqual(expected);
});
it('should format error using formatError method', () => {
const formatter = new JsonFormatter();
const error = new Error('Something went wrong');
const formatted = formatter.formatError(error, 500);
const parsed = JSON.parse(formatted);
expect(parsed).toEqual({
error: {
type: 'Error',
message: 'Something went wrong',
code: 500,
},
});
});
it('should format custom error using formatError method', () => {
class CustomError extends Error {
constructor(message: string) {
super(message);
this.name = 'CustomError';
}
}
const formatter = new JsonFormatter();
const error = new CustomError('Custom error occurred');
const formatted = formatter.formatError(error);
const parsed = JSON.parse(formatted);
expect(parsed).toEqual({
error: {
type: 'CustomError',
message: 'Custom error occurred',
},
});
});
it('should format complete JSON output with response, stats, and error', () => {
const formatter = new JsonFormatter();
const response = 'Partial response before error';
const stats: SessionMetrics = {
models: {},
tools: {
totalCalls: 0,
totalSuccess: 0,
totalFail: 1,
totalDurationMs: 0,
totalDecisions: {
accept: 0,
reject: 0,
modify: 0,
auto_accept: 0,
},
byName: {},
},
files: {
totalLinesAdded: 0,
totalLinesRemoved: 0,
},
};
const error: JsonError = {
type: 'ApiError',
message: 'Rate limit exceeded',
code: 429,
};
const formatted = formatter.format(response, stats, error);
const expected = {
response,
stats,
error,
};
expect(JSON.parse(formatted)).toEqual(expected);
});
it('should handle error messages containing JSON content', () => {
const formatter = new JsonFormatter();
const errorWithJson = new Error(
'API returned: {"error": "Invalid request", "code": 400}',
);
const formatted = formatter.formatError(errorWithJson, 'API_ERROR');
const parsed = JSON.parse(formatted);
expect(parsed).toEqual({
error: {
type: 'Error',
message: 'API returned: {"error": "Invalid request", "code": 400}',
code: 'API_ERROR',
},
});
// Verify the entire output is valid JSON
expect(() => JSON.parse(formatted)).not.toThrow();
});
it('should handle error messages with quotes and special characters', () => {
const formatter = new JsonFormatter();
const errorWithQuotes = new Error('Error: "quoted text" and \\backslash');
const formatted = formatter.formatError(errorWithQuotes);
const parsed = JSON.parse(formatted);
expect(parsed).toEqual({
error: {
type: 'Error',
message: 'Error: "quoted text" and \\backslash',
},
});
// Verify the entire output is valid JSON
expect(() => JSON.parse(formatted)).not.toThrow();
});
it('should handle error messages with control characters', () => {
const formatter = new JsonFormatter();
const errorWithControlChars = new Error('Error with\n newline and\t tab');
const formatted = formatter.formatError(errorWithControlChars);
const parsed = JSON.parse(formatted);
// Should preserve newlines and tabs as they are common whitespace characters
expect(parsed.error.message).toBe('Error with\n newline and\t tab');
// Verify the entire output is valid JSON
expect(() => JSON.parse(formatted)).not.toThrow();
});
it('should strip ANSI escape sequences from error messages', () => {
const formatter = new JsonFormatter();
const errorWithAnsi = new Error('\x1B[31mRed error\x1B[0m message');
const formatted = formatter.formatError(errorWithAnsi);
const parsed = JSON.parse(formatted);
expect(parsed.error.message).toBe('Red error message');
expect(() => JSON.parse(formatted)).not.toThrow();
});
it('should strip unsafe control characters from error messages', () => {
const formatter = new JsonFormatter();
const errorWithControlChars = new Error(
'Error\x07 with\x08 control\x0B chars',
);
const formatted = formatter.formatError(errorWithControlChars);
const parsed = JSON.parse(formatted);
// Only ANSI codes are stripped, other control chars are preserved
expect(parsed.error.message).toBe('Error\x07 with\x08 control\x0B chars');
expect(() => JSON.parse(formatted)).not.toThrow();
});
});

View File

@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import stripAnsi from 'strip-ansi';
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
import type { JsonError, JsonOutput } from './types.js';
export class JsonFormatter {
format(response?: string, stats?: SessionMetrics, error?: JsonError): string {
const output: JsonOutput = {};
if (response !== undefined) {
output.response = stripAnsi(response);
}
if (stats) {
output.stats = stats;
}
if (error) {
output.error = error;
}
return JSON.stringify(output, null, 2);
}
formatError(error: Error, code?: string | number): string {
const jsonError: JsonError = {
type: error.constructor.name,
message: stripAnsi(error.message),
...(code && { code }),
};
return this.format(undefined, undefined, jsonError);
}
}

View File

@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SessionMetrics } from '../telemetry/uiTelemetry.js';
export enum OutputFormat {
TEXT = 'text',
JSON = 'json',
}
export interface JsonError {
type: string;
message: string;
code?: string | number;
}
export interface JsonOutput {
response?: string;
stats?: SessionMetrics;
error?: JsonError;
}

View File

@@ -59,6 +59,16 @@ export class FatalTurnLimitedError extends FatalError {
super(message, 53);
}
}
export class FatalToolExecutionError extends FatalError {
constructor(message: string) {
super(message, 54);
}
}
export class FatalCancellationError extends FatalError {
constructor(message: string) {
super(message, 130); // Standard exit code for SIGINT
}
}
export class ForbiddenError extends Error {}
export class UnauthorizedError extends Error {}