Allow models.json to override built-in providers (#406)

* Allow models.json to override built-in providers

When a provider is defined in models.json with the same name as a
built-in provider (e.g., 'anthropic', 'google'), the built-in models
for that provider are completely replaced by the custom definition.

This enables users to:
- Use custom base URLs (proxies, self-hosted endpoints)
- Define a subset of models they want available
- Customize model configurations for built-in providers

Example usage in ~/.pi/agent/models.json:
{
  "providers": {
    "anthropic": {
      "baseUrl": "https://my-proxy.example.com/v1",
      "apiKey": "ANTHROPIC_API_KEY",
      "api": "anthropic-messages",
      "models": [...]
    }
  }
}

* Refactor model-registry for readability

- Extract CustomModelsResult type and emptyCustomModelsResult helper
- Extract loadBuiltInModels method with clear skip logic
- Simplify loadModels with destructuring and ternary
- Reduce repetition in error handling paths

* Refactor model-registry tests for readability

- Extract providerConfig() helper to hide irrelevant model fields
- Extract writeModelsJson() helper for file writing
- Extract getModelsForProvider() helper for filtering
- Move modelsJsonPath to beforeEach

Reduces test file from 262 to 130 lines while maintaining same coverage.
This commit is contained in:
Yevhen Bobrov 2026-01-03 01:59:59 +02:00 committed by GitHub
parent 6186e497c5
commit 243104fa18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 173 additions and 33 deletions

View file

@ -74,6 +74,17 @@ const ModelsConfigSchema = Type.Object({
type ModelsConfig = Static<typeof ModelsConfigSchema>;
/** Result of loading custom models from models.json */
interface CustomModelsResult {
models: Model<Api>[];
providers: Set<string>;
error: string | undefined;
}
function emptyCustomModelsResult(error?: string): CustomModelsResult {
return { models: [], providers: new Set(), error };
}
/**
* Resolve an API key config value to an actual key.
* Checks environment variable first, then treats as literal.
@ -126,25 +137,17 @@ export class ModelRegistry {
}
private loadModels(): void {
// Load built-in models
const builtInModels: Model<Api>[] = [];
for (const provider of getProviders()) {
const providerModels = getModels(provider as KnownProvider);
builtInModels.push(...(providerModels as Model<Api>[]));
}
// Load custom models from models.json (if path provided)
let customModels: Model<Api>[] = [];
if (this.modelsJsonPath) {
const result = this.loadCustomModels(this.modelsJsonPath);
if (result.error) {
this.loadError = result.error;
// Keep built-in models even if custom models failed to load
} else {
customModels = result.models;
}
// Load custom models from models.json first (to know which providers to skip)
const { models: customModels, providers: customProviders, error } = this.modelsJsonPath
? this.loadCustomModels(this.modelsJsonPath)
: emptyCustomModelsResult();
if (error) {
this.loadError = error;
// Keep built-in models even if custom models failed to load
}
const builtInModels = this.loadBuiltInModels(customProviders);
const combined = [...builtInModels, ...customModels];
// Update github-copilot base URL based on OAuth credentials
@ -160,9 +163,16 @@ export class ModelRegistry {
}
}
private loadCustomModels(modelsJsonPath: string): { models: Model<Api>[]; error: string | undefined } {
/** Load built-in models, skipping providers that are overridden in models.json */
private loadBuiltInModels(skipProviders: Set<string>): Model<Api>[] {
return getProviders()
.filter((provider) => !skipProviders.has(provider))
.flatMap((provider) => getModels(provider as KnownProvider) as Model<Api>[]);
}
private loadCustomModels(modelsJsonPath: string): CustomModelsResult {
if (!existsSync(modelsJsonPath)) {
return { models: [], error: undefined };
return emptyCustomModelsResult();
}
try {
@ -176,28 +186,22 @@ export class ModelRegistry {
const errors =
validate.errors?.map((e: any) => ` - ${e.instancePath || "root"}: ${e.message}`).join("\n") ||
"Unknown schema error";
return {
models: [],
error: `Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`,
};
return emptyCustomModelsResult(`Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`);
}
// Additional validation
this.validateConfig(config);
// Parse models
return { models: this.parseModels(config), error: undefined };
// Parse models and collect provider names
const providers = new Set(Object.keys(config.providers));
return { models: this.parseModels(config), providers, error: undefined };
} catch (error) {
if (error instanceof SyntaxError) {
return {
models: [],
error: `Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`,
};
return emptyCustomModelsResult(`Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`);
}
return {
models: [],
error: `Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsJsonPath}`,
};
return emptyCustomModelsResult(
`Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsJsonPath}`,
);
}
}