diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0ee4422a..f6487621 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 0c521e74..a9db4e40 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -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 diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index f4a032c3..35e59d54 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -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 { +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 { 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 { + const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(patterns, modelRegistry); + for (const diagnostic of diagnostics) { + console.warn(chalk.yellow(`Warning: ${diagnostic.message}`)); + } return scopedModels; } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 1b64d97a..6d7fd14e 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -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, diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 9ff02a68..b889abb9 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -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[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[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 = {