fix(coding-agent): supported models yaml config

Loaded default custom model configuration from models.yaml when models.yml is absent while preserving yml precedence over yaml and legacy json migration.

Fixes #5145
This commit is contained in:
roboomp
2026-07-11 03:52:45 +00:00
parent 7aa1d581c6
commit 2d4d445f95
6 changed files with 246 additions and 27 deletions
+30 -12
View File
@@ -11,9 +11,9 @@ interface ConfigSchemaError {
}
/**
* Module-private cache of (jsonPath, ymlPath) pairs we already migrated this
* process. Prevents `ConfigFile.relocate()` / repeated `tryLoad()` calls from
* re-running the migration over and over on the boot path.
* Module-private cache of JSON → YAML migrations this process already ran.
* Prevents `ConfigFile.relocate()` / repeated `tryLoad()` calls from re-running
* the migration over and over on the boot path.
*/
const migratedPaths = new Set<string>();
@@ -123,6 +123,7 @@ export type LoadResult<T> =
export class ConfigFile<T> implements IConfigFile<T> {
readonly #basePath: string;
readonly #yamlFallbackPath: string | null;
readonly #jsonMigrationPath: string | null;
#cache?: LoadResult<T>;
#auxValidate?: (value: T) => void;
@@ -134,13 +135,17 @@ export class ConfigFile<T> implements IConfigFile<T> {
) {
this.#basePath = configPath;
if (configPath.endsWith(".yml")) {
this.#yamlFallbackPath = `${configPath.slice(0, -4)}.yaml`;
this.#jsonMigrationPath = `${configPath.slice(0, -4)}.json`;
} else if (configPath.endsWith(".yaml")) {
this.#yamlFallbackPath = null;
this.#jsonMigrationPath = `${configPath.slice(0, -5)}.json`;
} else if (configPath.endsWith(".json") || configPath.endsWith(".jsonc")) {
this.#yamlFallbackPath = null;
// JSON configs are still supported without migration.
this.#jsonMigrationPath = null;
} else {
this.#yamlFallbackPath = null;
throw new Error(`Invalid config file path: ${configPath}`);
}
}
@@ -150,9 +155,11 @@ export class ConfigFile<T> implements IConfigFile<T> {
* Sync callers (tests, settings init) hit this implicitly via {@link tryLoad}.
*/
#ensureMigrated(): void {
if (this.#jsonMigrationPath) {
migrateJsonToYml(this.#jsonMigrationPath, this.#basePath);
if (!this.#jsonMigrationPath) return;
if (this.#yamlFallbackPath && !fs.existsSync(this.#basePath) && fs.existsSync(this.#yamlFallbackPath)) {
return;
}
migrateJsonToYml(this.#jsonMigrationPath, this.#basePath);
}
relocate(configPath?: string): ConfigFile<T> {
@@ -163,9 +170,19 @@ export class ConfigFile<T> implements IConfigFile<T> {
return result;
}
#resolveReadPath(): string {
if (fs.existsSync(this.#basePath)) {
return this.#basePath;
}
if (this.#yamlFallbackPath && fs.existsSync(this.#yamlFallbackPath)) {
return this.#yamlFallbackPath;
}
return this.#basePath;
}
getMtimeMs(): number | null {
try {
return fs.statSync(this.path()).mtimeMs;
return fs.statSync(this.#resolveReadPath()).mtimeMs;
} catch (err) {
if (isEnoent(err)) return null;
throw err;
@@ -211,12 +228,13 @@ export class ConfigFile<T> implements IConfigFile<T> {
#parseContent(content: string): LoadResult<T> {
try {
let parsed: unknown;
if (this.#basePath.endsWith(".json") || this.#basePath.endsWith(".jsonc")) {
const readPath = this.#resolveReadPath();
if (readPath.endsWith(".json") || readPath.endsWith(".jsonc")) {
parsed = JSONC.parse(content);
} else if (this.#basePath.endsWith(".yml") || this.#basePath.endsWith(".yaml")) {
} else if (readPath.endsWith(".yml") || readPath.endsWith(".yaml")) {
parsed = YAML.parse(content);
} else {
throw new Error(`Invalid config file path: ${this.#basePath}`);
throw new Error(`Invalid config file path: ${readPath}`);
}
const checked = this.schema(parsed);
@@ -255,7 +273,7 @@ export class ConfigFile<T> implements IConfigFile<T> {
let content: string;
try {
content = fs.readFileSync(this.path(), "utf-8").trim();
content = fs.readFileSync(this.#resolveReadPath(), "utf-8").trim();
} catch (error) {
if (isEnoent(error)) {
return this.#storeCache({ status: "not-found" });
@@ -275,7 +293,7 @@ export class ConfigFile<T> implements IConfigFile<T> {
let content: string;
try {
content = (await Bun.file(this.path()).text()).trim();
content = (await Bun.file(this.#resolveReadPath()).text()).trim();
} catch (error) {
if (isEnoent(error)) {
return this.#storeCache({ status: "not-found" });
@@ -306,7 +324,7 @@ export class ConfigFile<T> implements IConfigFile<T> {
}
path(): string {
return this.#basePath;
return this.#resolveReadPath();
}
invalidate() {
@@ -258,7 +258,7 @@ export interface ProviderDiscoveryState {
error?: string;
}
/** Result of loading custom models from models.json */
/** Result of loading custom models config. */
interface CustomModelsResult {
models?: CustomModelOverlay[];
overrides?: Map<string, ProviderOverride>;
@@ -309,7 +309,7 @@ interface CommandApiKeyResolution {
value?: string;
}
/**
* Resolve a models.yml secret/config value to an actual value.
* Resolve a models.yml/models.yaml secret/config value to an actual value.
* `!cmd` runs a shell command and returns trimmed stdout, otherwise env vars are
* checked first and the input falls back to a literal value.
*/
@@ -822,7 +822,7 @@ export class ModelRegistry {
}
/**
* Reload models from disk (built-in + custom from models.json).
* Reload models from disk (built-in + custom config).
*/
async refresh(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
this.#reloadStaticModels();
@@ -938,7 +938,7 @@ export class ModelRegistry {
#reloadStaticModels(): void {
const currentMtime = this.#modelsConfigFile.getMtimeMs();
if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) {
// models.json unchanged since last load; reloading would be redundant.
// Models config unchanged since last load; reloading would be redundant.
return;
}
this.#modelsConfigFile.invalidate();
@@ -962,14 +962,14 @@ export class ModelRegistry {
}
/**
* Get any error from loading models.json (undefined if no error).
* Get any error from loading custom models config (undefined if no error).
*/
getError(): ConfigError | undefined {
return this.#configError;
}
#loadModels() {
// Load custom models from models.json first (to know which providers to override)
// Load custom config first (to know which providers to override).
const {
models: customModels = [],
overrides = new Map(),
@@ -1909,7 +1909,7 @@ export class ModelRegistry {
/**
* Get all models (built-in + custom).
* If models.json had errors, returns only built-in models.
* If custom config had errors, returns only built-in models.
*/
getAll(): Model<Api>[] {
return this.#models;
@@ -1,5 +1,5 @@
/**
* models.json config file handle and provider configuration validation.
* Custom model/provider config file handle and validation.
*/
import type { Api, ModelSpec } from "@oh-my-pi/pi-ai/types";