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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user