feat(coding-agent): expose model resolution helpers

closes #6201
This commit is contained in:
Vegard Stikbakke
2026-07-01 10:22:35 +02:00
parent 1d061b3f45
commit 040f0a5197
5 changed files with 119 additions and 5 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added public SDK exports for CLI-equivalent model and scoped-model resolution ([#6201](https://github.com/earendil-works/pi/issues/6201)).
### Fixed
- Fixed oversized bash tool timeouts to fail with a clear validation error instead of being clamped to an immediate timeout ([#6181](https://github.com/earendil-works/pi/issues/6181)).
+28
View File
@@ -405,6 +405,32 @@ If no model is provided:
2. Uses default from settings
3. Falls back to first available model
To match CLI model parsing, use the exported resolver helpers:
```typescript
import {
resolveCliModel,
resolveModelScopeWithDiagnostics,
} from "@earendil-works/pi-coding-agent";
const cliModel = resolveCliModel({
cliModel: "anthropic/claude-opus-4-5:high",
modelRegistry,
});
if (cliModel.error) throw new Error(cliModel.error);
if (cliModel.warning) console.warn(cliModel.warning);
const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(
["anthropic/*:high", "gpt-5"],
modelRegistry,
);
for (const diagnostic of diagnostics) {
console.warn(diagnostic.message);
}
```
`resolveCliModel()` uses all registered models so `--api-key` style first-time setup can resolve a model before stored auth exists. `resolveModelScopeWithDiagnostics()` matches `--models` and `enabledModels` semantics while returning warnings instead of printing them.
> See [examples/sdk/02-custom-model.ts](../examples/sdk/02-custom-model.ts)
### API Keys and OAuth
@@ -1104,6 +1130,8 @@ AgentSessionRuntime
// Auth and Models
AuthStorage
ModelRegistry
resolveCliModel
resolveModelScopeWithDiagnostics
// Resource loading
DefaultResourceLoader
@@ -255,9 +255,24 @@ export function parseModelPattern(
* The algorithm tries to match the full pattern first, then progressively
* strips colon-suffixes to find a match.
*/
export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise<ScopedModel[]> {
export interface ModelScopeDiagnostic {
type: "warning";
message: string;
pattern: string;
}
export interface ResolveModelScopeResult {
scopedModels: ScopedModel[];
diagnostics: ModelScopeDiagnostic[];
}
export async function resolveModelScopeWithDiagnostics(
patterns: string[],
modelRegistry: ModelRegistry,
): Promise<ResolveModelScopeResult> {
const availableModels = await modelRegistry.getAvailable();
const scopedModels: ScopedModel[] = [];
const diagnostics: ModelScopeDiagnostic[] = [];
for (const pattern of patterns) {
// Check if pattern contains glob characters
@@ -283,7 +298,7 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model
});
if (matchingModels.length === 0) {
console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`));
diagnostics.push({ type: "warning", message: `No models match pattern "${pattern}"`, pattern });
continue;
}
@@ -298,11 +313,11 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model
const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels);
if (warning) {
console.warn(chalk.yellow(`Warning: ${warning}`));
diagnostics.push({ type: "warning", message: warning, pattern });
}
if (!model) {
console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`));
diagnostics.push({ type: "warning", message: `No models match pattern "${pattern}"`, pattern });
continue;
}
@@ -312,6 +327,14 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model
}
}
return { scopedModels, diagnostics };
}
export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise<ScopedModel[]> {
const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(patterns, modelRegistry);
for (const diagnostic of diagnostics) {
console.warn(chalk.yellow(`Warning: ${diagnostic.message}`));
}
return scopedModels;
}
+8
View File
@@ -165,6 +165,14 @@ export {
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.ts";
export { convertToLlm } from "./core/messages.ts";
export { ModelRegistry } from "./core/model-registry.ts";
export {
type ModelScopeDiagnostic,
type ResolveCliModelResult,
type ResolveModelScopeResult,
resolveCliModel,
resolveModelScopeWithDiagnostics,
type ScopedModel,
} from "./core/model-resolver.ts";
export type {
PackageManager,
PathMetadata,
@@ -1,10 +1,12 @@
import type { Model } from "@earendil-works/pi-ai";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import {
defaultModelPerProvider,
findInitialModel,
parseModelPattern,
resolveCliModel,
resolveModelScope,
resolveModelScopeWithDiagnostics,
} from "../src/core/model-resolver.ts";
// Mock models for testing
@@ -206,6 +208,55 @@ describe("parseModelPattern", () => {
});
});
describe("resolveModelScopeWithDiagnostics", () => {
test("returns scoped models and structured diagnostics without writing console warnings", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const registry = {
getAvailable: () => allModels,
} as unknown as Parameters<typeof resolveModelScopeWithDiagnostics>[1];
const result = await resolveModelScopeWithDiagnostics(["sonnet:high", "gpt-4o:invalid", "missing"], registry);
expect(result.scopedModels.map((scoped) => scoped.model.id)).toEqual(["claude-sonnet-4-5", "gpt-4o"]);
expect(result.scopedModels[0].thinkingLevel).toBe("high");
expect(result.scopedModels[1].thinkingLevel).toBeUndefined();
expect(result.diagnostics).toEqual([
{
type: "warning",
message: 'Invalid thinking level "invalid" in pattern "gpt-4o:invalid". Using default instead.',
pattern: "gpt-4o:invalid",
},
{
type: "warning",
message: 'No models match pattern "missing"',
pattern: "missing",
},
]);
expect(warn).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
}
});
test("resolveModelScope preserves CLI warning output", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const registry = {
getAvailable: () => allModels,
} as unknown as Parameters<typeof resolveModelScope>[1];
const scopedModels = await resolveModelScope(["missing"], registry);
expect(scopedModels).toEqual([]);
expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0][0]).toContain('Warning: No models match pattern "missing"');
} finally {
warn.mockRestore();
}
});
});
describe("resolveCliModel", () => {
test("resolves --model provider/id without --provider", () => {
const registry = {