feat: Add systemPromptMappings Configuration Feature (#108)

* feat: update system prompt for qwen3-coder

* feat: add default systemPromptMappings for Qwen models

- Add default systemPromptMappings configuration for qwen3-coder-plus model
- Support DashScope compatible mode API endpoints
- Include Qwen coder system prompt template with git repository and sandbox placeholders
- Add comprehensive test coverage for default and custom systemPromptMappings
- Update documentation to reflect the new default configuration behavior
- Ensure backward compatibility with existing user configurations

* feat: remove default system prompt template

* fix: test ci

* feat: handle code indentation issues

* feat: update prompt.test.snapshots

* feat: add URL trailing slash compatibility for system prompt mappings

- Add normalizeUrl() function to standardize URLs by removing trailing slashes
- Add urlMatches() function to compare URLs ignoring trailing slash differences
- Replace direct includes() comparison with urlMatches() for baseUrl matching
- Add comprehensive tests to verify URL matching with/without trailing slashes
- Fixes issue where URLs like 'https://api.example.com' and 'https://api.example.com/' were treated as different

* feat: update code
This commit is contained in:
pomelo
2025-07-29 13:11:41 +08:00
committed by GitHub
parent dc087deace
commit bd0d3479c1
10 changed files with 342 additions and 11 deletions

View File

@@ -154,6 +154,11 @@ export interface ConfigParameters {
temperature?: number;
max_tokens?: number;
};
systemPromptMappings?: Array<{
baseUrls?: string[];
modelNames?: string[];
template?: string;
}>;
}
export class Config {
@@ -204,6 +209,11 @@ export class Config {
temperature?: number;
max_tokens?: number;
};
private readonly systemPromptMappings?: Array<{
baseUrls?: string[];
modelNames?: string[];
template?: string;
}>;
private modelSwitchedDuringSession: boolean = false;
private readonly maxSessionTurns: number;
private readonly listExtensions: boolean;
@@ -258,6 +268,7 @@ export class Config {
this.ideMode = params.ideMode ?? false;
this.enableOpenAILogging = params.enableOpenAILogging ?? false;
this.sampling_params = params.sampling_params;
this.systemPromptMappings = params.systemPromptMappings;
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
@@ -540,6 +551,16 @@ export class Config {
return this.enableOpenAILogging;
}
getSystemPromptMappings():
| Array<{
baseUrls?: string[];
modelNames?: string[];
template?: string;
}>
| undefined {
return this.systemPromptMappings;
}
async refreshMemory(): Promise<{ memoryContent: string; fileCount: number }> {
const { memoryContent, fileCount } = await loadServerHierarchicalMemory(
this.getWorkingDir(),

View File

@@ -33,6 +33,9 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
// Reset mocks
vi.clearAllMocks();
// Mock environment variables
vi.stubEnv('OPENAI_BASE_URL', '');
// Mock config
mockConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
@@ -55,7 +58,7 @@ describe('OpenAIContentGenerator Timeout Handling', () => {
vi.mocked(OpenAI).mockImplementation(() => mockOpenAIClient);
// Create generator instance
generator = new OpenAIContentGenerator('test-api-key', 'gpt-4', mockConfig);
generator = new OpenAIContentGenerator('test-key', 'gpt-4', mockConfig);
});
afterEach(() => {

View File

@@ -198,6 +198,7 @@ describe('Gemini Client (client.ts)', () => {
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
setQuotaErrorOccurred: vi.fn(),
getNoBrowser: vi.fn().mockReturnValue(false),
getSystemPromptMappings: vi.fn().mockReturnValue(undefined),
};
return mock as unknown as Config;
});

View File

@@ -238,7 +238,10 @@ export class GeminiClient {
];
try {
const userMemory = this.config.getUserMemory();
const systemInstruction = getCoreSystemPrompt(userMemory);
const systemPromptMappings = this.config.getSystemPromptMappings();
const systemInstruction = getCoreSystemPrompt(userMemory, {
systemPromptMappings,
});
const generateContentConfigWithThinking = isThinkingSupported(
this.config.getModel(),
)
@@ -354,7 +357,10 @@ export class GeminiClient {
model || this.config.getModel() || DEFAULT_GEMINI_FLASH_MODEL;
try {
const userMemory = this.config.getUserMemory();
const systemInstruction = getCoreSystemPrompt(userMemory);
const systemPromptMappings = this.config.getSystemPromptMappings();
const systemInstruction = getCoreSystemPrompt(userMemory, {
systemPromptMappings,
});
const requestConfig = {
abortSignal,
...this.generateContentConfig,
@@ -470,7 +476,10 @@ export class GeminiClient {
try {
const userMemory = this.config.getUserMemory();
const systemInstruction = getCoreSystemPrompt(userMemory);
const systemPromptMappings = this.config.getSystemPromptMappings();
const systemInstruction = getCoreSystemPrompt(userMemory, {
systemPromptMappings,
});
const requestConfig = {
abortSignal,

View File

@@ -106,3 +106,96 @@ describe('Core System Prompt (prompts.ts)', () => {
expect(prompt).toMatchSnapshot();
});
});
describe('URL matching with trailing slash compatibility', () => {
it('should match URLs with and without trailing slash', () => {
const config = {
systemPromptMappings: [
{
baseUrls: ['https://api.example.com'],
modelNames: ['gpt-4'],
template: 'Custom template for example.com',
},
{
baseUrls: ['https://api.openai.com/'],
modelNames: ['gpt-3.5-turbo'],
template: 'Custom template for openai.com',
},
],
};
// Simulate environment variables
const originalEnv = process.env;
// Test case 1: No trailing slash in config, actual URL has trailing slash
process.env = {
...originalEnv,
OPENAI_BASE_URL: 'https://api.example.com/',
OPENAI_MODEL: 'gpt-4',
};
const result1 = getCoreSystemPrompt(undefined, config);
expect(result1).toContain('Custom template for example.com');
// Test case 2: Config has trailing slash, actual URL has no trailing slash
process.env = {
...originalEnv,
OPENAI_BASE_URL: 'https://api.openai.com',
OPENAI_MODEL: 'gpt-3.5-turbo',
};
const result2 = getCoreSystemPrompt(undefined, config);
expect(result2).toContain('Custom template for openai.com');
// Test case 3: No trailing slash in config, actual URL has no trailing slash
process.env = {
...originalEnv,
OPENAI_BASE_URL: 'https://api.example.com',
OPENAI_MODEL: 'gpt-4',
};
const result3 = getCoreSystemPrompt(undefined, config);
expect(result3).toContain('Custom template for example.com');
// Test case 4: Config has trailing slash, actual URL has trailing slash
process.env = {
...originalEnv,
OPENAI_BASE_URL: 'https://api.openai.com/',
OPENAI_MODEL: 'gpt-3.5-turbo',
};
const result4 = getCoreSystemPrompt(undefined, config);
expect(result4).toContain('Custom template for openai.com');
// Restore original environment variables
process.env = originalEnv;
});
it('should not match when URLs are different', () => {
const config = {
systemPromptMappings: [
{
baseUrls: ['https://api.example.com'],
modelNames: ['gpt-4'],
template: 'Custom template for example.com',
},
],
};
const originalEnv = process.env;
// Test case: URLs do not match
process.env = {
...originalEnv,
OPENAI_BASE_URL: 'https://api.different.com',
OPENAI_MODEL: 'gpt-4',
};
const result = getCoreSystemPrompt(undefined, config);
// Should return default template, not contain custom template
expect(result).not.toContain('Custom template for example.com');
// Restore original environment variables
process.env = originalEnv;
});
});

View File

@@ -18,7 +18,35 @@ import process from 'node:process';
import { isGitRepository } from '../utils/gitUtils.js';
import { MemoryTool, GEMINI_CONFIG_DIR } from '../tools/memoryTool.js';
export function getCoreSystemPrompt(userMemory?: string): string {
export interface ModelTemplateMapping {
baseUrls?: string[];
modelNames?: string[];
template?: string;
}
export interface SystemPromptConfig {
systemPromptMappings?: ModelTemplateMapping[];
}
/**
* Normalizes a URL by removing trailing slash for consistent comparison
*/
function normalizeUrl(url: string): string {
return url.endsWith('/') ? url.slice(0, -1) : url;
}
/**
* Checks if a URL matches any URL in the array, ignoring trailing slashes
*/
function urlMatches(urlArray: string[], targetUrl: string): boolean {
const normalizedTarget = normalizeUrl(targetUrl);
return urlArray.some((url) => normalizeUrl(url) === normalizedTarget);
}
export function getCoreSystemPrompt(
userMemory?: string,
config?: SystemPromptConfig,
): string {
// if GEMINI_SYSTEM_MD is set (and not 0|false), override system prompt from file
// default path is .qwen/system.md but can be modified via custom path in GEMINI_SYSTEM_MD
let systemMdEnabled = false;
@@ -34,6 +62,52 @@ export function getCoreSystemPrompt(userMemory?: string): string {
throw new Error(`missing system prompt file '${systemMdPath}'`);
}
}
// Check for system prompt mappings from global config
if (config?.systemPromptMappings) {
const currentModel = process.env.OPENAI_MODEL || '';
const currentBaseUrl = process.env.OPENAI_BASE_URL || '';
const matchedMapping = config.systemPromptMappings.find((mapping) => {
const { baseUrls, modelNames } = mapping;
// Check if baseUrl matches (when specified)
if (
baseUrls &&
modelNames &&
urlMatches(baseUrls, currentBaseUrl) &&
modelNames.includes(currentModel)
) {
return true;
}
if (baseUrls && urlMatches(baseUrls, currentBaseUrl) && !modelNames) {
return true;
}
if (modelNames && modelNames.includes(currentModel) && !baseUrls) {
return true;
}
return false;
});
if (matchedMapping?.template) {
const isGitRepo = isGitRepository(process.cwd());
// Replace placeholders in template
let template = matchedMapping.template;
template = template.replace(
'{RUNTIME_VARS_IS_GIT_REPO}',
String(isGitRepo),
);
template = template.replace(
'{RUNTIME_VARS_SANDBOX}',
process.env.SANDBOX || '',
);
return template;
}
}
const basePrompt = systemMdEnabled
? fs.readFileSync(systemMdPath, 'utf8')
: `
@@ -256,6 +330,7 @@ Your core function is efficient and safe assistance. Balance extreme conciseness
`.trim();
// if GEMINI_WRITE_SYSTEM_MD is set (and not 0|false), write base system prompt to file
const writeSystemMdVar = process.env.GEMINI_WRITE_SYSTEM_MD?.toLowerCase();
if (writeSystemMdVar && !['0', 'false'].includes(writeSystemMdVar)) {
if (['1', 'true'].includes(writeSystemMdVar)) {