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] ## [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
- 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)). - 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 2. Uses default from settings
3. Falls back to first available model 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) > See [examples/sdk/02-custom-model.ts](../examples/sdk/02-custom-model.ts)
### API Keys and OAuth ### API Keys and OAuth
@@ -1104,6 +1130,8 @@ AgentSessionRuntime
// Auth and Models // Auth and Models
AuthStorage AuthStorage
ModelRegistry ModelRegistry
resolveCliModel
resolveModelScopeWithDiagnostics
// Resource loading // Resource loading
DefaultResourceLoader DefaultResourceLoader
@@ -255,9 +255,24 @@ export function parseModelPattern(
* The algorithm tries to match the full pattern first, then progressively * The algorithm tries to match the full pattern first, then progressively
* strips colon-suffixes to find a match. * 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 availableModels = await modelRegistry.getAvailable();
const scopedModels: ScopedModel[] = []; const scopedModels: ScopedModel[] = [];
const diagnostics: ModelScopeDiagnostic[] = [];
for (const pattern of patterns) { for (const pattern of patterns) {
// Check if pattern contains glob characters // Check if pattern contains glob characters
@@ -283,7 +298,7 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model
}); });
if (matchingModels.length === 0) { 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; continue;
} }
@@ -298,11 +313,11 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model
const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels); const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels);
if (warning) { if (warning) {
console.warn(chalk.yellow(`Warning: ${warning}`)); diagnostics.push({ type: "warning", message: warning, pattern });
} }
if (!model) { if (!model) {
console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`)); diagnostics.push({ type: "warning", message: `No models match pattern "${pattern}"`, pattern });
continue; 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; return scopedModels;
} }
+8
View File
@@ -165,6 +165,14 @@ export {
export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.ts"; export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.ts";
export { convertToLlm } from "./core/messages.ts"; export { convertToLlm } from "./core/messages.ts";
export { ModelRegistry } from "./core/model-registry.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 { export type {
PackageManager, PackageManager,
PathMetadata, PathMetadata,
@@ -1,10 +1,12 @@
import type { Model } from "@earendil-works/pi-ai"; import type { Model } from "@earendil-works/pi-ai";
import { describe, expect, test } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { import {
defaultModelPerProvider, defaultModelPerProvider,
findInitialModel, findInitialModel,
parseModelPattern, parseModelPattern,
resolveCliModel, resolveCliModel,
resolveModelScope,
resolveModelScopeWithDiagnostics,
} from "../src/core/model-resolver.ts"; } from "../src/core/model-resolver.ts";
// Mock models for testing // 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", () => { describe("resolveCliModel", () => {
test("resolves --model provider/id without --provider", () => { test("resolves --model provider/id without --provider", () => {
const registry = { const registry = {