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:
+8
-7
@@ -1,4 +1,4 @@
|
||||
# Model and Provider Configuration (`models.yml`)
|
||||
# Model and Provider Configuration (`models.yml` / `models.yaml`)
|
||||
|
||||
This document describes how the coding-agent currently loads models, applies overrides, resolves credentials, and chooses models at runtime.
|
||||
|
||||
@@ -14,16 +14,17 @@ Primary implementation files:
|
||||
|
||||
## Config file location and legacy behavior
|
||||
|
||||
Default config path:
|
||||
Default config paths, in precedence order:
|
||||
|
||||
- `~/.omp/agent/models.yml`
|
||||
- `~/.omp/agent/models.yaml`
|
||||
|
||||
Legacy behavior still present:
|
||||
|
||||
- If `models.yml` is missing and `models.json` exists at the same location, it is migrated to `models.yml`.
|
||||
- If both YAML files are missing and `models.json` exists at the same location, it is migrated to `models.yml`.
|
||||
- Explicit `.json` / `.jsonc` config paths are still supported when passed programmatically to `ModelRegistry`.
|
||||
|
||||
## `models.yml` shape
|
||||
## `models.yml` / `models.yaml` shape
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
@@ -158,7 +159,7 @@ Successful command outputs are cached for the process lifetime so the command is
|
||||
ModelRegistry pipeline (on refresh):
|
||||
|
||||
1. Load built-in providers/models from `@oh-my-pi/pi-catalog` (`getBundledProviders` / `getBundledModels`).
|
||||
2. Load `models.yml` custom config.
|
||||
2. Load `models.yml` / `models.yaml` custom config.
|
||||
3. Apply provider overrides (`baseUrl`, `headers`, `disableStrictTools`) to built-in models.
|
||||
4. Apply `modelOverrides` (per provider + model id).
|
||||
5. Merge custom `models`:
|
||||
@@ -735,11 +736,11 @@ providers:
|
||||
|
||||
## Legacy consumer caveat
|
||||
|
||||
Most model configuration now flows through `models.yml` via `ModelRegistry`. Explicit `.json` / `.jsonc` paths remain supported only when passed programmatically to `ModelRegistry`; the default user config is `~/.omp/agent/models.yml`.
|
||||
Most model configuration now flows through `models.yml` / `models.yaml` via `ModelRegistry`. Explicit `.json` / `.jsonc` paths remain supported only when passed programmatically to `ModelRegistry`; the default user config prefers `~/.omp/agent/models.yml`, then falls back to `~/.omp/agent/models.yaml`.
|
||||
|
||||
## Failure mode
|
||||
|
||||
If `models.yml` fails schema or validation checks:
|
||||
If `models.yml` / `models.yaml` fails schema or validation checks:
|
||||
|
||||
- registry keeps operating with built-in models
|
||||
- error is exposed via `ModelRegistry.getError()` and surfaced in UI/notifications
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed custom model/provider config discovery so `~/.omp/agent/models.yaml` loads when `models.yml` is absent, while preserving `.yml` precedence and only migrating legacy `models.json` when neither YAML file exists. ([#5145](https://github.com/can1357/oh-my-pi/issues/5145))
|
||||
|
||||
## [16.4.2] - 2026-07-10
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const packageRoot = path.resolve(import.meta.dir, "..");
|
||||
|
||||
let tempDir: TempDir;
|
||||
|
||||
describe("ModelRegistry default custom models config", () => {
|
||||
beforeEach(() => {
|
||||
tempDir = TempDir.createSync("@model-registry-default-config-");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await tempDir.remove().catch(() => {});
|
||||
});
|
||||
|
||||
test("loads custom provider models from default models.yaml when models.yml is absent", () => {
|
||||
writeModelsYaml("models.yaml", {
|
||||
provider: "yaml-default-only",
|
||||
modelId: "yaml-model",
|
||||
modelName: "YAML default model",
|
||||
baseUrl: "https://yaml-default.example.com/v1",
|
||||
});
|
||||
|
||||
const model = loadDefaultRegistryModel({
|
||||
provider: "yaml-default-only",
|
||||
modelId: "yaml-model",
|
||||
});
|
||||
|
||||
expect(model?.name).toBe("YAML default model");
|
||||
expect(model?.baseUrl).toBe("https://yaml-default.example.com/v1");
|
||||
});
|
||||
|
||||
test("prefers default models.yml over models.yaml when both exist", () => {
|
||||
writeModelsYaml("models.yml", {
|
||||
provider: "yaml-precedence",
|
||||
modelId: "from-yml",
|
||||
modelName: "YML winner",
|
||||
baseUrl: "https://yml-winner.example.com/v1",
|
||||
});
|
||||
writeModelsYaml("models.yaml", {
|
||||
provider: "yaml-precedence",
|
||||
modelId: "from-yaml",
|
||||
modelName: "YAML loser",
|
||||
baseUrl: "https://yaml-loser.example.com/v1",
|
||||
});
|
||||
|
||||
const ymlModel = loadDefaultRegistryModel({
|
||||
provider: "yaml-precedence",
|
||||
modelId: "from-yml",
|
||||
});
|
||||
const yamlModel = loadDefaultRegistryModel({
|
||||
provider: "yaml-precedence",
|
||||
modelId: "from-yaml",
|
||||
});
|
||||
|
||||
expect(ymlModel?.baseUrl).toBe("https://yml-winner.example.com/v1");
|
||||
expect(yamlModel).toBeUndefined();
|
||||
});
|
||||
|
||||
test("prefers default models.yaml over legacy models.json when models.yml is absent", () => {
|
||||
writeModelsYaml("models.yaml", {
|
||||
provider: "yaml-json-precedence",
|
||||
modelId: "from-yaml",
|
||||
modelName: "YAML winner over JSON",
|
||||
baseUrl: "https://yaml-over-json.example.com/v1",
|
||||
});
|
||||
writeModelsJson({
|
||||
provider: "yaml-json-precedence",
|
||||
modelId: "from-json",
|
||||
modelName: "JSON loser",
|
||||
baseUrl: "https://json-loser.example.com/v1",
|
||||
});
|
||||
|
||||
const yamlModel = loadDefaultRegistryModel({
|
||||
provider: "yaml-json-precedence",
|
||||
modelId: "from-yaml",
|
||||
});
|
||||
const jsonModel = loadDefaultRegistryModel({
|
||||
provider: "yaml-json-precedence",
|
||||
modelId: "from-json",
|
||||
});
|
||||
|
||||
expect(yamlModel?.baseUrl).toBe("https://yaml-over-json.example.com/v1");
|
||||
expect(jsonModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
interface ProviderFixture {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelName: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface ModelLookup {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
interface ModelSnapshot {
|
||||
provider: string;
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string | undefined;
|
||||
}
|
||||
|
||||
function writeModelsYaml(file: "models.yml" | "models.yaml", fixture: ProviderFixture): void {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir.path(), file),
|
||||
[
|
||||
"providers:",
|
||||
` ${fixture.provider}:`,
|
||||
` baseUrl: ${fixture.baseUrl}`,
|
||||
" apiKey: TEST_KEY",
|
||||
" api: anthropic-messages",
|
||||
" models:",
|
||||
` - id: ${fixture.modelId}`,
|
||||
` name: ${fixture.modelName}`,
|
||||
" reasoning: false",
|
||||
" input: [text]",
|
||||
" cost:",
|
||||
" input: 0",
|
||||
" output: 0",
|
||||
" cacheRead: 0",
|
||||
" cacheWrite: 0",
|
||||
" contextWindow: 100000",
|
||||
" maxTokens: 8000",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function writeModelsJson(fixture: ProviderFixture): void {
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir.path(), "models.json"),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
[fixture.provider]: {
|
||||
baseUrl: fixture.baseUrl,
|
||||
apiKey: "TEST_KEY",
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: fixture.modelId,
|
||||
name: fixture.modelName,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 100000,
|
||||
maxTokens: 8000,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function loadDefaultRegistryModel(lookup: ModelLookup): ModelSnapshot | undefined {
|
||||
const script = `
|
||||
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
||||
|
||||
const authStorage = await AuthStorage.create(":memory:");
|
||||
try {
|
||||
const registry = new ModelRegistry(authStorage);
|
||||
const model = registry.find(${JSON.stringify(lookup.provider)}, ${JSON.stringify(lookup.modelId)});
|
||||
process.stdout.write(JSON.stringify(model ? {
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
baseUrl: model.baseUrl,
|
||||
} : null));
|
||||
} finally {
|
||||
authStorage.close();
|
||||
}
|
||||
`;
|
||||
const result = Bun.spawnSync([process.execPath, "-e", script], {
|
||||
cwd: packageRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PI_CODING_AGENT_DIR: tempDir.path(),
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const stdout = new TextDecoder().decode(result.stdout).trim();
|
||||
const stderr = new TextDecoder().decode(result.stderr).trim();
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`default ModelRegistry lookup failed: ${stderr || stdout || `exit ${result.exitCode}`}`);
|
||||
}
|
||||
return JSON.parse(stdout) ?? undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user