mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
sync gemini-cli 0.1.17
Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -18,16 +18,19 @@ import {
|
||||
ApiErrorEvent,
|
||||
FlashFallbackEvent,
|
||||
LoopDetectedEvent,
|
||||
FlashDecidedToContinueEvent,
|
||||
NextSpeakerCheckEvent,
|
||||
SlashCommandEvent,
|
||||
MalformedJsonResponseEvent,
|
||||
} from '../types.js';
|
||||
import { EventMetadataKey } from './event-metadata-key.js';
|
||||
import { Config } from '../../config/config.js';
|
||||
import { getInstallationId } from '../../utils/user_id.js';
|
||||
import { safeJsonStringify } from '../../utils/safeJsonStringify.js';
|
||||
import {
|
||||
getCachedGoogleAccount,
|
||||
getLifetimeGoogleAccounts,
|
||||
} from '../../utils/user_account.js';
|
||||
import { safeJsonStringify } from '../../utils/safeJsonStringify.js';
|
||||
import { HttpError, retryWithBackoff } from '../../utils/retry.js';
|
||||
import { getInstallationId } from '../../utils/user_id.js';
|
||||
|
||||
const start_session_event_name = 'start_session';
|
||||
const new_prompt_event_name = 'new_prompt';
|
||||
@@ -38,7 +41,9 @@ const api_error_event_name = 'api_error';
|
||||
const end_session_event_name = 'end_session';
|
||||
const flash_fallback_event_name = 'flash_fallback';
|
||||
const loop_detected_event_name = 'loop_detected';
|
||||
const flash_decided_to_continue_event_name = 'flash_decided_to_continue';
|
||||
const next_speaker_check_event_name = 'next_speaker_check';
|
||||
const slash_command_event_name = 'slash_command';
|
||||
const malformed_json_response_event_name = 'malformed_json_response';
|
||||
|
||||
export interface LogResponse {
|
||||
nextRequestWaitMs?: number;
|
||||
@@ -113,66 +118,81 @@ export class ClearcutLogger {
|
||||
});
|
||||
}
|
||||
|
||||
flushToClearcut(): Promise<LogResponse> {
|
||||
async flushToClearcut(): Promise<LogResponse> {
|
||||
if (this.config?.getDebugMode()) {
|
||||
console.log('Flushing log events to Clearcut.');
|
||||
}
|
||||
const eventsToSend = [...this.events];
|
||||
this.events.length = 0;
|
||||
if (eventsToSend.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const request = [
|
||||
{
|
||||
log_source_name: 'CONCORD',
|
||||
request_time_ms: Date.now(),
|
||||
log_event: eventsToSend,
|
||||
},
|
||||
];
|
||||
const body = safeJsonStringify(request);
|
||||
const options = {
|
||||
hostname: 'play.googleapis.com',
|
||||
path: '/log',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Length': Buffer.byteLength(body) },
|
||||
};
|
||||
const bufs: Buffer[] = [];
|
||||
const req = https.request(
|
||||
{
|
||||
...options,
|
||||
agent: this.getProxyAgent(),
|
||||
},
|
||||
(res) => {
|
||||
res.on('data', (buf) => bufs.push(buf));
|
||||
res.on('end', () => {
|
||||
resolve(Buffer.concat(bufs));
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', (e) => {
|
||||
if (this.config?.getDebugMode()) {
|
||||
console.log('Clearcut POST request error: ', e);
|
||||
}
|
||||
// Add the events back to the front of the queue to be retried.
|
||||
this.events.unshift(...eventsToSend);
|
||||
reject(e);
|
||||
const flushFn = () =>
|
||||
new Promise<Buffer>((resolve, reject) => {
|
||||
const request = [
|
||||
{
|
||||
log_source_name: 'CONCORD',
|
||||
request_time_ms: Date.now(),
|
||||
log_event: eventsToSend,
|
||||
},
|
||||
];
|
||||
const body = safeJsonStringify(request);
|
||||
const options = {
|
||||
hostname: 'play.googleapis.com',
|
||||
path: '/log',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Length': Buffer.byteLength(body) },
|
||||
};
|
||||
const bufs: Buffer[] = [];
|
||||
const req = https.request(
|
||||
{
|
||||
...options,
|
||||
agent: this.getProxyAgent(),
|
||||
},
|
||||
(res) => {
|
||||
if (
|
||||
res.statusCode &&
|
||||
(res.statusCode < 200 || res.statusCode >= 300)
|
||||
) {
|
||||
const err: HttpError = new Error(
|
||||
`Request failed with status ${res.statusCode}`,
|
||||
);
|
||||
err.status = res.statusCode;
|
||||
res.resume();
|
||||
return reject(err);
|
||||
}
|
||||
res.on('data', (buf) => bufs.push(buf));
|
||||
res.on('end', () => resolve(Buffer.concat(bufs)));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
req.end(body);
|
||||
})
|
||||
.then((buf: Buffer) => {
|
||||
try {
|
||||
this.last_flush_time = Date.now();
|
||||
return this.decodeLogResponse(buf) || {};
|
||||
} catch (error: unknown) {
|
||||
console.error('Error flushing log events:', error);
|
||||
return {};
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
// Handle all errors to prevent unhandled promise rejections
|
||||
console.error('Error flushing log events:', error);
|
||||
// Return empty response to maintain the Promise<LogResponse> contract
|
||||
return {};
|
||||
|
||||
try {
|
||||
const responseBuffer = await retryWithBackoff(flushFn, {
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 200,
|
||||
shouldRetry: (err: unknown) => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const status = (err as HttpError).status as number | undefined;
|
||||
// If status is not available, it's likely a network error
|
||||
if (status === undefined) return true;
|
||||
|
||||
// Retry on 429 (Too many Requests) and 5xx server errors.
|
||||
return status === 429 || (status >= 500 && status < 600);
|
||||
},
|
||||
});
|
||||
|
||||
this.events.splice(0, eventsToSend.length);
|
||||
this.last_flush_time = Date.now();
|
||||
return this.decodeLogResponse(responseBuffer) || {};
|
||||
} catch (error) {
|
||||
if (this.config?.getDebugMode()) {
|
||||
console.error('Clearcut flush failed after multiple retries.', error);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Visible for testing. Decodes protobuf-encoded response from Clearcut server.
|
||||
@@ -215,7 +235,11 @@ export class ClearcutLogger {
|
||||
}
|
||||
|
||||
logStartSessionEvent(event: StartSessionEvent): void {
|
||||
const surface = process.env.SURFACE || 'SURFACE_NOT_SET';
|
||||
const surface =
|
||||
process.env.CLOUD_SHELL === 'true'
|
||||
? 'CLOUD_SHELL'
|
||||
: process.env.SURFACE || 'SURFACE_NOT_SET';
|
||||
|
||||
const data = [
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_MODEL,
|
||||
@@ -494,12 +518,20 @@ export class ClearcutLogger {
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logFlashDecidedToContinueEvent(event: FlashDecidedToContinueEvent): void {
|
||||
logNextSpeakerCheck(event: NextSpeakerCheckEvent): void {
|
||||
const data = [
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_PROMPT_ID,
|
||||
value: JSON.stringify(event.prompt_id),
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_RESPONSE_FINISH_REASON,
|
||||
value: JSON.stringify(event.finish_reason),
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_NEXT_SPEAKER_CHECK_RESULT,
|
||||
value: JSON.stringify(event.result),
|
||||
},
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SESSION_ID,
|
||||
value: this.config?.getSessionId() ?? '',
|
||||
@@ -507,7 +539,41 @@ export class ClearcutLogger {
|
||||
];
|
||||
|
||||
this.enqueueLogEvent(
|
||||
this.createLogEvent(flash_decided_to_continue_event_name, data),
|
||||
this.createLogEvent(next_speaker_check_event_name, data),
|
||||
);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logSlashCommandEvent(event: SlashCommandEvent): void {
|
||||
const data = [
|
||||
{
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SLASH_COMMAND_NAME,
|
||||
value: JSON.stringify(event.command),
|
||||
},
|
||||
];
|
||||
|
||||
if (event.subcommand) {
|
||||
data.push({
|
||||
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SLASH_COMMAND_SUBCOMMAND,
|
||||
value: JSON.stringify(event.subcommand),
|
||||
});
|
||||
}
|
||||
|
||||
this.enqueueLogEvent(this.createLogEvent(slash_command_event_name, data));
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
logMalformedJsonResponseEvent(event: MalformedJsonResponseEvent): void {
|
||||
const data = [
|
||||
{
|
||||
gemini_cli_key:
|
||||
EventMetadataKey.GEMINI_CLI_MALFORMED_JSON_RESPONSE_MODEL,
|
||||
value: JSON.stringify(event.model),
|
||||
},
|
||||
];
|
||||
|
||||
this.enqueueLogEvent(
|
||||
this.createLogEvent(malformed_json_response_event_name, data),
|
||||
);
|
||||
this.flushIfNeeded();
|
||||
}
|
||||
|
||||
@@ -163,6 +163,33 @@ export enum EventMetadataKey {
|
||||
|
||||
// Logs the type of loop detected.
|
||||
GEMINI_CLI_LOOP_DETECTED_TYPE = 38,
|
||||
|
||||
// ==========================================================================
|
||||
// Slash Command Event Keys
|
||||
// ===========================================================================
|
||||
|
||||
// Logs the name of the slash command.
|
||||
GEMINI_CLI_SLASH_COMMAND_NAME = 41,
|
||||
|
||||
// Logs the subcommand of the slash command.
|
||||
GEMINI_CLI_SLASH_COMMAND_SUBCOMMAND = 42,
|
||||
|
||||
// ==========================================================================
|
||||
// Next Speaker Check Event Keys
|
||||
// ===========================================================================
|
||||
|
||||
// Logs the finish reason of the previous streamGenerateContent response
|
||||
GEMINI_CLI_RESPONSE_FINISH_REASON = 43,
|
||||
|
||||
// Logs the result of the next speaker check
|
||||
GEMINI_CLI_NEXT_SPEAKER_CHECK_RESULT = 44,
|
||||
|
||||
// ==========================================================================
|
||||
// Malformed JSON Response Event Keys
|
||||
// ==========================================================================
|
||||
|
||||
// Logs the model that produced the malformed JSON response.
|
||||
GEMINI_CLI_MALFORMED_JSON_RESPONSE_MODEL = 45,
|
||||
}
|
||||
|
||||
export function getEventMetadataKey(
|
||||
|
||||
@@ -13,8 +13,9 @@ export const EVENT_API_ERROR = 'qwen-code.api_error';
|
||||
export const EVENT_API_RESPONSE = 'qwen-code.api_response';
|
||||
export const EVENT_CLI_CONFIG = 'qwen-code.config';
|
||||
export const EVENT_FLASH_FALLBACK = 'qwen-code.flash_fallback';
|
||||
export const EVENT_FLASH_DECIDED_TO_CONTINUE =
|
||||
'qwen-code.flash_decided_to_continue';
|
||||
export const EVENT_NEXT_SPEAKER_CHECK = 'qwen-code.next_speaker_check';
|
||||
export const EVENT_SLASH_COMMAND = 'qwen-code.slash_command';
|
||||
|
||||
export const METRIC_TOOL_CALL_COUNT = 'qwen-code.tool.call.count';
|
||||
export const METRIC_TOOL_CALL_LATENCY = 'qwen-code.tool.call.latency';
|
||||
export const METRIC_API_REQUEST_COUNT = 'qwen-code.api.request.count';
|
||||
|
||||
@@ -27,6 +27,7 @@ export {
|
||||
logApiError,
|
||||
logApiResponse,
|
||||
logFlashFallback,
|
||||
logSlashCommand,
|
||||
} from './loggers.js';
|
||||
export {
|
||||
StartSessionEvent,
|
||||
@@ -38,6 +39,7 @@ export {
|
||||
ApiResponseEvent,
|
||||
TelemetryEvent,
|
||||
FlashFallbackEvent,
|
||||
SlashCommandEvent,
|
||||
} from './types.js';
|
||||
export { SpanStatusCode, ValueType } from '@opentelemetry/api';
|
||||
export { SemanticAttributes } from '@opentelemetry/semantic-conventions';
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('Circular Reference Handling', () => {
|
||||
responseParts: [{ text: 'test result' }],
|
||||
resultDisplay: undefined,
|
||||
error: undefined, // undefined means success
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
const mockCompletedToolCall: CompletedToolCall = {
|
||||
@@ -100,6 +101,7 @@ describe('Circular Reference Handling', () => {
|
||||
responseParts: [{ text: 'test result' }],
|
||||
resultDisplay: undefined,
|
||||
error: undefined, // undefined means success
|
||||
errorType: undefined,
|
||||
};
|
||||
|
||||
const mockCompletedToolCall: CompletedToolCall = {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ErroredToolCall,
|
||||
GeminiClient,
|
||||
ToolConfirmationOutcome,
|
||||
ToolErrorType,
|
||||
ToolRegistry,
|
||||
} from '../index.js';
|
||||
import { logs } from '@opentelemetry/api-logs';
|
||||
@@ -448,6 +449,7 @@ describe('loggers', () => {
|
||||
responseParts: 'test-response',
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
tool: new EditTool(mockConfig),
|
||||
durationMs: 100,
|
||||
@@ -511,6 +513,7 @@ describe('loggers', () => {
|
||||
responseParts: 'test-response',
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
durationMs: 100,
|
||||
outcome: ToolConfirmationOutcome.Cancel,
|
||||
@@ -574,6 +577,7 @@ describe('loggers', () => {
|
||||
responseParts: 'test-response',
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
outcome: ToolConfirmationOutcome.ModifyWithEditor,
|
||||
tool: new EditTool(mockConfig),
|
||||
@@ -638,6 +642,7 @@ describe('loggers', () => {
|
||||
responseParts: 'test-response',
|
||||
resultDisplay: undefined,
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
},
|
||||
tool: new EditTool(mockConfig),
|
||||
durationMs: 100,
|
||||
@@ -703,6 +708,7 @@ describe('loggers', () => {
|
||||
name: 'test-error-type',
|
||||
message: 'test-error',
|
||||
},
|
||||
errorType: ToolErrorType.UNKNOWN,
|
||||
},
|
||||
durationMs: 100,
|
||||
};
|
||||
@@ -729,8 +735,8 @@ describe('loggers', () => {
|
||||
success: false,
|
||||
error: 'test-error',
|
||||
'error.message': 'test-error',
|
||||
error_type: 'test-error-type',
|
||||
'error.type': 'test-error-type',
|
||||
error_type: ToolErrorType.UNKNOWN,
|
||||
'error.type': ToolErrorType.UNKNOWN,
|
||||
prompt_id: 'prompt-id-5',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
EVENT_TOOL_CALL,
|
||||
EVENT_USER_PROMPT,
|
||||
EVENT_FLASH_FALLBACK,
|
||||
EVENT_FLASH_DECIDED_TO_CONTINUE,
|
||||
EVENT_NEXT_SPEAKER_CHECK,
|
||||
SERVICE_NAME,
|
||||
EVENT_SLASH_COMMAND,
|
||||
} from './constants.js';
|
||||
import {
|
||||
ApiErrorEvent,
|
||||
@@ -26,8 +27,9 @@ import {
|
||||
ToolCallEvent,
|
||||
UserPromptEvent,
|
||||
FlashFallbackEvent,
|
||||
FlashDecidedToContinueEvent,
|
||||
NextSpeakerCheckEvent,
|
||||
LoopDetectedEvent,
|
||||
SlashCommandEvent,
|
||||
} from './types.js';
|
||||
import {
|
||||
recordApiErrorMetrics,
|
||||
@@ -312,22 +314,43 @@ export function logLoopDetected(
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
export function logFlashDecidedToContinue(
|
||||
export function logNextSpeakerCheck(
|
||||
config: Config,
|
||||
event: FlashDecidedToContinueEvent,
|
||||
event: NextSpeakerCheckEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logFlashDecidedToContinueEvent(event);
|
||||
ClearcutLogger.getInstance(config)?.logNextSpeakerCheck(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
...getCommonAttributes(config),
|
||||
...event,
|
||||
'event.name': EVENT_FLASH_DECIDED_TO_CONTINUE,
|
||||
'event.name': EVENT_NEXT_SPEAKER_CHECK,
|
||||
};
|
||||
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: `Flash decided to continue.`,
|
||||
body: `Next speaker check.`,
|
||||
attributes,
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
}
|
||||
|
||||
export function logSlashCommand(
|
||||
config: Config,
|
||||
event: SlashCommandEvent,
|
||||
): void {
|
||||
ClearcutLogger.getInstance(config)?.logSlashCommandEvent(event);
|
||||
if (!isTelemetrySdkInitialized()) return;
|
||||
|
||||
const attributes: LogAttributes = {
|
||||
...getCommonAttributes(config),
|
||||
...event,
|
||||
'event.name': EVENT_SLASH_COMMAND,
|
||||
};
|
||||
|
||||
const logger = logs.getLogger(SERVICE_NAME);
|
||||
const logRecord: LogRecord = {
|
||||
body: `Slash command: ${event.command}.`,
|
||||
attributes,
|
||||
};
|
||||
logger.emit(logRecord);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './sdk.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { IdeClient } from '../ide/ide-client.js';
|
||||
|
||||
vi.mock('@opentelemetry/sdk-node');
|
||||
vi.mock('../config/config.js');
|
||||
@@ -29,6 +30,7 @@ describe('telemetry', () => {
|
||||
targetDir: '/test/dir',
|
||||
debugMode: false,
|
||||
cwd: '/test/dir',
|
||||
ideClient: IdeClient.getInstance(false),
|
||||
});
|
||||
vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(true);
|
||||
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
|
||||
|
||||
@@ -137,7 +137,7 @@ export class ToolCallEvent {
|
||||
? getDecisionFromOutcome(call.outcome)
|
||||
: undefined;
|
||||
this.error = call.response.error?.message;
|
||||
this.error_type = call.response.error?.name;
|
||||
this.error_type = call.response.errorType;
|
||||
this.prompt_id = call.request.prompt_id;
|
||||
}
|
||||
}
|
||||
@@ -266,15 +266,45 @@ export class LoopDetectedEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export class FlashDecidedToContinueEvent {
|
||||
'event.name': 'flash_decided_to_continue';
|
||||
export class NextSpeakerCheckEvent {
|
||||
'event.name': 'next_speaker_check';
|
||||
'event.timestamp': string; // ISO 8601
|
||||
prompt_id: string;
|
||||
finish_reason: string;
|
||||
result: string;
|
||||
|
||||
constructor(prompt_id: string) {
|
||||
this['event.name'] = 'flash_decided_to_continue';
|
||||
constructor(prompt_id: string, finish_reason: string, result: string) {
|
||||
this['event.name'] = 'next_speaker_check';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.prompt_id = prompt_id;
|
||||
this.finish_reason = finish_reason;
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
export class SlashCommandEvent {
|
||||
'event.name': 'slash_command';
|
||||
'event.timestamp': string; // ISO 8106
|
||||
command: string;
|
||||
subcommand?: string;
|
||||
|
||||
constructor(command: string, subcommand?: string) {
|
||||
this['event.name'] = 'slash_command';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.command = command;
|
||||
this.subcommand = subcommand;
|
||||
}
|
||||
}
|
||||
|
||||
export class MalformedJsonResponseEvent {
|
||||
'event.name': 'malformed_json_response';
|
||||
'event.timestamp': string; // ISO 8601
|
||||
model: string;
|
||||
|
||||
constructor(model: string) {
|
||||
this['event.name'] = 'malformed_json_response';
|
||||
this['event.timestamp'] = new Date().toISOString();
|
||||
this.model = model;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,4 +318,6 @@ export type TelemetryEvent =
|
||||
| ApiResponseEvent
|
||||
| FlashFallbackEvent
|
||||
| LoopDetectedEvent
|
||||
| FlashDecidedToContinueEvent;
|
||||
| NextSpeakerCheckEvent
|
||||
| SlashCommandEvent
|
||||
| MalformedJsonResponseEvent;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ErroredToolCall,
|
||||
SuccessfulToolCall,
|
||||
} from '../core/coreToolScheduler.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { Tool, ToolConfirmationOutcome } from '../tools/tools.js';
|
||||
|
||||
const createFakeCompletedToolCall = (
|
||||
@@ -54,6 +55,7 @@ const createFakeCompletedToolCall = (
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
resultDisplay: 'Success!',
|
||||
},
|
||||
durationMs: duration,
|
||||
@@ -73,6 +75,7 @@ const createFakeCompletedToolCall = (
|
||||
},
|
||||
},
|
||||
error: error || new Error('Tool failed'),
|
||||
errorType: ToolErrorType.UNKNOWN,
|
||||
resultDisplay: 'Failure!',
|
||||
},
|
||||
durationMs: duration,
|
||||
|
||||
Reference in New Issue
Block a user