@renxqoo/renx-code 0.0.7 → 0.0.9

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.
@@ -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 compatibility gateway', () => {
73
- expect(MODEL_CONFIGS['gpt-5.3'].baseURL).toBe('https://gmncode.cn/v1');
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(128 * 1000);
77
- expect(MODEL_CONFIGS['gpt-5.3'].LLMMAX_TOKENS).toBe(400 * 1000);
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 gmn-compatible defaults', () => {
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://gmncode.cn/v1');
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(128 * 1000);
145
- expect(provider.config.LLMMAX_TOKENS).toBe(400 * 1000);
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
- * 模型配置表(以模型 ID 为键,不包含 apiKey 的配置)
24
+ * Built-in model definitions.
11
25
  */
12
- export const MODEL_DEFINITIONS: Record<ModelId, Omit<ModelConfig, 'apiKey'>> = {
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://coding.dashscope.aliyuncs.com/v1',
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://coding.dashscope.aliyuncs.com/v1',
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://coding.dashscope.aliyuncs.com/v1',
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: 8000,
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://coding.dashscope.aliyuncs.com/v1',
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: 8000,
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://coding.dashscope.aliyuncs.com/v1',
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: 8000,
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://gmncode.cn/v1',
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: 128 * 1000,
195
- LLMMAX_TOKENS: 400 * 1000,
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://gmncode.cn/v1',
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: 10000,
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: 32 * 10,
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 { MODEL_DEFINITIONS } from './model-config';
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 = MODEL_DEFINITIONS[modelId];
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 = MODEL_DEFINITIONS[modelId];
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 = MODEL_DEFINITIONS[modelId];
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,