@oh-my-pi/pi-coding-agent 12.10.1 → 12.11.0

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.
@@ -1,38 +1,25 @@
1
1
  import {
2
2
  type Api,
3
3
  type AssistantMessageEventStream,
4
- anthropicModelManagerOptions,
5
4
  type Context,
6
- cerebrasModelManagerOptions,
7
5
  createModelManager,
8
- cursorModelManagerOptions,
9
6
  getBundledModels,
10
7
  getBundledProviders,
11
8
  getGitHubCopilotBaseUrl,
12
- githubCopilotModelManagerOptions,
13
9
  googleAntigravityModelManagerOptions,
14
10
  googleGeminiCliModelManagerOptions,
15
- googleModelManagerOptions,
16
- groqModelManagerOptions,
17
- kimiCodeModelManagerOptions,
18
11
  type Model,
19
12
  type ModelManagerOptions,
20
- mistralModelManagerOptions,
21
13
  normalizeDomain,
22
14
  type OAuthCredentials,
23
15
  type OAuthLoginCallbacks,
24
16
  openaiCodexModelManagerOptions,
25
- openaiModelManagerOptions,
26
- opencodeModelManagerOptions,
27
- openrouterModelManagerOptions,
17
+ PROVIDER_DESCRIPTORS,
28
18
  registerCustomApi,
29
19
  registerOAuthProvider,
30
20
  type SimpleStreamOptions,
31
- syntheticModelManagerOptions,
32
21
  unregisterCustomApis,
33
22
  unregisterOAuthProviders,
34
- vercelAiGatewayModelManagerOptions,
35
- xaiModelManagerOptions,
36
23
  } from "@oh-my-pi/pi-ai";
37
24
  import { logger } from "@oh-my-pi/pi-utils";
38
25
  import { type Static, Type } from "@sinclair/typebox";
@@ -177,55 +164,104 @@ type ModelsConfig = Static<typeof ModelsConfigSchema>;
177
164
  type ProviderAuthMode = Static<typeof ProviderAuthSchema>;
178
165
  type ProviderDiscovery = Static<typeof ProviderDiscoverySchema>;
179
166
 
180
- export const ModelsConfigFile = new ConfigFile<ModelsConfig>("models", ModelsConfigSchema).withValidation(
181
- "models",
182
- config => {
183
- for (const [providerName, providerConfig] of Object.entries(config.providers)) {
184
- const hasProviderApi = !!providerConfig.api;
185
- const models = providerConfig.models ?? [];
186
-
187
- if (models.length === 0) {
188
- // Override-only config: needs baseUrl, modelOverrides, or discovery
189
- const hasModelOverrides =
190
- providerConfig.modelOverrides && Object.keys(providerConfig.modelOverrides).length > 0;
191
- if (!providerConfig.baseUrl && !hasModelOverrides && !providerConfig.discovery) {
192
- throw new Error(
193
- `Provider ${providerName}: must specify "baseUrl", "modelOverrides", "discovery", or "models".`,
194
- );
195
- }
196
- } else {
197
- // Full replacement: needs baseUrl and apiKey unless auth is disabled
198
- if (!providerConfig.baseUrl) {
199
- throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`);
200
- }
201
- if (!providerConfig.apiKey && providerConfig.auth !== "none") {
202
- throw new Error(
203
- `Provider ${providerName}: "apiKey" is required when defining custom models unless auth is "none".`,
204
- );
205
- }
206
- }
167
+ type ProviderValidationMode = "models-config" | "runtime-register";
207
168
 
208
- if (providerConfig.discovery && !providerConfig.api) {
209
- throw new Error(`Provider ${providerName}: "api" is required when discovery is enabled at provider level.`);
210
- }
169
+ interface ProviderValidationModel {
170
+ id: string;
171
+ api?: Api;
172
+ contextWindow?: number;
173
+ maxTokens?: number;
174
+ }
211
175
 
212
- for (const modelDef of models) {
213
- const hasModelApi = !!modelDef.api;
176
+ interface ProviderValidationConfig {
177
+ baseUrl?: string;
178
+ apiKey?: string;
179
+ api?: Api;
180
+ auth?: ProviderAuthMode;
181
+ oauthConfigured?: boolean;
182
+ discovery?: ProviderDiscovery;
183
+ modelOverrides?: Record<string, unknown>;
184
+ models: ProviderValidationModel[];
185
+ }
214
186
 
215
- if (!hasProviderApi && !hasModelApi) {
216
- throw new Error(
217
- `Provider ${providerName}, model ${modelDef.id}: no "api" specified. Set at provider or model level.`,
218
- );
219
- }
187
+ function validateProviderConfiguration(
188
+ providerName: string,
189
+ config: ProviderValidationConfig,
190
+ mode: ProviderValidationMode,
191
+ ): void {
192
+ const hasProviderApi = !!config.api;
193
+ const models = config.models;
194
+
195
+ if (models.length === 0) {
196
+ if (mode === "models-config") {
197
+ const hasModelOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0;
198
+ if (!config.baseUrl && !hasModelOverrides && !config.discovery) {
199
+ throw new Error(
200
+ `Provider ${providerName}: must specify "baseUrl", "modelOverrides", "discovery", or "models".`,
201
+ );
202
+ }
203
+ }
204
+ } else {
205
+ if (!config.baseUrl) {
206
+ throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`);
207
+ }
208
+ const requiresAuth =
209
+ mode === "runtime-register"
210
+ ? !config.apiKey && !config.oauthConfigured
211
+ : !config.apiKey && (config.auth ?? "apiKey") !== "none";
212
+ if (requiresAuth) {
213
+ throw new Error(
214
+ mode === "runtime-register"
215
+ ? `Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`
216
+ : `Provider ${providerName}: "apiKey" is required when defining custom models unless auth is "none".`,
217
+ );
218
+ }
219
+ }
220
+
221
+ if (mode === "models-config" && config.discovery && !config.api) {
222
+ throw new Error(`Provider ${providerName}: "api" is required when discovery is enabled at provider level.`);
223
+ }
220
224
 
221
- if (!modelDef.id) throw new Error(`Provider ${providerName}: model missing "id"`);
222
- // Validate contextWindow/maxTokens only if provided (they have defaults)
223
- if (modelDef.contextWindow !== undefined && modelDef.contextWindow <= 0)
224
- throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`);
225
- if (modelDef.maxTokens !== undefined && modelDef.maxTokens <= 0)
226
- throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`);
225
+ for (const modelDef of models) {
226
+ if (!hasProviderApi && !modelDef.api) {
227
+ throw new Error(
228
+ mode === "runtime-register"
229
+ ? `Provider ${providerName}, model ${modelDef.id}: no "api" specified.`
230
+ : `Provider ${providerName}, model ${modelDef.id}: no "api" specified. Set at provider or model level.`,
231
+ );
232
+ }
233
+ if (!modelDef.id) {
234
+ throw new Error(`Provider ${providerName}: model missing "id"`);
235
+ }
236
+ if (mode === "models-config") {
237
+ if (modelDef.contextWindow !== undefined && modelDef.contextWindow <= 0) {
238
+ throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`);
239
+ }
240
+ if (modelDef.maxTokens !== undefined && modelDef.maxTokens <= 0) {
241
+ throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`);
227
242
  }
228
243
  }
244
+ }
245
+ }
246
+
247
+ export const ModelsConfigFile = new ConfigFile<ModelsConfig>("models", ModelsConfigSchema).withValidation(
248
+ "models",
249
+ config => {
250
+ for (const [providerName, providerConfig] of Object.entries(config.providers)) {
251
+ validateProviderConfiguration(
252
+ providerName,
253
+ {
254
+ baseUrl: providerConfig.baseUrl,
255
+ apiKey: providerConfig.apiKey,
256
+ api: providerConfig.api as Api | undefined,
257
+ auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode,
258
+ discovery: providerConfig.discovery as ProviderDiscovery | undefined,
259
+ modelOverrides: providerConfig.modelOverrides,
260
+ models: (providerConfig.models ?? []) as ProviderValidationModel[],
261
+ },
262
+ "models-config",
263
+ );
264
+ }
229
265
  },
230
266
  );
231
267
 
@@ -356,6 +392,72 @@ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<A
356
392
  return result;
357
393
  }
358
394
 
395
+ interface CustomModelDefinitionLike {
396
+ id: string;
397
+ name?: string;
398
+ api?: Api;
399
+ reasoning?: boolean;
400
+ input?: ("text" | "image")[];
401
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
402
+ contextWindow?: number;
403
+ maxTokens?: number;
404
+ headers?: Record<string, string>;
405
+ compat?: Model<Api>["compat"];
406
+ contextPromotionTarget?: string;
407
+ }
408
+
409
+ interface CustomModelBuildOptions {
410
+ useDefaults: boolean;
411
+ }
412
+
413
+ function mergeCustomModelHeaders(
414
+ providerHeaders: Record<string, string> | undefined,
415
+ modelHeaders: Record<string, string> | undefined,
416
+ authHeader: boolean | undefined,
417
+ apiKeyConfig: string | undefined,
418
+ ): Record<string, string> | undefined {
419
+ let headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined;
420
+ if (authHeader && apiKeyConfig) {
421
+ const resolvedKey = resolveApiKeyConfig(apiKeyConfig);
422
+ if (resolvedKey) {
423
+ headers = { ...headers, Authorization: `Bearer ${resolvedKey}` };
424
+ }
425
+ }
426
+ return headers;
427
+ }
428
+
429
+ function buildCustomModel(
430
+ providerName: string,
431
+ providerBaseUrl: string,
432
+ providerApi: Api | undefined,
433
+ providerHeaders: Record<string, string> | undefined,
434
+ providerApiKey: string | undefined,
435
+ authHeader: boolean | undefined,
436
+ modelDef: CustomModelDefinitionLike,
437
+ options: CustomModelBuildOptions,
438
+ ): Model<Api> | undefined {
439
+ const api = modelDef.api ?? providerApi;
440
+ if (!api) return undefined;
441
+ const withDefaults = options.useDefaults;
442
+ const cost = modelDef.cost ?? (withDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
443
+ const input = modelDef.input ?? (withDefaults ? ["text"] : undefined);
444
+ return {
445
+ id: modelDef.id,
446
+ name: modelDef.name ?? (withDefaults ? modelDef.id : undefined),
447
+ api,
448
+ provider: providerName,
449
+ baseUrl: providerBaseUrl,
450
+ reasoning: modelDef.reasoning ?? (withDefaults ? false : undefined),
451
+ input: input as ("text" | "image")[],
452
+ cost,
453
+ contextWindow: modelDef.contextWindow ?? (withDefaults ? 128000 : undefined),
454
+ maxTokens: modelDef.maxTokens ?? (withDefaults ? 16384 : undefined),
455
+ headers: mergeCustomModelHeaders(providerHeaders, modelDef.headers, authHeader, providerApiKey),
456
+ compat: modelDef.compat,
457
+ contextPromotionTarget: modelDef.contextPromotionTarget,
458
+ } as Model<Api>;
459
+ }
460
+
359
461
  /**
360
462
  * Model registry - loads and manages models, resolves API keys via AuthStorage.
361
463
  */
@@ -620,7 +722,11 @@ export class ModelRegistry {
620
722
  }
621
723
 
622
724
  async #discoverBuiltInProviderModels(): Promise<Model<Api>[]> {
623
- const managerOptions = await this.#collectBuiltInModelManagerOptions();
725
+ // Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type)
726
+ const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(p => p.provider));
727
+ const managerOptions = (await this.#collectBuiltInModelManagerOptions()).filter(
728
+ opts => !configuredDiscoveryProviders.has(opts.providerId),
729
+ );
624
730
  if (managerOptions.length === 0) {
625
731
  return [];
626
732
  }
@@ -629,189 +735,77 @@ export class ModelRegistry {
629
735
  }
630
736
 
631
737
  async #collectBuiltInModelManagerOptions(): Promise<ModelManagerOptions<Api>[]> {
632
- const [
633
- anthropicApiKey,
634
- openaiApiKey,
635
- groqApiKey,
636
- cerebrasApiKey,
637
- xaiApiKey,
638
- mistralApiKey,
639
- opencodeApiKey,
640
- openrouterApiKey,
641
- vercelGatewayApiKey,
642
- kimiApiKey,
643
- syntheticApiKey,
644
- githubCopilotApiKey,
645
- googleApiKey,
646
- cursorApiKey,
647
- googleAntigravityApiKey,
648
- googleGeminiCliApiKey,
649
- codexAccessToken,
650
- ] = await Promise.all([
651
- this.getApiKeyForProvider("anthropic"),
652
- this.getApiKeyForProvider("openai"),
653
- this.getApiKeyForProvider("groq"),
654
- this.getApiKeyForProvider("cerebras"),
655
- this.getApiKeyForProvider("xai"),
656
- this.getApiKeyForProvider("mistral"),
657
- this.getApiKeyForProvider("opencode"),
658
- this.getApiKeyForProvider("openrouter"),
659
- this.getApiKeyForProvider("vercel-ai-gateway"),
660
- this.getApiKeyForProvider("kimi-code"),
661
- this.getApiKeyForProvider("synthetic"),
662
- this.getApiKeyForProvider("github-copilot"),
663
- this.getApiKeyForProvider("google"),
664
- this.getApiKeyForProvider("cursor"),
665
- this.getApiKeyForProvider("google-antigravity"),
666
- this.getApiKeyForProvider("google-gemini-cli"),
667
- this.getApiKeyForProvider("openai-codex"),
738
+ const specialProviderDescriptors: Array<{
739
+ providerId: string;
740
+ resolveKey: (value: string | undefined) => string | undefined;
741
+ createOptions: (key: string) => ModelManagerOptions<Api>;
742
+ }> = [
743
+ {
744
+ providerId: "google-antigravity",
745
+ resolveKey: extractGoogleOAuthToken,
746
+ createOptions: oauthToken =>
747
+ googleAntigravityModelManagerOptions({
748
+ oauthToken,
749
+ endpoint: this.getProviderBaseUrl("google-antigravity"),
750
+ }),
751
+ },
752
+ {
753
+ providerId: "google-gemini-cli",
754
+ resolveKey: extractGoogleOAuthToken,
755
+ createOptions: oauthToken =>
756
+ googleGeminiCliModelManagerOptions({
757
+ oauthToken,
758
+ endpoint: this.getProviderBaseUrl("google-gemini-cli"),
759
+ }),
760
+ },
761
+ {
762
+ providerId: "openai-codex",
763
+ resolveKey: value => value,
764
+ createOptions: accessToken => {
765
+ const accountId = resolveOAuthAccountIdForAccessToken(this.authStorage, "openai-codex", accessToken);
766
+ return openaiCodexModelManagerOptions({
767
+ accessToken,
768
+ accountId,
769
+ });
770
+ },
771
+ },
772
+ ];
773
+ const [standardProviderKeys, specialKeys] = await Promise.all([
774
+ Promise.all(PROVIDER_DESCRIPTORS.map(descriptor => this.getApiKeyForProvider(descriptor.providerId))),
775
+ Promise.all(specialProviderDescriptors.map(descriptor => this.getApiKeyForProvider(descriptor.providerId))),
668
776
  ]);
669
-
670
777
  const options: ModelManagerOptions<Api>[] = [];
671
- if (isAuthenticated(anthropicApiKey)) {
672
- options.push(
673
- anthropicModelManagerOptions({
674
- apiKey: anthropicApiKey,
675
- baseUrl: this.getProviderBaseUrl("anthropic"),
676
- }),
677
- );
678
- }
679
- if (isAuthenticated(openaiApiKey)) {
680
- options.push(
681
- openaiModelManagerOptions({
682
- apiKey: openaiApiKey,
683
- baseUrl: this.getProviderBaseUrl("openai"),
684
- }),
685
- );
686
- }
687
- if (isAuthenticated(groqApiKey)) {
688
- options.push(
689
- groqModelManagerOptions({
690
- apiKey: groqApiKey,
691
- baseUrl: this.getProviderBaseUrl("groq"),
692
- }),
693
- );
694
- }
695
- if (isAuthenticated(cerebrasApiKey)) {
696
- options.push(
697
- cerebrasModelManagerOptions({
698
- apiKey: cerebrasApiKey,
699
- baseUrl: this.getProviderBaseUrl("cerebras"),
700
- }),
701
- );
702
- }
703
- if (isAuthenticated(xaiApiKey)) {
704
- options.push(
705
- xaiModelManagerOptions({
706
- apiKey: xaiApiKey,
707
- baseUrl: this.getProviderBaseUrl("xai"),
708
- }),
709
- );
710
- }
711
- if (isAuthenticated(mistralApiKey)) {
712
- options.push(
713
- mistralModelManagerOptions({
714
- apiKey: mistralApiKey,
715
- baseUrl: this.getProviderBaseUrl("mistral"),
716
- }),
717
- );
718
- }
719
- if (isAuthenticated(opencodeApiKey)) {
720
- options.push(
721
- opencodeModelManagerOptions({
722
- apiKey: opencodeApiKey,
723
- baseUrl: this.getProviderBaseUrl("opencode"),
724
- }),
725
- );
726
- }
727
- if (isAuthenticated(openrouterApiKey)) {
728
- options.push(
729
- openrouterModelManagerOptions({
730
- apiKey: openrouterApiKey,
731
- baseUrl: this.getProviderBaseUrl("openrouter"),
732
- }),
733
- );
734
- }
735
- if (isAuthenticated(vercelGatewayApiKey)) {
736
- options.push(
737
- vercelAiGatewayModelManagerOptions({
738
- apiKey: vercelGatewayApiKey,
739
- baseUrl: this.getProviderBaseUrl("vercel-ai-gateway"),
740
- }),
741
- );
742
- }
743
- if (isAuthenticated(kimiApiKey)) {
744
- options.push(
745
- kimiCodeModelManagerOptions({
746
- apiKey: kimiApiKey,
747
- baseUrl: this.getProviderBaseUrl("kimi-code"),
748
- }),
749
- );
750
- }
751
- if (isAuthenticated(syntheticApiKey)) {
752
- options.push(
753
- syntheticModelManagerOptions({
754
- apiKey: syntheticApiKey,
755
- baseUrl: this.getProviderBaseUrl("synthetic"),
756
- }),
757
- );
758
- }
759
- if (isAuthenticated(githubCopilotApiKey)) {
760
- options.push(
761
- githubCopilotModelManagerOptions({
762
- apiKey: githubCopilotApiKey,
763
- baseUrl: this.getProviderBaseUrl("github-copilot"),
764
- }),
765
- );
766
- }
767
- if (isAuthenticated(googleApiKey)) options.push(googleModelManagerOptions({ apiKey: googleApiKey }));
768
- if (isAuthenticated(cursorApiKey)) {
769
- options.push(
770
- cursorModelManagerOptions({
771
- apiKey: cursorApiKey,
772
- baseUrl: this.getProviderBaseUrl("cursor"),
773
- }),
774
- );
775
- }
776
-
777
- const antigravityToken = extractGoogleOAuthToken(googleAntigravityApiKey);
778
- if (isAuthenticated(antigravityToken)) {
779
- options.push(
780
- googleAntigravityModelManagerOptions({
781
- oauthToken: antigravityToken,
782
- endpoint: this.getProviderBaseUrl("google-antigravity"),
783
- }),
784
- );
785
- }
786
-
787
- const geminiCliToken = extractGoogleOAuthToken(googleGeminiCliApiKey);
788
- if (isAuthenticated(geminiCliToken)) {
789
- options.push(
790
- googleGeminiCliModelManagerOptions({
791
- oauthToken: geminiCliToken,
792
- endpoint: this.getProviderBaseUrl("google-gemini-cli"),
793
- }),
794
- );
778
+ for (let i = 0; i < PROVIDER_DESCRIPTORS.length; i++) {
779
+ const descriptor = PROVIDER_DESCRIPTORS[i];
780
+ const apiKey = standardProviderKeys[i];
781
+ if (isAuthenticated(apiKey) || descriptor.allowUnauthenticated) {
782
+ options.push(
783
+ descriptor.createModelManagerOptions({
784
+ apiKey: isAuthenticated(apiKey) ? apiKey : undefined,
785
+ baseUrl: this.getProviderBaseUrl(descriptor.providerId),
786
+ }),
787
+ );
788
+ }
795
789
  }
796
790
 
797
- if (isAuthenticated(codexAccessToken)) {
798
- const codexAccountId = resolveOAuthAccountIdForAccessToken(this.authStorage, "openai-codex", codexAccessToken);
799
- options.push(
800
- openaiCodexModelManagerOptions({
801
- accessToken: codexAccessToken,
802
- accountId: codexAccountId,
803
- }),
804
- );
791
+ for (let i = 0; i < specialProviderDescriptors.length; i++) {
792
+ const descriptor = specialProviderDescriptors[i];
793
+ const key = descriptor.resolveKey(specialKeys[i]);
794
+ if (!isAuthenticated(key)) {
795
+ continue;
796
+ }
797
+ options.push(descriptor.createOptions(key));
805
798
  }
806
-
807
799
  return options;
808
800
  }
809
801
 
810
802
  async #discoverWithModelManager(options: ModelManagerOptions<Api>): Promise<Model<Api>[]> {
811
803
  try {
812
804
  const manager = createModelManager(options);
813
- const result = await manager.refresh("online");
814
- return result.models;
805
+ const result = await manager.refresh();
806
+ return result.models.map(model =>
807
+ model.provider === options.providerId ? model : { ...model, provider: options.providerId },
808
+ );
815
809
  } catch (error) {
816
810
  logger.warn("model discovery failed for provider", {
817
811
  provider: options.providerId,
@@ -905,51 +899,24 @@ export class ModelRegistry {
905
899
  for (const [providerName, providerConfig] of Object.entries(config.providers)) {
906
900
  const modelDefs = providerConfig.models ?? [];
907
901
  if (modelDefs.length === 0) continue; // Override-only, no custom models
908
-
909
- // Store API key config for fallback resolver
910
902
  if (providerConfig.apiKey) {
911
903
  this.#customProviderApiKeys.set(providerName, providerConfig.apiKey);
912
904
  }
913
-
914
905
  for (const modelDef of modelDefs) {
915
- const api = modelDef.api || providerConfig.api;
916
- if (!api) continue;
917
-
918
- // Merge headers: provider headers are base, model headers override
919
- let headers =
920
- providerConfig.headers || modelDef.headers
921
- ? { ...providerConfig.headers, ...modelDef.headers }
922
- : undefined;
923
-
924
- // If authHeader is true, add Authorization header with resolved API key
925
- if (providerConfig.authHeader && providerConfig.apiKey) {
926
- const resolvedKey = resolveApiKeyConfig(providerConfig.apiKey);
927
- if (resolvedKey) {
928
- headers = { ...headers, Authorization: `Bearer ${resolvedKey}` };
929
- }
930
- }
931
-
932
- // baseUrl is validated to exist for providers with models
933
- // Apply defaults for optional fields
934
- const defaultCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
935
- models.push({
936
- id: modelDef.id,
937
- name: modelDef.name ?? modelDef.id,
938
- api: api as Api,
939
- provider: providerName,
940
- baseUrl: providerConfig.baseUrl!,
941
- reasoning: modelDef.reasoning ?? false,
942
- input: (modelDef.input ?? ["text"]) as ("text" | "image")[],
943
- cost: modelDef.cost ?? defaultCost,
944
- contextWindow: modelDef.contextWindow ?? 128000,
945
- maxTokens: modelDef.maxTokens ?? 16384,
946
- headers,
947
- compat: modelDef.compat,
948
- contextPromotionTarget: modelDef.contextPromotionTarget,
949
- } as Model<Api>);
906
+ const model = buildCustomModel(
907
+ providerName,
908
+ providerConfig.baseUrl!,
909
+ providerConfig.api as Api | undefined,
910
+ providerConfig.headers,
911
+ providerConfig.apiKey,
912
+ providerConfig.authHeader,
913
+ modelDef,
914
+ { useDefaults: true },
915
+ );
916
+ if (!model) continue;
917
+ models.push(model);
950
918
  }
951
919
  }
952
-
953
920
  return models;
954
921
  }
955
922
 
@@ -1045,20 +1012,17 @@ export class ModelRegistry {
1045
1012
  throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`);
1046
1013
  }
1047
1014
 
1048
- if (config.models && config.models.length > 0) {
1049
- if (!config.baseUrl) {
1050
- throw new Error(`Provider ${providerName}: "baseUrl" is required when defining models.`);
1051
- }
1052
- if (!config.apiKey && !config.oauth) {
1053
- throw new Error(`Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`);
1054
- }
1055
- for (const modelDef of config.models) {
1056
- const api = modelDef.api || config.api;
1057
- if (!api) {
1058
- throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
1059
- }
1060
- }
1061
- }
1015
+ validateProviderConfiguration(
1016
+ providerName,
1017
+ {
1018
+ baseUrl: config.baseUrl,
1019
+ apiKey: config.apiKey,
1020
+ api: config.api,
1021
+ oauthConfigured: Boolean(config.oauth),
1022
+ models: (config.models ?? []) as ProviderValidationModel[],
1023
+ },
1024
+ "runtime-register",
1025
+ );
1062
1026
 
1063
1027
  if (config.streamSimple && config.api) {
1064
1028
  const streamSimple = config.streamSimple;
@@ -1085,33 +1049,20 @@ export class ModelRegistry {
1085
1049
  if (config.models && config.models.length > 0) {
1086
1050
  const nextModels = this.#models.filter(m => m.provider !== providerName);
1087
1051
  for (const modelDef of config.models) {
1088
- const api = modelDef.api || config.api;
1089
- if (!api) {
1052
+ const model = buildCustomModel(
1053
+ providerName,
1054
+ config.baseUrl!,
1055
+ config.api,
1056
+ config.headers,
1057
+ config.apiKey,
1058
+ config.authHeader,
1059
+ modelDef,
1060
+ { useDefaults: false },
1061
+ );
1062
+ if (!model) {
1090
1063
  throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
1091
1064
  }
1092
- let headers = config.headers || modelDef.headers ? { ...config.headers, ...modelDef.headers } : undefined;
1093
- if (config.authHeader && config.apiKey) {
1094
- const resolvedKey = resolveApiKeyConfig(config.apiKey);
1095
- if (resolvedKey) {
1096
- headers = { ...headers, Authorization: `Bearer ${resolvedKey}` };
1097
- }
1098
- }
1099
-
1100
- nextModels.push({
1101
- id: modelDef.id,
1102
- name: modelDef.name,
1103
- api,
1104
- provider: providerName,
1105
- baseUrl: config.baseUrl!,
1106
- reasoning: modelDef.reasoning,
1107
- input: modelDef.input as ("text" | "image")[],
1108
- cost: modelDef.cost,
1109
- contextWindow: modelDef.contextWindow,
1110
- maxTokens: modelDef.maxTokens,
1111
- headers,
1112
- compat: modelDef.compat,
1113
- contextPromotionTarget: modelDef.contextPromotionTarget,
1114
- } as Model<Api>);
1065
+ nextModels.push(model);
1115
1066
  }
1116
1067
 
1117
1068
  if (config.oauth?.modifyModels) {