@renxqoo/renx-code 0.0.7 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/vendor/agent-root/src/config/__tests__/load-config-to-env.test.ts +109 -0
- package/vendor/agent-root/src/config/__tests__/loader.test.ts +114 -0
- package/vendor/agent-root/src/config/index.ts +1 -0
- package/vendor/agent-root/src/config/loader.ts +67 -4
- package/vendor/agent-root/src/config/types.ts +26 -0
- package/vendor/agent-root/src/providers/__tests__/registry.test.ts +82 -8
- package/vendor/agent-root/src/providers/index.ts +1 -1
- package/vendor/agent-root/src/providers/registry/model-config.ts +291 -44
- package/vendor/agent-root/src/providers/registry/provider-factory.ts +8 -4
- package/vendor/agent-root/src/providers/registry.ts +8 -8
- package/vendor/agent-root/src/providers/types/index.ts +1 -1
- package/vendor/agent-root/src/providers/types/registry.ts +10 -30
package/package.json
CHANGED
|
@@ -25,6 +25,7 @@ describe('loadConfigToEnv', () => {
|
|
|
25
25
|
delete process.env.AGENT_TOOL_CONFIRMATION_MODE;
|
|
26
26
|
delete process.env.AGENT_MODEL;
|
|
27
27
|
delete process.env.AGENT_MAX_STEPS;
|
|
28
|
+
delete process.env.RENX_CUSTOM_MODELS_JSON;
|
|
28
29
|
});
|
|
29
30
|
|
|
30
31
|
afterEach(() => {
|
|
@@ -126,4 +127,112 @@ describe('loadConfigToEnv', () => {
|
|
|
126
127
|
expect(process.env.AGENT_MODEL).toBe('qwen3.5-max');
|
|
127
128
|
expect(process.env.AGENT_MAX_STEPS).toBe('100');
|
|
128
129
|
});
|
|
130
|
+
|
|
131
|
+
it('should merge custom models into RENX_CUSTOM_MODELS_JSON', () => {
|
|
132
|
+
fs.mkdirSync(globalDir, { recursive: true });
|
|
133
|
+
fs.writeFileSync(
|
|
134
|
+
path.join(globalDir, 'config.json'),
|
|
135
|
+
JSON.stringify({
|
|
136
|
+
models: {
|
|
137
|
+
'shared-model': {
|
|
138
|
+
provider: 'openai',
|
|
139
|
+
name: 'Shared Model',
|
|
140
|
+
baseURL: 'https://global.example.com/v1',
|
|
141
|
+
endpointPath: '/chat/completions',
|
|
142
|
+
envApiKey: 'SHARED_API_KEY',
|
|
143
|
+
envBaseURL: 'SHARED_API_BASE',
|
|
144
|
+
model: 'shared-global',
|
|
145
|
+
max_tokens: 4096,
|
|
146
|
+
LLMMAX_TOKENS: 64000,
|
|
147
|
+
features: ['streaming'],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const projectConfigDir = path.join(tmpDir, '.renx');
|
|
154
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
155
|
+
fs.writeFileSync(
|
|
156
|
+
path.join(projectConfigDir, 'config.json'),
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
models: {
|
|
159
|
+
'shared-model': {
|
|
160
|
+
baseURL: 'https://project.example.com/v1',
|
|
161
|
+
model: 'shared-project',
|
|
162
|
+
},
|
|
163
|
+
'project-model': {
|
|
164
|
+
provider: 'openai',
|
|
165
|
+
name: 'Project Model',
|
|
166
|
+
baseURL: 'https://project-only.example.com/v1',
|
|
167
|
+
endpointPath: '/responses',
|
|
168
|
+
envApiKey: 'PROJECT_API_KEY',
|
|
169
|
+
envBaseURL: 'PROJECT_API_BASE',
|
|
170
|
+
model: 'project-model',
|
|
171
|
+
max_tokens: 8000,
|
|
172
|
+
LLMMAX_TOKENS: 128000,
|
|
173
|
+
features: ['streaming', 'function-calling'],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
loadConfigToEnv({ projectRoot: tmpDir, globalDir });
|
|
180
|
+
|
|
181
|
+
const models = JSON.parse(process.env.RENX_CUSTOM_MODELS_JSON ?? '{}') as Record<
|
|
182
|
+
string,
|
|
183
|
+
Record<string, unknown>
|
|
184
|
+
>;
|
|
185
|
+
|
|
186
|
+
expect(models['shared-model']).toMatchObject({
|
|
187
|
+
provider: 'openai',
|
|
188
|
+
baseURL: 'https://project.example.com/v1',
|
|
189
|
+
model: 'shared-project',
|
|
190
|
+
});
|
|
191
|
+
expect(models['project-model']).toMatchObject({
|
|
192
|
+
endpointPath: '/responses',
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should keep existing RENX_CUSTOM_MODELS_JSON values over config files', () => {
|
|
197
|
+
process.env.RENX_CUSTOM_MODELS_JSON = JSON.stringify({
|
|
198
|
+
'shared-model': {
|
|
199
|
+
baseURL: 'https://env.example.com/v1',
|
|
200
|
+
model: 'env-model',
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
fs.mkdirSync(globalDir, { recursive: true });
|
|
205
|
+
fs.writeFileSync(
|
|
206
|
+
path.join(globalDir, 'config.json'),
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
models: {
|
|
209
|
+
'shared-model': {
|
|
210
|
+
provider: 'openai',
|
|
211
|
+
name: 'Shared Model',
|
|
212
|
+
baseURL: 'https://global.example.com/v1',
|
|
213
|
+
endpointPath: '/chat/completions',
|
|
214
|
+
envApiKey: 'SHARED_API_KEY',
|
|
215
|
+
envBaseURL: 'SHARED_API_BASE',
|
|
216
|
+
model: 'shared-global',
|
|
217
|
+
max_tokens: 4096,
|
|
218
|
+
LLMMAX_TOKENS: 64000,
|
|
219
|
+
features: ['streaming'],
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
loadConfigToEnv({ projectRoot: tmpDir, globalDir });
|
|
226
|
+
|
|
227
|
+
const models = JSON.parse(process.env.RENX_CUSTOM_MODELS_JSON ?? '{}') as Record<
|
|
228
|
+
string,
|
|
229
|
+
Record<string, unknown>
|
|
230
|
+
>;
|
|
231
|
+
expect(models['shared-model']).toMatchObject({
|
|
232
|
+
baseURL: 'https://env.example.com/v1',
|
|
233
|
+
model: 'env-model',
|
|
234
|
+
provider: 'openai',
|
|
235
|
+
envApiKey: 'SHARED_API_KEY',
|
|
236
|
+
});
|
|
237
|
+
});
|
|
129
238
|
});
|
|
@@ -144,6 +144,74 @@ describe('Renx Config Loader', () => {
|
|
|
144
144
|
expect(config.sources.global).toBe(path.join(globalDir, 'config.json'));
|
|
145
145
|
expect(config.sources.project).toBe(path.join(projectConfigDir, 'config.json'));
|
|
146
146
|
});
|
|
147
|
+
|
|
148
|
+
it('should merge custom models from global and project config', () => {
|
|
149
|
+
fs.mkdirSync(globalDir, { recursive: true });
|
|
150
|
+
fs.writeFileSync(
|
|
151
|
+
path.join(globalDir, 'config.json'),
|
|
152
|
+
JSON.stringify({
|
|
153
|
+
models: {
|
|
154
|
+
'shared-model': {
|
|
155
|
+
provider: 'openai',
|
|
156
|
+
name: 'Shared Model',
|
|
157
|
+
baseURL: 'https://global.example.com/v1',
|
|
158
|
+
endpointPath: '/chat/completions',
|
|
159
|
+
envApiKey: 'SHARED_API_KEY',
|
|
160
|
+
envBaseURL: 'SHARED_API_BASE',
|
|
161
|
+
model: 'shared-global',
|
|
162
|
+
max_tokens: 4096,
|
|
163
|
+
LLMMAX_TOKENS: 32000,
|
|
164
|
+
features: ['streaming'],
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const projectConfigDir = path.join(tmpDir, '.renx');
|
|
171
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
172
|
+
fs.writeFileSync(
|
|
173
|
+
path.join(projectConfigDir, 'config.json'),
|
|
174
|
+
JSON.stringify({
|
|
175
|
+
models: {
|
|
176
|
+
'shared-model': {
|
|
177
|
+
baseURL: 'https://project.example.com/v1',
|
|
178
|
+
model: 'shared-project',
|
|
179
|
+
},
|
|
180
|
+
'project-model': {
|
|
181
|
+
provider: 'openai',
|
|
182
|
+
name: 'Project Model',
|
|
183
|
+
baseURL: 'https://project-only.example.com/v1',
|
|
184
|
+
endpointPath: '/responses',
|
|
185
|
+
envApiKey: 'PROJECT_API_KEY',
|
|
186
|
+
envBaseURL: 'PROJECT_API_BASE',
|
|
187
|
+
model: 'project-model',
|
|
188
|
+
max_tokens: 8000,
|
|
189
|
+
LLMMAX_TOKENS: 128000,
|
|
190
|
+
features: ['streaming', 'function-calling'],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const config = loadConfig({
|
|
197
|
+
projectRoot: tmpDir,
|
|
198
|
+
globalDir,
|
|
199
|
+
loadEnv: false,
|
|
200
|
+
env: { RENX_HOME: renxHome },
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(config.models['shared-model']).toMatchObject({
|
|
204
|
+
provider: 'openai',
|
|
205
|
+
name: 'Shared Model',
|
|
206
|
+
baseURL: 'https://project.example.com/v1',
|
|
207
|
+
model: 'shared-project',
|
|
208
|
+
envApiKey: 'SHARED_API_KEY',
|
|
209
|
+
});
|
|
210
|
+
expect(config.models['project-model']).toMatchObject({
|
|
211
|
+
provider: 'openai',
|
|
212
|
+
endpointPath: '/responses',
|
|
213
|
+
});
|
|
214
|
+
});
|
|
147
215
|
});
|
|
148
216
|
|
|
149
217
|
describe('loadConfig with env overrides', () => {
|
|
@@ -190,6 +258,52 @@ describe('Renx Config Loader', () => {
|
|
|
190
258
|
expect(config.log.format).toBe('json');
|
|
191
259
|
expect(config.agent.confirmationMode).toBe('auto-deny');
|
|
192
260
|
});
|
|
261
|
+
|
|
262
|
+
it('should let RENX_CUSTOM_MODELS_JSON override file-based model config', () => {
|
|
263
|
+
const projectConfigDir = path.join(tmpDir, '.renx');
|
|
264
|
+
fs.mkdirSync(projectConfigDir, { recursive: true });
|
|
265
|
+
fs.writeFileSync(
|
|
266
|
+
path.join(projectConfigDir, 'config.json'),
|
|
267
|
+
JSON.stringify({
|
|
268
|
+
models: {
|
|
269
|
+
'custom-openai': {
|
|
270
|
+
provider: 'openai',
|
|
271
|
+
name: 'Custom OpenAI',
|
|
272
|
+
baseURL: 'https://file.example.com/v1',
|
|
273
|
+
endpointPath: '/chat/completions',
|
|
274
|
+
envApiKey: 'CUSTOM_API_KEY',
|
|
275
|
+
envBaseURL: 'CUSTOM_API_BASE',
|
|
276
|
+
model: 'file-model',
|
|
277
|
+
max_tokens: 4096,
|
|
278
|
+
LLMMAX_TOKENS: 64000,
|
|
279
|
+
features: ['streaming'],
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const config = loadConfig({
|
|
286
|
+
projectRoot: tmpDir,
|
|
287
|
+
globalDir,
|
|
288
|
+
env: {
|
|
289
|
+
RENX_HOME: renxHome,
|
|
290
|
+
RENX_CUSTOM_MODELS_JSON: JSON.stringify({
|
|
291
|
+
'custom-openai': {
|
|
292
|
+
baseURL: 'https://env.example.com/v1',
|
|
293
|
+
model: 'env-model',
|
|
294
|
+
features: ['streaming', 'function-calling'],
|
|
295
|
+
},
|
|
296
|
+
}),
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(config.models['custom-openai']).toMatchObject({
|
|
301
|
+
provider: 'openai',
|
|
302
|
+
baseURL: 'https://env.example.com/v1',
|
|
303
|
+
model: 'env-model',
|
|
304
|
+
features: ['streaming', 'function-calling'],
|
|
305
|
+
});
|
|
306
|
+
});
|
|
193
307
|
});
|
|
194
308
|
|
|
195
309
|
describe('writeProjectConfig', () => {
|
|
@@ -7,10 +7,17 @@ import {
|
|
|
7
7
|
resolveRenxLogsDir,
|
|
8
8
|
resolveRenxStorageRoot,
|
|
9
9
|
} from './paths';
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
ConfigModelDefinition,
|
|
12
|
+
LoadConfigOptions,
|
|
13
|
+
LogConfig,
|
|
14
|
+
RenxConfig,
|
|
15
|
+
ResolvedConfig,
|
|
16
|
+
} from './types';
|
|
11
17
|
|
|
12
18
|
const PROJECT_DIR_NAME = '.renx';
|
|
13
19
|
const CONFIG_FILENAME = 'config.json';
|
|
20
|
+
const CUSTOM_MODELS_ENV_VAR = 'RENX_CUSTOM_MODELS_JSON';
|
|
14
21
|
|
|
15
22
|
const DEFAULTS: RenxConfig = {
|
|
16
23
|
log: {
|
|
@@ -57,6 +64,37 @@ function readJsonFile<T>(filePath: string): T | null {
|
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
68
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseCustomModelsEnv(
|
|
72
|
+
raw: string | undefined
|
|
73
|
+
): Record<string, ConfigModelDefinition> | null {
|
|
74
|
+
if (!raw) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
80
|
+
if (!isPlainObject(parsed)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const result: Record<string, ConfigModelDefinition> = {};
|
|
85
|
+
for (const [modelId, modelConfig] of Object.entries(parsed)) {
|
|
86
|
+
if (!isPlainObject(modelConfig)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
result[modelId] = modelConfig as ConfigModelDefinition;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return result;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
60
98
|
function parseLogLevelValue(raw: string | undefined): LogLevel | null {
|
|
61
99
|
if (!raw) {
|
|
62
100
|
return null;
|
|
@@ -242,6 +280,11 @@ function applyEnvOverrides(config: RenxConfig, env: NodeJS.ProcessEnv): RenxConf
|
|
|
242
280
|
}
|
|
243
281
|
}
|
|
244
282
|
|
|
283
|
+
const customModels = parseCustomModelsEnv(env[CUSTOM_MODELS_ENV_VAR]);
|
|
284
|
+
if (customModels) {
|
|
285
|
+
result.models = deepMerge(result.models ?? {}, customModels);
|
|
286
|
+
}
|
|
287
|
+
|
|
245
288
|
return result;
|
|
246
289
|
}
|
|
247
290
|
|
|
@@ -281,6 +324,7 @@ function resolveConfig(
|
|
|
281
324
|
confirmationMode: merged.agent?.confirmationMode ?? 'manual',
|
|
282
325
|
defaultModel: merged.agent?.defaultModel ?? 'qwen3.5-plus',
|
|
283
326
|
},
|
|
327
|
+
models: merged.models ?? {},
|
|
284
328
|
sources,
|
|
285
329
|
};
|
|
286
330
|
}
|
|
@@ -324,25 +368,30 @@ export function loadConfigToEnv(options: LoadConfigOptions = {}): string[] {
|
|
|
324
368
|
const loadedFiles: string[] = [];
|
|
325
369
|
|
|
326
370
|
const protectedEnvKeys = new Set(Object.keys(process.env));
|
|
371
|
+
const protectedCustomModels = parseCustomModelsEnv(process.env[CUSTOM_MODELS_ENV_VAR]) ?? {};
|
|
327
372
|
|
|
328
373
|
const globalConfigPath = ensureGlobalConfigFile(globalDir);
|
|
329
374
|
const globalConfig = readJsonFile<RenxConfig>(globalConfigPath);
|
|
330
375
|
if (globalConfig) {
|
|
331
|
-
applyConfigToEnv(globalConfig, protectedEnvKeys);
|
|
376
|
+
applyConfigToEnv(globalConfig, protectedEnvKeys, protectedCustomModels);
|
|
332
377
|
loadedFiles.push(globalConfigPath);
|
|
333
378
|
}
|
|
334
379
|
|
|
335
380
|
const projectConfigPath = path.join(projectRoot, PROJECT_DIR_NAME, CONFIG_FILENAME);
|
|
336
381
|
const projectConfig = readJsonFile<RenxConfig>(projectConfigPath);
|
|
337
382
|
if (projectConfig) {
|
|
338
|
-
applyConfigToEnv(projectConfig, protectedEnvKeys);
|
|
383
|
+
applyConfigToEnv(projectConfig, protectedEnvKeys, protectedCustomModels);
|
|
339
384
|
loadedFiles.push(projectConfigPath);
|
|
340
385
|
}
|
|
341
386
|
|
|
342
387
|
return loadedFiles;
|
|
343
388
|
}
|
|
344
389
|
|
|
345
|
-
function applyConfigToEnv(
|
|
390
|
+
function applyConfigToEnv(
|
|
391
|
+
config: RenxConfig,
|
|
392
|
+
protectedEnvKeys: Set<string>,
|
|
393
|
+
protectedCustomModels: Record<string, ConfigModelDefinition>
|
|
394
|
+
): void {
|
|
346
395
|
const setIfUnset = (key: string, value: string | undefined) => {
|
|
347
396
|
if (value !== undefined && !protectedEnvKeys.has(key)) {
|
|
348
397
|
process.env[key] = value;
|
|
@@ -397,6 +446,20 @@ function applyConfigToEnv(config: RenxConfig, protectedEnvKeys: Set<string>): vo
|
|
|
397
446
|
config.agent.maxSteps !== undefined ? String(config.agent.maxSteps) : undefined
|
|
398
447
|
);
|
|
399
448
|
}
|
|
449
|
+
|
|
450
|
+
if (config.models && Object.keys(config.models).length > 0) {
|
|
451
|
+
const existingModels = parseCustomModelsEnv(process.env[CUSTOM_MODELS_ENV_VAR]) ?? {};
|
|
452
|
+
const mergedModels = deepMerge(existingModels, config.models);
|
|
453
|
+
|
|
454
|
+
if (protectedEnvKeys.has(CUSTOM_MODELS_ENV_VAR)) {
|
|
455
|
+
process.env[CUSTOM_MODELS_ENV_VAR] = JSON.stringify(
|
|
456
|
+
deepMerge(mergedModels, protectedCustomModels)
|
|
457
|
+
);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
process.env[CUSTOM_MODELS_ENV_VAR] = JSON.stringify(mergedModels);
|
|
462
|
+
}
|
|
400
463
|
}
|
|
401
464
|
|
|
402
465
|
export function getGlobalConfigDir(): string {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LogLevel } from '../logger';
|
|
2
|
+
import type { ProviderType } from '../providers/types';
|
|
2
3
|
|
|
3
4
|
export interface LogConfig {
|
|
4
5
|
level?: 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL';
|
|
@@ -24,10 +25,34 @@ export interface AgentConfig {
|
|
|
24
25
|
defaultModel?: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
export interface ConfigModelDefinition {
|
|
29
|
+
provider?: ProviderType;
|
|
30
|
+
name?: string;
|
|
31
|
+
endpointPath?: string;
|
|
32
|
+
envApiKey?: string;
|
|
33
|
+
envBaseURL?: string;
|
|
34
|
+
baseURL?: string;
|
|
35
|
+
model?: string;
|
|
36
|
+
max_tokens?: number;
|
|
37
|
+
LLMMAX_TOKENS?: number;
|
|
38
|
+
features?: string[];
|
|
39
|
+
modalities?: {
|
|
40
|
+
image?: boolean;
|
|
41
|
+
audio?: boolean;
|
|
42
|
+
video?: boolean;
|
|
43
|
+
};
|
|
44
|
+
temperature?: number;
|
|
45
|
+
tool_stream?: boolean;
|
|
46
|
+
thinking?: boolean;
|
|
47
|
+
timeout?: number;
|
|
48
|
+
model_reasoning_effort?: 'low' | 'medium' | 'high';
|
|
49
|
+
}
|
|
50
|
+
|
|
27
51
|
export interface RenxConfig {
|
|
28
52
|
log?: LogConfig;
|
|
29
53
|
storage?: StorageConfig;
|
|
30
54
|
agent?: AgentConfig;
|
|
55
|
+
models?: Record<string, ConfigModelDefinition>;
|
|
31
56
|
}
|
|
32
57
|
|
|
33
58
|
export interface ResolvedConfig {
|
|
@@ -56,6 +81,7 @@ export interface ResolvedConfig {
|
|
|
56
81
|
confirmationMode: 'manual' | 'auto-approve' | 'auto-deny';
|
|
57
82
|
defaultModel: string;
|
|
58
83
|
};
|
|
84
|
+
models: Record<string, ConfigModelDefinition>;
|
|
59
85
|
sources: {
|
|
60
86
|
global: string | null;
|
|
61
87
|
project: string | null;
|
|
@@ -15,6 +15,7 @@ describe('ProviderRegistry', () => {
|
|
|
15
15
|
beforeEach(() => {
|
|
16
16
|
vi.resetModules();
|
|
17
17
|
process.env = { ...originalEnv };
|
|
18
|
+
delete process.env.RENX_CUSTOM_MODELS_JSON;
|
|
18
19
|
});
|
|
19
20
|
|
|
20
21
|
afterEach(() => {
|
|
@@ -69,12 +70,12 @@ describe('ProviderRegistry', () => {
|
|
|
69
70
|
expect(MODEL_CONFIGS['claude-opus-4.6'].endpointPath).toBe('/v1/messages');
|
|
70
71
|
});
|
|
71
72
|
|
|
72
|
-
it('should use responses endpoint for gpt-5.3
|
|
73
|
-
expect(MODEL_CONFIGS['gpt-5.3'].baseURL).toBe('https://
|
|
73
|
+
it('should use responses endpoint for gpt-5.3 official OpenAI API', () => {
|
|
74
|
+
expect(MODEL_CONFIGS['gpt-5.3'].baseURL).toBe('https://api.openai.com/v1');
|
|
74
75
|
expect(MODEL_CONFIGS['gpt-5.3'].endpointPath).toBe('/responses');
|
|
75
76
|
expect(MODEL_CONFIGS['gpt-5.3'].model).toBe('gpt-5.3-codex');
|
|
76
|
-
expect(MODEL_CONFIGS['gpt-5.3'].max_tokens).toBe(
|
|
77
|
-
expect(MODEL_CONFIGS['gpt-5.3'].LLMMAX_TOKENS).toBe(
|
|
77
|
+
expect(MODEL_CONFIGS['gpt-5.3'].max_tokens).toBe(1000 * 32);
|
|
78
|
+
expect(MODEL_CONFIGS['gpt-5.3'].LLMMAX_TOKENS).toBe(258 * 1000);
|
|
78
79
|
expect(MODEL_CONFIGS['gpt-5.3'].features).toContain('reasoning');
|
|
79
80
|
});
|
|
80
81
|
});
|
|
@@ -132,20 +133,45 @@ describe('ProviderRegistry', () => {
|
|
|
132
133
|
expect(provider.adapter).toBeInstanceOf(StandardAdapter);
|
|
133
134
|
});
|
|
134
135
|
|
|
135
|
-
it('should create gpt-5.3 provider with
|
|
136
|
+
it('should create gpt-5.3 provider with official OpenAI defaults', () => {
|
|
136
137
|
process.env.OPENAI_API_KEY = 'test-openai-key';
|
|
137
138
|
delete process.env.OPENAI_API_BASE;
|
|
138
139
|
|
|
139
140
|
const provider = ProviderRegistry.createFromEnv('gpt-5.3');
|
|
140
141
|
|
|
141
142
|
expect(provider.config.apiKey).toBe('test-openai-key');
|
|
142
|
-
expect(provider.config.baseURL).toBe('https://
|
|
143
|
+
expect(provider.config.baseURL).toBe('https://api.openai.com/v1');
|
|
143
144
|
expect(provider.config.model).toBe('gpt-5.3-codex');
|
|
144
|
-
expect(provider.config.max_tokens).toBe(
|
|
145
|
-
expect(provider.config.LLMMAX_TOKENS).toBe(
|
|
145
|
+
expect(provider.config.max_tokens).toBe(1000 * 32);
|
|
146
|
+
expect(provider.config.LLMMAX_TOKENS).toBe(258 * 1000);
|
|
146
147
|
expect(provider.adapter).toBeInstanceOf(ResponsesAdapter);
|
|
147
148
|
});
|
|
148
149
|
|
|
150
|
+
it('should create provider for a custom model from RENX_CUSTOM_MODELS_JSON', () => {
|
|
151
|
+
process.env.CUSTOM_OPENAI_API_KEY = 'custom-key';
|
|
152
|
+
process.env.RENX_CUSTOM_MODELS_JSON = JSON.stringify({
|
|
153
|
+
'custom-openai': {
|
|
154
|
+
provider: 'openai',
|
|
155
|
+
name: 'Custom OpenAI',
|
|
156
|
+
baseURL: 'https://custom.example.com/v1',
|
|
157
|
+
endpointPath: '/chat/completions',
|
|
158
|
+
envApiKey: 'CUSTOM_OPENAI_API_KEY',
|
|
159
|
+
envBaseURL: 'CUSTOM_OPENAI_API_BASE',
|
|
160
|
+
model: 'custom-model',
|
|
161
|
+
max_tokens: 4096,
|
|
162
|
+
LLMMAX_TOKENS: 64000,
|
|
163
|
+
features: ['streaming', 'function-calling'],
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const provider = ProviderRegistry.createFromEnv('custom-openai');
|
|
168
|
+
|
|
169
|
+
expect(provider.config.apiKey).toBe('custom-key');
|
|
170
|
+
expect(provider.config.baseURL).toBe('https://custom.example.com/v1');
|
|
171
|
+
expect(provider.config.model).toBe('custom-model');
|
|
172
|
+
expect(provider.adapter).toBeInstanceOf(StandardAdapter);
|
|
173
|
+
});
|
|
174
|
+
|
|
149
175
|
it('should use default baseURL when env var not set', () => {
|
|
150
176
|
process.env.GLM_API_KEY = 'test-key';
|
|
151
177
|
delete process.env.GLM_API_BASE;
|
|
@@ -164,6 +190,20 @@ describe('ProviderRegistry', () => {
|
|
|
164
190
|
expect(provider.config.baseURL).toBe('https://custom.example.com');
|
|
165
191
|
});
|
|
166
192
|
|
|
193
|
+
it('should allow custom model config to override a built-in baseURL', () => {
|
|
194
|
+
process.env.OPENAI_API_KEY = 'test-openai-key';
|
|
195
|
+
process.env.RENX_CUSTOM_MODELS_JSON = JSON.stringify({
|
|
196
|
+
'gpt-5.4': {
|
|
197
|
+
baseURL: 'https://proxy.example.com/v1',
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const provider = ProviderRegistry.createFromEnv('gpt-5.4');
|
|
202
|
+
|
|
203
|
+
expect(provider.config.baseURL).toBe('https://proxy.example.com/v1');
|
|
204
|
+
expect(provider.adapter).toBeInstanceOf(ResponsesAdapter);
|
|
205
|
+
});
|
|
206
|
+
|
|
167
207
|
it('should accept config overrides', () => {
|
|
168
208
|
process.env.GLM_API_KEY = 'test-key';
|
|
169
209
|
|
|
@@ -355,6 +395,25 @@ describe('ProviderRegistry', () => {
|
|
|
355
395
|
|
|
356
396
|
expect(Array.isArray(ids)).toBe(true);
|
|
357
397
|
});
|
|
398
|
+
|
|
399
|
+
it('should include custom model IDs from env config', () => {
|
|
400
|
+
process.env.RENX_CUSTOM_MODELS_JSON = JSON.stringify({
|
|
401
|
+
'custom-openai': {
|
|
402
|
+
provider: 'openai',
|
|
403
|
+
name: 'Custom OpenAI',
|
|
404
|
+
baseURL: 'https://custom.example.com/v1',
|
|
405
|
+
endpointPath: '/chat/completions',
|
|
406
|
+
envApiKey: 'CUSTOM_OPENAI_API_KEY',
|
|
407
|
+
envBaseURL: 'CUSTOM_OPENAI_API_BASE',
|
|
408
|
+
model: 'custom-model',
|
|
409
|
+
max_tokens: 4096,
|
|
410
|
+
LLMMAX_TOKENS: 64000,
|
|
411
|
+
features: ['streaming'],
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
expect(ProviderRegistry.getModelIds()).toContain('custom-openai');
|
|
416
|
+
});
|
|
358
417
|
});
|
|
359
418
|
|
|
360
419
|
describe('getModelConfig', () => {
|
|
@@ -379,6 +438,21 @@ describe('ProviderRegistry', () => {
|
|
|
379
438
|
ProviderRegistry.getModelConfig('unknown' as ModelId);
|
|
380
439
|
}).toThrow('Unknown model: unknown');
|
|
381
440
|
});
|
|
441
|
+
|
|
442
|
+
it('should reflect built-in overrides from custom env config', () => {
|
|
443
|
+
process.env.RENX_CUSTOM_MODELS_JSON = JSON.stringify({
|
|
444
|
+
'gpt-5.4': {
|
|
445
|
+
baseURL: 'https://proxy.example.com/v1',
|
|
446
|
+
name: 'GPT-5.4 Proxy',
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const config = ProviderRegistry.getModelConfig('gpt-5.4');
|
|
451
|
+
|
|
452
|
+
expect(config.baseURL).toBe('https://proxy.example.com/v1');
|
|
453
|
+
expect(config.name).toBe('GPT-5.4 Proxy');
|
|
454
|
+
expect(config.envApiKey).toBe('OPENAI_API_KEY');
|
|
455
|
+
});
|
|
382
456
|
});
|
|
383
457
|
|
|
384
458
|
describe('getModelName', () => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
// Registry 相关
|
|
6
6
|
export { Models, MODEL_CONFIGS, ProviderRegistry } from './registry';
|
|
7
|
-
export type { ProviderType, ModelId } from './registry';
|
|
7
|
+
export type { ProviderType, BuiltinModelId, ModelId } from './registry';
|
|
8
8
|
|
|
9
9
|
// Provider 相关
|
|
10
10
|
export { LLMProvider } from './types';
|
|
@@ -1,21 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* 集中管理所有模型的配置信息,可从外部加载
|
|
2
|
+
* Central model configuration storage.
|
|
5
3
|
*/
|
|
6
4
|
|
|
7
|
-
import type { ModelConfig, ModelId } from '../types';
|
|
5
|
+
import type { ModelConfig, ModelId, BuiltinModelId, ProviderType } from '../types';
|
|
6
|
+
|
|
7
|
+
const CUSTOM_MODELS_ENV_VAR = 'RENX_CUSTOM_MODELS_JSON';
|
|
8
|
+
|
|
9
|
+
export type ModelDefinition = Omit<ModelConfig, 'apiKey'>;
|
|
10
|
+
type PartialModelDefinition = Partial<ModelDefinition>;
|
|
11
|
+
|
|
12
|
+
const VALID_PROVIDERS: ProviderType[] = [
|
|
13
|
+
'anthropic',
|
|
14
|
+
'kimi',
|
|
15
|
+
'deepseek',
|
|
16
|
+
'glm',
|
|
17
|
+
'minimax',
|
|
18
|
+
'openai',
|
|
19
|
+
'openrouter',
|
|
20
|
+
'qwen',
|
|
21
|
+
];
|
|
8
22
|
|
|
9
23
|
/**
|
|
10
|
-
*
|
|
24
|
+
* Built-in model definitions.
|
|
11
25
|
*/
|
|
12
|
-
export const MODEL_DEFINITIONS: Record<
|
|
13
|
-
// Anthropic 系列
|
|
26
|
+
export const MODEL_DEFINITIONS: Record<BuiltinModelId, ModelDefinition> = {
|
|
14
27
|
'claude-opus-4.6': {
|
|
15
28
|
id: 'claude-opus-4.6',
|
|
16
29
|
provider: 'anthropic',
|
|
17
30
|
name: 'Claude Opus 4.6',
|
|
18
|
-
baseURL: '',
|
|
31
|
+
baseURL: 'https://api.anthropic.com',
|
|
19
32
|
endpointPath: '/v1/messages',
|
|
20
33
|
envApiKey: 'ANTHROPIC_API_KEY',
|
|
21
34
|
envBaseURL: 'ANTHROPIC_API_BASE',
|
|
@@ -25,8 +38,6 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
25
38
|
features: ['streaming', 'function-calling', 'vision'],
|
|
26
39
|
modalities: { image: true },
|
|
27
40
|
},
|
|
28
|
-
|
|
29
|
-
// GLM 系列
|
|
30
41
|
'glm-4.7': {
|
|
31
42
|
id: 'glm-4.7',
|
|
32
43
|
provider: 'glm',
|
|
@@ -41,7 +52,6 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
41
52
|
features: ['streaming', 'function-calling', 'vision'],
|
|
42
53
|
modalities: { image: true },
|
|
43
54
|
},
|
|
44
|
-
// GLM 系列
|
|
45
55
|
'glm-5': {
|
|
46
56
|
id: 'glm-5',
|
|
47
57
|
provider: 'glm',
|
|
@@ -56,7 +66,6 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
56
66
|
features: ['streaming', 'function-calling', 'vision'],
|
|
57
67
|
modalities: { image: true },
|
|
58
68
|
},
|
|
59
|
-
// MiniMax 系列
|
|
60
69
|
'minimax-2.5': {
|
|
61
70
|
id: 'minimax-2.5',
|
|
62
71
|
provider: 'minimax',
|
|
@@ -70,7 +79,6 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
70
79
|
LLMMAX_TOKENS: 200 * 1000,
|
|
71
80
|
features: ['streaming', 'function-calling'],
|
|
72
81
|
},
|
|
73
|
-
// Kimi 系列
|
|
74
82
|
'kimi-k2.5': {
|
|
75
83
|
id: 'kimi-k2.5',
|
|
76
84
|
provider: 'kimi',
|
|
@@ -86,7 +94,6 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
86
94
|
temperature: 0.6,
|
|
87
95
|
thinking: false,
|
|
88
96
|
},
|
|
89
|
-
// DeepSeek 系列
|
|
90
97
|
'deepseek-reasoner': {
|
|
91
98
|
id: 'deepseek-reasoner',
|
|
92
99
|
provider: 'deepseek',
|
|
@@ -100,12 +107,11 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
100
107
|
LLMMAX_TOKENS: 128 * 1000,
|
|
101
108
|
features: ['streaming', 'function-calling'],
|
|
102
109
|
},
|
|
103
|
-
// Qwen 系列
|
|
104
110
|
'qwen3.5-plus': {
|
|
105
111
|
id: 'qwen3.5-plus',
|
|
106
112
|
provider: 'qwen',
|
|
107
113
|
name: 'Qwen 3.5 Plus',
|
|
108
|
-
baseURL: 'https://
|
|
114
|
+
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
109
115
|
endpointPath: '/chat/completions',
|
|
110
116
|
envApiKey: 'QWEN_API_KEY',
|
|
111
117
|
envBaseURL: 'QWEN_API_BASE',
|
|
@@ -115,12 +121,11 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
115
121
|
features: ['streaming', 'function-calling'],
|
|
116
122
|
modalities: { image: true },
|
|
117
123
|
},
|
|
118
|
-
// Qwen 系列
|
|
119
124
|
'qwen3.5-max': {
|
|
120
125
|
id: 'qwen3.5-max',
|
|
121
126
|
provider: 'qwen',
|
|
122
127
|
name: 'Qwen 3.5 Max',
|
|
123
|
-
baseURL: 'https://
|
|
128
|
+
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
124
129
|
endpointPath: '/chat/completions',
|
|
125
130
|
envApiKey: 'QWEN_API_KEY',
|
|
126
131
|
envBaseURL: 'QWEN_API_BASE',
|
|
@@ -133,12 +138,12 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
133
138
|
id: 'qwen-kimi-k2.5',
|
|
134
139
|
provider: 'qwen',
|
|
135
140
|
name: 'qwen kimi k2.5',
|
|
136
|
-
baseURL: 'https://
|
|
141
|
+
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
137
142
|
endpointPath: '/chat/completions',
|
|
138
143
|
envApiKey: 'QWEN_API_KEY',
|
|
139
144
|
envBaseURL: 'QWEN_API_BASE',
|
|
140
145
|
model: 'kimi-k2.5',
|
|
141
|
-
max_tokens:
|
|
146
|
+
max_tokens: 1000 * 32,
|
|
142
147
|
LLMMAX_TOKENS: 200 * 1000,
|
|
143
148
|
features: ['streaming', 'function-calling'],
|
|
144
149
|
},
|
|
@@ -146,12 +151,12 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
146
151
|
id: 'qwen-glm-5',
|
|
147
152
|
provider: 'qwen',
|
|
148
153
|
name: 'Qwen GLM 5',
|
|
149
|
-
baseURL: 'https://
|
|
154
|
+
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
150
155
|
endpointPath: '/chat/completions',
|
|
151
156
|
envApiKey: 'QWEN_API_KEY',
|
|
152
157
|
envBaseURL: 'QWEN_API_BASE',
|
|
153
158
|
model: 'glm-5',
|
|
154
|
-
max_tokens:
|
|
159
|
+
max_tokens: 1000 * 32,
|
|
155
160
|
LLMMAX_TOKENS: 200 * 1000,
|
|
156
161
|
features: ['streaming', 'function-calling'],
|
|
157
162
|
},
|
|
@@ -159,40 +164,26 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
159
164
|
id: 'qwen-minimax-2.5',
|
|
160
165
|
provider: 'qwen',
|
|
161
166
|
name: 'Qwen MiniMax 2.5',
|
|
162
|
-
baseURL: 'https://
|
|
167
|
+
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
163
168
|
endpointPath: '/chat/completions',
|
|
164
169
|
envApiKey: 'QWEN_API_KEY',
|
|
165
170
|
envBaseURL: 'QWEN_API_BASE',
|
|
166
171
|
model: 'MiniMax-M2.5',
|
|
167
|
-
max_tokens:
|
|
172
|
+
max_tokens: 1000 * 32,
|
|
168
173
|
LLMMAX_TOKENS: 200 * 1000,
|
|
169
174
|
features: ['streaming', 'function-calling'],
|
|
170
175
|
},
|
|
171
|
-
// 'claude-4.6': {
|
|
172
|
-
// id: 'wr-claude-4.6',
|
|
173
|
-
// provider: 'openai',
|
|
174
|
-
// name: 'Claude Opus 4.6',
|
|
175
|
-
// baseURL: '',
|
|
176
|
-
// endpointPath: '/chat/completions',
|
|
177
|
-
// envApiKey: 'ANTHROPIC_API_KEY',
|
|
178
|
-
// envBaseURL: 'ANTHROPIC_API_BASE',
|
|
179
|
-
// model: 'claude-opus-4-6',
|
|
180
|
-
// max_tokens: 16384,
|
|
181
|
-
// LLMMAX_TOKENS: 1000 * 1000,
|
|
182
|
-
// features: ['streaming', 'function-calling', 'vision'],
|
|
183
|
-
// modalities: { image: true },
|
|
184
|
-
// },
|
|
185
176
|
'gpt-5.3': {
|
|
186
177
|
id: 'gpt-5.3',
|
|
187
178
|
provider: 'openai',
|
|
188
179
|
name: 'GPT-5.3',
|
|
189
|
-
baseURL: 'https://
|
|
180
|
+
baseURL: 'https://api.openai.com/v1',
|
|
190
181
|
endpointPath: '/responses',
|
|
191
182
|
envApiKey: 'OPENAI_API_KEY',
|
|
192
183
|
envBaseURL: 'OPENAI_API_BASE',
|
|
193
184
|
model: 'gpt-5.3-codex',
|
|
194
|
-
max_tokens:
|
|
195
|
-
LLMMAX_TOKENS:
|
|
185
|
+
max_tokens: 1000 * 32,
|
|
186
|
+
LLMMAX_TOKENS: 258 * 1000,
|
|
196
187
|
model_reasoning_effort: 'high',
|
|
197
188
|
features: ['streaming', 'function-calling', 'reasoning'],
|
|
198
189
|
modalities: { image: true },
|
|
@@ -201,12 +192,12 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
201
192
|
id: 'gpt-5.4',
|
|
202
193
|
provider: 'openai',
|
|
203
194
|
name: 'GPT-5.4',
|
|
204
|
-
baseURL: 'https://
|
|
195
|
+
baseURL: 'https://api.openai.com/v1',
|
|
205
196
|
endpointPath: '/responses',
|
|
206
197
|
envApiKey: 'OPENAI_API_KEY',
|
|
207
198
|
envBaseURL: 'OPENAI_API_BASE',
|
|
208
199
|
model: 'gpt-5.4',
|
|
209
|
-
max_tokens:
|
|
200
|
+
max_tokens: 1000 * 32,
|
|
210
201
|
LLMMAX_TOKENS: 200 * 1000,
|
|
211
202
|
model_reasoning_effort: 'high',
|
|
212
203
|
features: ['streaming', 'function-calling'],
|
|
@@ -221,10 +212,266 @@ export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
|
|
|
221
212
|
envApiKey: 'OPENROUTER_API_KEY',
|
|
222
213
|
envBaseURL: 'OPENROUTER_API_BASE',
|
|
223
214
|
model: 'openrouter/hunter-alpha',
|
|
224
|
-
max_tokens:
|
|
215
|
+
max_tokens: 1000 * 32,
|
|
225
216
|
LLMMAX_TOKENS: 200 * 1000,
|
|
226
217
|
model_reasoning_effort: 'high',
|
|
227
218
|
features: ['streaming', 'function-calling'],
|
|
228
219
|
modalities: { image: true },
|
|
229
220
|
},
|
|
230
221
|
};
|
|
222
|
+
|
|
223
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
224
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isValidProvider(value: unknown): value is ProviderType {
|
|
228
|
+
return typeof value === 'string' && VALID_PROVIDERS.includes(value as ProviderType);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isValidNumber(value: unknown): value is number {
|
|
232
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isValidReasoningEffort(
|
|
236
|
+
value: unknown
|
|
237
|
+
): value is NonNullable<ModelDefinition['model_reasoning_effort']> {
|
|
238
|
+
return value === 'low' || value === 'medium' || value === 'high';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function sanitizeModalities(value: unknown): ModelDefinition['modalities'] | undefined {
|
|
242
|
+
if (value === undefined) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!isPlainObject(value)) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const result: NonNullable<ModelDefinition['modalities']> = {};
|
|
251
|
+
|
|
252
|
+
if (typeof value.image === 'boolean') {
|
|
253
|
+
result.image = value.image;
|
|
254
|
+
}
|
|
255
|
+
if (typeof value.audio === 'boolean') {
|
|
256
|
+
result.audio = value.audio;
|
|
257
|
+
}
|
|
258
|
+
if (typeof value.video === 'boolean') {
|
|
259
|
+
result.video = value.video;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return Object.keys(result).length > 0 ? result : {};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function sanitizePartialModelDefinition(
|
|
266
|
+
modelId: string,
|
|
267
|
+
value: unknown
|
|
268
|
+
): PartialModelDefinition | null {
|
|
269
|
+
if (!isPlainObject(value)) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const sanitized: PartialModelDefinition = { id: modelId as ModelId };
|
|
274
|
+
|
|
275
|
+
if (value.provider !== undefined) {
|
|
276
|
+
if (!isValidProvider(value.provider)) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
sanitized.provider = value.provider;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (value.name !== undefined) {
|
|
283
|
+
if (typeof value.name !== 'string' || value.name.trim() === '') {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
sanitized.name = value.name;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (value.endpointPath !== undefined) {
|
|
290
|
+
if (typeof value.endpointPath !== 'string' || value.endpointPath.trim() === '') {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
sanitized.endpointPath = value.endpointPath;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (value.envApiKey !== undefined) {
|
|
297
|
+
if (typeof value.envApiKey !== 'string' || value.envApiKey.trim() === '') {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
sanitized.envApiKey = value.envApiKey;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (value.envBaseURL !== undefined) {
|
|
304
|
+
if (typeof value.envBaseURL !== 'string' || value.envBaseURL.trim() === '') {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
sanitized.envBaseURL = value.envBaseURL;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (value.baseURL !== undefined) {
|
|
311
|
+
if (typeof value.baseURL !== 'string' || value.baseURL.trim() === '') {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
sanitized.baseURL = value.baseURL;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (value.model !== undefined) {
|
|
318
|
+
if (typeof value.model !== 'string' || value.model.trim() === '') {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
sanitized.model = value.model;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (value.max_tokens !== undefined) {
|
|
325
|
+
if (!isValidNumber(value.max_tokens)) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
sanitized.max_tokens = value.max_tokens;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (value.LLMMAX_TOKENS !== undefined) {
|
|
332
|
+
if (!isValidNumber(value.LLMMAX_TOKENS)) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
sanitized.LLMMAX_TOKENS = value.LLMMAX_TOKENS;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (value.features !== undefined) {
|
|
339
|
+
if (!Array.isArray(value.features) || value.features.some((feature) => typeof feature !== 'string')) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
sanitized.features = [...value.features];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (value.modalities !== undefined) {
|
|
346
|
+
const modalities = sanitizeModalities(value.modalities);
|
|
347
|
+
if (modalities === undefined) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
sanitized.modalities = modalities;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (value.temperature !== undefined) {
|
|
354
|
+
if (typeof value.temperature !== 'number' || !Number.isFinite(value.temperature)) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
sanitized.temperature = value.temperature;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (value.tool_stream !== undefined) {
|
|
361
|
+
if (typeof value.tool_stream !== 'boolean') {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
sanitized.tool_stream = value.tool_stream;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (value.thinking !== undefined) {
|
|
368
|
+
if (typeof value.thinking !== 'boolean') {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
sanitized.thinking = value.thinking;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (value.timeout !== undefined) {
|
|
375
|
+
if (!isValidNumber(value.timeout)) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
sanitized.timeout = value.timeout;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (value.model_reasoning_effort !== undefined) {
|
|
382
|
+
if (!isValidReasoningEffort(value.model_reasoning_effort)) {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
sanitized.model_reasoning_effort = value.model_reasoning_effort;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return sanitized;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isCompleteModelDefinition(value: PartialModelDefinition): value is ModelDefinition {
|
|
392
|
+
return (
|
|
393
|
+
typeof value.id === 'string' &&
|
|
394
|
+
isValidProvider(value.provider) &&
|
|
395
|
+
typeof value.name === 'string' &&
|
|
396
|
+
typeof value.endpointPath === 'string' &&
|
|
397
|
+
typeof value.envApiKey === 'string' &&
|
|
398
|
+
typeof value.envBaseURL === 'string' &&
|
|
399
|
+
typeof value.baseURL === 'string' &&
|
|
400
|
+
typeof value.model === 'string' &&
|
|
401
|
+
isValidNumber(value.max_tokens) &&
|
|
402
|
+
isValidNumber(value.LLMMAX_TOKENS) &&
|
|
403
|
+
Array.isArray(value.features) &&
|
|
404
|
+
value.features.every((feature) => typeof feature === 'string')
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function mergeModelDefinition(
|
|
409
|
+
modelId: string,
|
|
410
|
+
base: PartialModelDefinition,
|
|
411
|
+
override: PartialModelDefinition
|
|
412
|
+
): ModelDefinition | null {
|
|
413
|
+
const merged: PartialModelDefinition = {
|
|
414
|
+
...base,
|
|
415
|
+
...override,
|
|
416
|
+
id: modelId as ModelId,
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
if (base.modalities || override.modalities) {
|
|
420
|
+
merged.modalities = {
|
|
421
|
+
...(base.modalities ?? {}),
|
|
422
|
+
...(override.modalities ?? {}),
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!isCompleteModelDefinition(merged)) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return merged;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function readCustomModelDefinitionsFromEnv(
|
|
434
|
+
env: NodeJS.ProcessEnv = process.env
|
|
435
|
+
): Record<string, PartialModelDefinition> {
|
|
436
|
+
const raw = env[CUSTOM_MODELS_ENV_VAR];
|
|
437
|
+
if (!raw) {
|
|
438
|
+
return {};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
443
|
+
if (!isPlainObject(parsed)) {
|
|
444
|
+
return {};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const result: Record<string, PartialModelDefinition> = {};
|
|
448
|
+
for (const [modelId, modelConfig] of Object.entries(parsed)) {
|
|
449
|
+
const sanitized = sanitizePartialModelDefinition(modelId, modelConfig);
|
|
450
|
+
if (sanitized) {
|
|
451
|
+
result[modelId] = sanitized;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return result;
|
|
456
|
+
} catch {
|
|
457
|
+
return {};
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function getResolvedModelDefinitions(
|
|
462
|
+
env: NodeJS.ProcessEnv = process.env
|
|
463
|
+
): Record<ModelId, ModelDefinition> {
|
|
464
|
+
const customDefinitions = readCustomModelDefinitionsFromEnv(env);
|
|
465
|
+
const resolved: Record<string, ModelDefinition> = { ...MODEL_DEFINITIONS };
|
|
466
|
+
|
|
467
|
+
for (const [modelId, customDefinition] of Object.entries(customDefinitions)) {
|
|
468
|
+
const baseDefinition = resolved[modelId] ?? ({ id: modelId as ModelId } as PartialModelDefinition);
|
|
469
|
+
const mergedDefinition = mergeModelDefinition(modelId, baseDefinition, customDefinition);
|
|
470
|
+
|
|
471
|
+
if (mergedDefinition) {
|
|
472
|
+
resolved[modelId] = mergedDefinition;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return resolved as Record<ModelId, ModelDefinition>;
|
|
477
|
+
}
|
|
@@ -9,7 +9,7 @@ import { AnthropicAdapter } from '../adapters/anthropic';
|
|
|
9
9
|
import { OpenAICompatibleProvider, OpenAICompatibleConfig } from '../openai-compatible';
|
|
10
10
|
import type { BaseProviderConfig, ModelId } from '../types';
|
|
11
11
|
import type { BaseAPIAdapter } from '../adapters/base';
|
|
12
|
-
import {
|
|
12
|
+
import { getResolvedModelDefinitions } from './model-config';
|
|
13
13
|
import { KimiAdapter } from '../adapters/kimi';
|
|
14
14
|
import { ResponsesAdapter } from '../adapters/responses';
|
|
15
15
|
|
|
@@ -32,7 +32,7 @@ export class ProviderFactory {
|
|
|
32
32
|
throw new Error('ModelId is required.');
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const modelConfig =
|
|
35
|
+
const modelConfig = getResolvedModelDefinitions()[modelId];
|
|
36
36
|
if (!modelConfig) {
|
|
37
37
|
throw new Error(`Unknown model: ${modelId}`);
|
|
38
38
|
}
|
|
@@ -73,7 +73,7 @@ export class ProviderFactory {
|
|
|
73
73
|
* @returns OpenAI Compatible Provider 实例
|
|
74
74
|
*/
|
|
75
75
|
static create(modelId: ModelId, config: BaseProviderConfig): OpenAICompatibleProvider {
|
|
76
|
-
const modelConfig =
|
|
76
|
+
const modelConfig = getResolvedModelDefinitions()[modelId];
|
|
77
77
|
if (!modelConfig) {
|
|
78
78
|
throw new Error(`Unknown model: ${modelId}`);
|
|
79
79
|
}
|
|
@@ -92,7 +92,11 @@ export class ProviderFactory {
|
|
|
92
92
|
modelId: ModelId,
|
|
93
93
|
logger?: OpenAICompatibleConfig['logger']
|
|
94
94
|
): BaseAPIAdapter {
|
|
95
|
-
const modelConfig =
|
|
95
|
+
const modelConfig = getResolvedModelDefinitions()[modelId];
|
|
96
|
+
if (!modelConfig) {
|
|
97
|
+
throw new Error(`Unknown model: ${modelId}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
96
100
|
if (modelConfig.provider === 'anthropic') {
|
|
97
101
|
return new AnthropicAdapter({
|
|
98
102
|
defaultModel: modelConfig.model,
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { ProviderFactory } from './registry/provider-factory';
|
|
9
|
-
import { MODEL_DEFINITIONS } from './registry/model-config';
|
|
9
|
+
import { getResolvedModelDefinitions, MODEL_DEFINITIONS } from './registry/model-config';
|
|
10
10
|
import type { ModelConfig, ModelId, ProviderType } from './types';
|
|
11
11
|
|
|
12
12
|
// 导出类型
|
|
13
|
-
export type { ProviderType, ModelId, ModelConfig } from './types';
|
|
13
|
+
export type { ProviderType, BuiltinModelId, ModelId, ModelConfig } from './types';
|
|
14
14
|
|
|
15
15
|
// 导出模型配置
|
|
16
16
|
export { MODEL_DEFINITIONS as MODEL_CONFIGS } from './registry/model-config';
|
|
@@ -47,7 +47,7 @@ export class ProviderRegistry {
|
|
|
47
47
|
* 获取所有模型配置
|
|
48
48
|
*/
|
|
49
49
|
static listModels(): ModelConfig[] {
|
|
50
|
-
return Object.values(
|
|
50
|
+
return Object.values(getResolvedModelDefinitions()).map((config) => ({
|
|
51
51
|
...config,
|
|
52
52
|
apiKey: undefined,
|
|
53
53
|
}));
|
|
@@ -57,7 +57,7 @@ export class ProviderRegistry {
|
|
|
57
57
|
* 获取指定厂商的所有模型
|
|
58
58
|
*/
|
|
59
59
|
static listModelsByProvider(provider: ProviderType): ModelConfig[] {
|
|
60
|
-
return Object.values(
|
|
60
|
+
return Object.values(getResolvedModelDefinitions())
|
|
61
61
|
.filter((m) => m.provider === provider)
|
|
62
62
|
.map((config) => ({
|
|
63
63
|
...config,
|
|
@@ -69,14 +69,14 @@ export class ProviderRegistry {
|
|
|
69
69
|
* 获取所有模型 ID
|
|
70
70
|
*/
|
|
71
71
|
static getModelIds(): ModelId[] {
|
|
72
|
-
return Object.keys(
|
|
72
|
+
return Object.keys(getResolvedModelDefinitions()) as ModelId[];
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* 获取指定模型的配置
|
|
77
77
|
*/
|
|
78
78
|
static getModelConfig(modelId: ModelId): ModelConfig {
|
|
79
|
-
const config =
|
|
79
|
+
const config = getResolvedModelDefinitions()[modelId];
|
|
80
80
|
if (!config) {
|
|
81
81
|
throw new Error(`Unknown model: ${modelId}`);
|
|
82
82
|
}
|
|
@@ -87,7 +87,7 @@ export class ProviderRegistry {
|
|
|
87
87
|
* 获取模型显示名称
|
|
88
88
|
*/
|
|
89
89
|
static getModelName(modelId: ModelId): string {
|
|
90
|
-
return
|
|
90
|
+
return getResolvedModelDefinitions()[modelId]?.name || modelId;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
@@ -95,7 +95,7 @@ export class ProviderRegistry {
|
|
|
95
95
|
*/
|
|
96
96
|
static getProviders(): ProviderType[] {
|
|
97
97
|
const providers = new Set<ProviderType>();
|
|
98
|
-
Object.values(
|
|
98
|
+
Object.values(getResolvedModelDefinitions()).forEach((m) => providers.add(m.provider));
|
|
99
99
|
return Array.from(providers);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Registry
|
|
3
|
-
*
|
|
4
|
-
* Provider Registry 相关的类型定义
|
|
2
|
+
* Registry related type definitions.
|
|
5
3
|
*/
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
|
-
*
|
|
6
|
+
* Supported provider types.
|
|
9
7
|
*/
|
|
10
8
|
export type ProviderType =
|
|
11
9
|
| 'anthropic'
|
|
@@ -18,22 +16,14 @@ export type ProviderType =
|
|
|
18
16
|
| 'qwen';
|
|
19
17
|
|
|
20
18
|
/**
|
|
21
|
-
*
|
|
19
|
+
* Built-in model IDs shipped with the CLI.
|
|
22
20
|
*/
|
|
23
|
-
export type
|
|
24
|
-
// Anthropic 系列
|
|
21
|
+
export type BuiltinModelId =
|
|
25
22
|
| 'claude-opus-4.6'
|
|
26
|
-
// GLM 系列
|
|
27
23
|
| 'glm-4.7'
|
|
28
|
-
// MiniMax 系列
|
|
29
24
|
| 'minimax-2.5'
|
|
30
|
-
// Kimi 系列
|
|
31
25
|
| 'kimi-k2.5'
|
|
32
|
-
// DeepSeek 系列
|
|
33
|
-
// | 'deepseek-chat'
|
|
34
|
-
// GLM 5.0 系列
|
|
35
26
|
| 'glm-5'
|
|
36
|
-
// Qwen 系列
|
|
37
27
|
| 'qwen3.5-plus'
|
|
38
28
|
| 'qwen-kimi-k2.5'
|
|
39
29
|
| 'qwen-glm-5'
|
|
@@ -45,42 +35,32 @@ export type ModelId =
|
|
|
45
35
|
| 'openrouter/hunter-alpha';
|
|
46
36
|
|
|
47
37
|
/**
|
|
48
|
-
*
|
|
38
|
+
* Model IDs can include built-ins and user-defined config.json entries.
|
|
39
|
+
*/
|
|
40
|
+
export type ModelId = BuiltinModelId | (string & {});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Model configuration.
|
|
49
44
|
*/
|
|
50
45
|
export interface ModelConfig {
|
|
51
|
-
/** 模型唯一标识 */
|
|
52
46
|
id: ModelId;
|
|
53
|
-
/** 所属厂商 */
|
|
54
47
|
provider: ProviderType;
|
|
55
|
-
/** 显示名称 */
|
|
56
48
|
name: string;
|
|
57
|
-
/** API 端点路径 */
|
|
58
49
|
endpointPath: string;
|
|
59
|
-
/** API Key 环境变量名 */
|
|
60
50
|
envApiKey: string;
|
|
61
|
-
/** Base URL 环境变量名 */
|
|
62
51
|
envBaseURL: string;
|
|
63
|
-
/** API 基础 URL */
|
|
64
52
|
baseURL: string;
|
|
65
|
-
/** API 模型名称 */
|
|
66
53
|
model: string;
|
|
67
|
-
/** 最大输出 token 数 */
|
|
68
54
|
max_tokens: number;
|
|
69
|
-
/** 最大上下文 token 数 */
|
|
70
55
|
LLMMAX_TOKENS: number;
|
|
71
|
-
/** 支持的特性 */
|
|
72
56
|
features: string[];
|
|
73
|
-
/** 多模态输入能力 */
|
|
74
57
|
modalities?: {
|
|
75
58
|
image?: boolean;
|
|
76
59
|
audio?: boolean;
|
|
77
60
|
video?: boolean;
|
|
78
61
|
};
|
|
79
|
-
/** API 密钥(可选) */
|
|
80
62
|
apiKey?: string;
|
|
81
|
-
/** 温度(可选) */
|
|
82
63
|
temperature?: number;
|
|
83
|
-
/** 默认工具流式输出(可选) */
|
|
84
64
|
tool_stream?: boolean;
|
|
85
65
|
thinking?: boolean;
|
|
86
66
|
timeout?: number;
|