mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 09:17:53 +00:00
Merge branch 'main' of github.com:QwenLM/qwen-code into fix/windows-startup-and-exit-hangs
This commit is contained in:
@@ -287,7 +287,7 @@ export interface ConfigParameters {
|
||||
contextFileName?: string | string[];
|
||||
accessibility?: AccessibilitySettings;
|
||||
telemetry?: TelemetrySettings;
|
||||
gitCoAuthor?: GitCoAuthorSettings;
|
||||
gitCoAuthor?: boolean;
|
||||
usageStatisticsEnabled?: boolean;
|
||||
fileFiltering?: {
|
||||
respectGitIgnore?: boolean;
|
||||
@@ -534,9 +534,9 @@ export class Config {
|
||||
useCollector: params.telemetry?.useCollector,
|
||||
};
|
||||
this.gitCoAuthor = {
|
||||
enabled: params.gitCoAuthor?.enabled ?? true,
|
||||
name: params.gitCoAuthor?.name ?? 'Qwen-Coder',
|
||||
email: params.gitCoAuthor?.email ?? 'qwen-coder@alibabacloud.com',
|
||||
enabled: params.gitCoAuthor ?? true,
|
||||
name: 'Qwen-Coder',
|
||||
email: 'qwen-coder@alibabacloud.com',
|
||||
};
|
||||
this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true;
|
||||
|
||||
@@ -741,9 +741,12 @@ export class Config {
|
||||
/**
|
||||
* Starts a new session and resets session-scoped services.
|
||||
*/
|
||||
startNewSession(sessionId?: string): string {
|
||||
startNewSession(
|
||||
sessionId?: string,
|
||||
sessionData?: ResumedSessionData,
|
||||
): string {
|
||||
this.sessionId = sessionId ?? randomUUID();
|
||||
this.sessionData = undefined;
|
||||
this.sessionData = sessionData;
|
||||
this.chatRecordingService = this.chatRecordingEnabled
|
||||
? new ChatRecordingService(this)
|
||||
: undefined;
|
||||
|
||||
@@ -76,6 +76,8 @@ export type ContentGeneratorConfig = {
|
||||
};
|
||||
proxy?: string | undefined;
|
||||
userAgent?: string;
|
||||
// Schema compliance mode for tool definitions
|
||||
schemaCompliance?: 'auto' | 'openapi_30';
|
||||
};
|
||||
|
||||
export function createContentGeneratorConfig(
|
||||
|
||||
@@ -22,6 +22,10 @@ import { GenerateContentResponse, FinishReason } from '@google/genai';
|
||||
import type OpenAI from 'openai';
|
||||
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
||||
import { StreamingToolCallParser } from './streamingToolCallParser.js';
|
||||
import {
|
||||
convertSchema,
|
||||
type SchemaComplianceMode,
|
||||
} from '../../utils/schemaConverter.js';
|
||||
|
||||
/**
|
||||
* Extended usage type that supports both OpenAI standard format and alternative formats
|
||||
@@ -80,11 +84,13 @@ interface ParsedParts {
|
||||
*/
|
||||
export class OpenAIContentConverter {
|
||||
private model: string;
|
||||
private schemaCompliance: SchemaComplianceMode;
|
||||
private streamingToolCallParser: StreamingToolCallParser =
|
||||
new StreamingToolCallParser();
|
||||
|
||||
constructor(model: string) {
|
||||
constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') {
|
||||
this.model = model;
|
||||
this.schemaCompliance = schemaCompliance;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,6 +211,10 @@ export class OpenAIContentConverter {
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters) {
|
||||
parameters = convertSchema(parameters, this.schemaCompliance);
|
||||
}
|
||||
|
||||
openAITools.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
|
||||
@@ -108,7 +108,10 @@ describe('ContentGenerationPipeline', () => {
|
||||
describe('constructor', () => {
|
||||
it('should initialize with correct configuration', () => {
|
||||
expect(mockProvider.buildClient).toHaveBeenCalled();
|
||||
expect(OpenAIContentConverter).toHaveBeenCalledWith('test-model');
|
||||
expect(OpenAIContentConverter).toHaveBeenCalledWith(
|
||||
'test-model',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export class ContentGenerationPipeline {
|
||||
this.client = this.config.provider.buildClient();
|
||||
this.converter = new OpenAIContentConverter(
|
||||
this.contentGeneratorConfig.model,
|
||||
this.contentGeneratorConfig.schemaCompliance,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -608,6 +608,36 @@ describe('ShellTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle git commit with combined short flags like -am', async () => {
|
||||
const command = 'git commit -am "Add feature"';
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify non-git commands', async () => {
|
||||
const command = 'npm install';
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
@@ -768,6 +798,69 @@ describe('ShellTool', () => {
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add co-author when git commit is prefixed with cd command', async () => {
|
||||
const command = 'cd /tmp/test && git commit -m "Test commit"';
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should add co-author to git commit with multi-line message', async () => {
|
||||
const command = `git commit -m "Fix bug
|
||||
|
||||
This is a detailed description
|
||||
spanning multiple lines"`;
|
||||
const invocation = shellTool.build({ command, is_background: false });
|
||||
const promise = invocation.execute(mockAbortSignal);
|
||||
|
||||
resolveExecutionPromise({
|
||||
rawOutput: Buffer.from(''),
|
||||
output: '',
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
aborted: false,
|
||||
pid: 12345,
|
||||
executionMethod: 'child_process',
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
||||
),
|
||||
expect.any(String),
|
||||
expect.any(Function),
|
||||
mockAbortSignal,
|
||||
false,
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -334,13 +334,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
private addCoAuthorToGitCommit(command: string): string {
|
||||
// Check if co-author feature is enabled
|
||||
const gitCoAuthorSettings = this.config.getGitCoAuthor();
|
||||
|
||||
if (!gitCoAuthorSettings.enabled) {
|
||||
return command;
|
||||
}
|
||||
|
||||
// Check if this is a git commit command
|
||||
const gitCommitPattern = /^git\s+commit/;
|
||||
if (!gitCommitPattern.test(command.trim())) {
|
||||
// Check if this is a git commit command (anywhere in the command, e.g., after "cd /path &&")
|
||||
const gitCommitPattern = /\bgit\s+commit\b/;
|
||||
if (!gitCommitPattern.test(command)) {
|
||||
return command;
|
||||
}
|
||||
|
||||
@@ -349,15 +350,27 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
|
||||
Co-authored-by: ${gitCoAuthorSettings.name} <${gitCoAuthorSettings.email}>`;
|
||||
|
||||
// Handle different git commit patterns
|
||||
// Match -m "message" or -m 'message'
|
||||
const messagePattern = /(-m\s+)(['"])((?:\\.|[^\\])*?)(\2)/;
|
||||
const match = command.match(messagePattern);
|
||||
// Handle different git commit patterns:
|
||||
// Match -m "message" or -m 'message', including combined flags like -am
|
||||
// Use separate patterns to avoid ReDoS (catastrophic backtracking)
|
||||
//
|
||||
// Pattern breakdown:
|
||||
// -[a-zA-Z]*m matches -m, -am, -nm, etc. (combined short flags)
|
||||
// \s+ matches whitespace after the flag
|
||||
// [^"\\] matches any char except double-quote and backslash
|
||||
// \\. matches escape sequences like \" or \\
|
||||
// (?:...|...)* matches normal chars or escapes, repeated
|
||||
const doubleQuotePattern = /(-[a-zA-Z]*m\s+)"((?:[^"\\]|\\.)*)"/;
|
||||
const singleQuotePattern = /(-[a-zA-Z]*m\s+)'((?:[^'\\]|\\.)*)'/;
|
||||
const doubleMatch = command.match(doubleQuotePattern);
|
||||
const singleMatch = command.match(singleQuotePattern);
|
||||
const match = doubleMatch ?? singleMatch;
|
||||
const quote = doubleMatch ? '"' : "'";
|
||||
|
||||
if (match) {
|
||||
const [fullMatch, prefix, quote, existingMessage, closingQuote] = match;
|
||||
const [fullMatch, prefix, existingMessage] = match;
|
||||
const newMessage = existingMessage + coAuthor;
|
||||
const replacement = prefix + quote + newMessage + closingQuote;
|
||||
const replacement = prefix + quote + newMessage + quote;
|
||||
|
||||
return command.replace(fullMatch, replacement);
|
||||
}
|
||||
|
||||
118
packages/core/src/utils/schemaConverter.test.ts
Normal file
118
packages/core/src/utils/schemaConverter.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { convertSchema } from './schemaConverter.js';
|
||||
|
||||
describe('convertSchema', () => {
|
||||
describe('mode: auto (default)', () => {
|
||||
it('should preserve type arrays', () => {
|
||||
const input = { type: ['string', 'null'] };
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
|
||||
it('should preserve items array (tuples)', () => {
|
||||
const input = {
|
||||
type: 'array',
|
||||
items: [{ type: 'string' }, { type: 'number' }],
|
||||
};
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
|
||||
it('should preserve mixed enums', () => {
|
||||
const input = { enum: [1, 2, '3'] };
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
|
||||
it('should preserve unsupported keywords', () => {
|
||||
const input = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
exclusiveMinimum: 10,
|
||||
type: 'number',
|
||||
};
|
||||
expect(convertSchema(input, 'auto')).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mode: openapi_30 (strict)', () => {
|
||||
it('should convert type arrays to nullable', () => {
|
||||
const input = { type: ['string', 'null'] };
|
||||
const expected = { type: 'string', nullable: true };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should fallback to first type for non-nullable arrays', () => {
|
||||
const input = { type: ['string', 'number'] };
|
||||
const expected = { type: 'string' };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should convert const to enum', () => {
|
||||
const input = { const: 'foo' };
|
||||
const expected = { enum: ['foo'] };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should convert exclusiveMinimum number to boolean', () => {
|
||||
const input = { type: 'number', exclusiveMinimum: 10 };
|
||||
const expected = {
|
||||
type: 'number',
|
||||
minimum: 10,
|
||||
exclusiveMinimum: true,
|
||||
};
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should convert nested objects recursively', () => {
|
||||
const input = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
prop1: { type: ['integer', 'null'], exclusiveMaximum: 5 },
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
prop1: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
maximum: 5,
|
||||
exclusiveMaximum: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should stringify enums', () => {
|
||||
const input = { enum: [1, 2, '3'] };
|
||||
const expected = { enum: ['1', '2', '3'] };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should remove tuple items (array of schemas)', () => {
|
||||
const input = {
|
||||
type: 'array',
|
||||
items: [{ type: 'string' }, { type: 'number' }],
|
||||
};
|
||||
const expected = { type: 'array' };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should remove unsupported keywords', () => {
|
||||
const input = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
$id: '#foo',
|
||||
type: 'string',
|
||||
default: 'bar',
|
||||
dependencies: { foo: ['bar'] },
|
||||
patternProperties: { '^foo': { type: 'string' } },
|
||||
};
|
||||
const expected = { type: 'string' };
|
||||
expect(convertSchema(input, 'openapi_30')).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
135
packages/core/src/utils/schemaConverter.ts
Normal file
135
packages/core/src/utils/schemaConverter.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility for converting JSON Schemas to be compatible with different LLM providers.
|
||||
* Specifically focuses on downgrading modern JSON Schema (Draft 7/2020-12) to
|
||||
* OpenAPI 3.0 compatible Schema Objects, which is required for Google Gemini API.
|
||||
*/
|
||||
|
||||
export type SchemaComplianceMode = 'auto' | 'openapi_30';
|
||||
|
||||
/**
|
||||
* Converts a JSON Schema to be compatible with the specified compliance mode.
|
||||
*/
|
||||
export function convertSchema(
|
||||
schema: Record<string, unknown>,
|
||||
mode: SchemaComplianceMode = 'auto',
|
||||
): Record<string, unknown> {
|
||||
if (mode === 'openapi_30') {
|
||||
return toOpenAPI30(schema);
|
||||
}
|
||||
|
||||
// Default ('auto') mode now does nothing.
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Modern JSON Schema to OpenAPI 3.0 Schema Object.
|
||||
* Attempts to preserve semantics where possible through transformations.
|
||||
*/
|
||||
function toOpenAPI30(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
const convert = (obj: unknown): unknown => {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(convert);
|
||||
}
|
||||
|
||||
const source = obj as Record<string, unknown>;
|
||||
const target: Record<string, unknown> = {};
|
||||
|
||||
// 1. Type Handling
|
||||
if (Array.isArray(source['type'])) {
|
||||
const types = source['type'] as string[];
|
||||
// Handle ["string", "null"] pattern common in modern schemas
|
||||
if (types.length === 2 && types.includes('null')) {
|
||||
target['type'] = types.find((t) => t !== 'null');
|
||||
target['nullable'] = true;
|
||||
} else {
|
||||
// Fallback for other unions: take the first non-null type
|
||||
// OpenAPI 3.0 doesn't support type arrays.
|
||||
// Ideal fix would be anyOf, but simple fallback is safer for now.
|
||||
target['type'] = types[0];
|
||||
}
|
||||
} else if (source['type'] !== undefined) {
|
||||
target['type'] = source['type'];
|
||||
}
|
||||
|
||||
// 2. Const Handling (Draft 6+) -> Enum (OpenAPI 3.0)
|
||||
if (source['const'] !== undefined) {
|
||||
target['enum'] = [source['const']];
|
||||
delete target['const'];
|
||||
}
|
||||
|
||||
// 3. Exclusive Limits (Draft 6+ number) -> (Draft 4 boolean)
|
||||
// exclusiveMinimum: 10 -> minimum: 10, exclusiveMinimum: true
|
||||
if (typeof source['exclusiveMinimum'] === 'number') {
|
||||
target['minimum'] = source['exclusiveMinimum'];
|
||||
target['exclusiveMinimum'] = true;
|
||||
}
|
||||
if (typeof source['exclusiveMaximum'] === 'number') {
|
||||
target['maximum'] = source['exclusiveMaximum'];
|
||||
target['exclusiveMaximum'] = true;
|
||||
}
|
||||
|
||||
// 4. Array Items (Tuple -> Single Schema)
|
||||
// OpenAPI 3.0 items must be a schema object, not an array of schemas
|
||||
if (Array.isArray(source['items'])) {
|
||||
// Tuple support is tricky.
|
||||
// Best effort: Use the first item's schema as a generic array type
|
||||
// or convert to an empty object (any type) if mixed.
|
||||
// For now, we'll strip it to allow validation to pass (accepts any items)
|
||||
// This matches the legacy behavior but is explicit.
|
||||
// Ideally, we could use `oneOf` on the items if we wanted to be stricter.
|
||||
delete target['items'];
|
||||
} else if (
|
||||
typeof source['items'] === 'object' &&
|
||||
source['items'] !== null
|
||||
) {
|
||||
target['items'] = convert(source['items']);
|
||||
}
|
||||
|
||||
// 5. Enum Stringification
|
||||
// Gemini strictly requires enums to be strings
|
||||
if (Array.isArray(source['enum'])) {
|
||||
target['enum'] = source['enum'].map(String);
|
||||
}
|
||||
|
||||
// 6. Recursively process other properties
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
// Skip fields we've already handled or want to remove
|
||||
if (
|
||||
key === 'type' ||
|
||||
key === 'const' ||
|
||||
key === 'exclusiveMinimum' ||
|
||||
key === 'exclusiveMaximum' ||
|
||||
key === 'items' ||
|
||||
key === 'enum' ||
|
||||
key === '$schema' ||
|
||||
key === '$id' ||
|
||||
key === 'default' || // Optional: Gemini sometimes complains about defaults conflicting with types
|
||||
key === 'dependencies' ||
|
||||
key === 'patternProperties'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
target[key] = convert(value);
|
||||
}
|
||||
|
||||
// Preserve default if it doesn't conflict (simple pass-through)
|
||||
// if (source['default'] !== undefined) {
|
||||
// target['default'] = source['default'];
|
||||
// }
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
return convert(schema) as Record<string, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user