feat: support custom pi model configs, with registry-resolvable model IDs and distinct model error codes (#450)

This commit is contained in:
ezl-keygraph
2026-09-08 14:54:37 +05:30
committed by GitHub
parent 4b8131fdd5
commit d41d52f17d
20 changed files with 441 additions and 124 deletions
+8
View File
@@ -22,6 +22,7 @@ import {
FINAL_REPORT_PDF_FILENAME,
INTERNAL_DIR,
resolveConfig,
resolveModelsConfig,
resolveRepo,
resolveRunFile,
} from '../paths.js';
@@ -37,6 +38,7 @@ export interface StartArgs {
url: string;
repo: string;
config?: string;
modelsConfig?: string;
workspace?: string;
output?: string;
pipelineTesting: boolean;
@@ -231,6 +233,7 @@ export async function start(args: StartArgs): Promise<void> {
}
const repo = resolveRepo(args.repo);
const config = args.config ? resolveConfig(args.config) : undefined;
const modelsConfig = args.modelsConfig ? resolveModelsConfig(args.modelsConfig) : undefined;
const workspacesDir = getWorkspacesDir();
const workspace =
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
@@ -322,6 +325,7 @@ export async function start(args: StartArgs): Promise<void> {
containerName,
envFlags: buildEnvFlags(),
...(config && { config }),
...(modelsConfig && { modelsConfig }),
...(promptsDir && { promptsDir }),
...(outputDir && { outputDir }),
workspace,
@@ -530,6 +534,10 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
if (args.config) {
console.log(` Config: ${interactive ? path.resolve(args.config) : path.basename(args.config)}`);
}
if (args.modelsConfig) {
const shown = interactive ? path.resolve(args.modelsConfig) : path.basename(args.modelsConfig);
console.log(` Models: ${shown}`);
}
if (args.pipelineTesting) {
console.log(' Mode: Pipeline Testing');
}
+7
View File
@@ -407,6 +407,7 @@ export interface WorkerOptions {
containerName: string;
envFlags: string[];
config?: { hostPath: string; containerPath: string };
modelsConfig?: { hostPath: string; containerPath: string };
promptsDir?: string;
outputDir?: string;
workspace: string;
@@ -469,6 +470,12 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
args.push('-v', `${opts.config.hostPath}:${opts.config.containerPath}:ro`);
}
// pi model config. The mount is the only signal the worker gets: it detects the file at
// this fixed path, so nothing about --models-config travels through the environment.
if (opts.modelsConfig) {
args.push('-v', `${opts.modelsConfig.hostPath}:${opts.modelsConfig.containerPath}:ro`);
}
// Customer-copy destination. The workflow surfaces only final report artifacts here.
if (opts.outputDir) {
args.push('-v', `${opts.outputDir}:/app/output`);
+1
View File
@@ -29,6 +29,7 @@ export const START_OPTIONS: readonly (readonly [string, string])[] = [
['-u, --url <url>', 'Target URL (required)'],
['-r, --repo <path>', 'Repository path (required)'],
['-c, --config <path>', 'Configuration file (YAML)'],
['--models-config <path>', "pi model config (models.json) defining models pi's catalogue lacks"],
['-o, --output <path>', 'Copy deliverables to this directory after the run'],
['-w, --workspace <name>', 'Named workspace (auto-resumes if it exists)'],
['-f, --follow', 'Stream the scan log until it finishes'],
+3
View File
@@ -171,6 +171,7 @@ interface ParsedStartArgs {
url: string;
repo: string;
config?: string;
modelsConfig?: string;
workspace?: string;
output?: string;
pipelineTesting: boolean;
@@ -184,6 +185,7 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
url: ['-u', '--url'],
repo: ['-r', '--repo'],
config: ['-c', '--config'],
modelsConfig: ['--models-config'],
output: ['-o', '--output'],
workspace: ['-w', '--workspace'],
},
@@ -213,6 +215,7 @@ function parseStartArgs(argv: string[]): ParsedStartArgs {
keepContainer: !!flags.keepContainer,
follow: !!flags.follow,
...(values.config && { config: values.config }),
...(values.modelsConfig && { modelsConfig: values.modelsConfig }),
...(values.workspace && { workspace: values.workspace }),
...(values.output && { output: values.output }),
};
+31 -2
View File
@@ -1,7 +1,7 @@
/**
* Path resolution for --repo and --config arguments.
* Path resolution for --repo, --config and --models-config arguments.
*
* Both --repo and --config are filesystem paths, absolute or relative to CWD.
* All three are filesystem paths, absolute or relative to CWD.
*/
import fs from 'node:fs';
@@ -108,3 +108,32 @@ export function resolveConfig(configArg: string): MountPair {
containerPath: `/app/configs/${basename}`,
};
}
/**
* Container path for a mounted pi model config. Fixed, not derived from the host filename:
* the worker detects the file here to decide whether models.json is enabled at all. Must
* match MODELS_CONFIG_PATH in the worker package.
*/
export const MODELS_CONFIG_CONTAINER_PATH = '/app/models.json';
/**
* Resolve --models-config to an absolute path and container mount. Content is left
* unparsed: pi's models.json permits comments, so JSON.parse would reject valid input,
* and pi's own loader reports schema faults far better — the worker surfaces those.
*/
export function resolveModelsConfig(modelsConfigArg: string): MountPair {
const hostPath = path.resolve(expandHome(modelsConfigArg));
if (!fs.existsSync(hostPath)) {
fail(`Model config file not found: ${hostPath}`);
}
if (!fs.statSync(hostPath).isFile()) {
fail(`Not a file: ${hostPath}`);
}
return {
hostPath,
containerPath: MODELS_CONFIG_CONTAINER_PATH,
};
}
+3 -3
View File
@@ -39,9 +39,9 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^0.84.2",
"@earendil-works/pi-ai": "^0.84.2",
"@earendil-works/pi-coding-agent": "^0.84.2",
"@earendil-works/pi-agent-core": "^0.84.4",
"@earendil-works/pi-ai": "^0.84.4",
"@earendil-works/pi-coding-agent": "^0.84.4",
"@gotgenes/pi-permission-system": "^10.9.0",
"@temporalio/activity": "1.15.0",
"@temporalio/client": "1.15.0",
+39 -19
View File
@@ -20,6 +20,11 @@
* Resolution returns a pi `Model` plus the `ModelRuntime` that owns its auth,
* built over an in-memory credential store primed from the environment.
*
* A model too new for the pinned pi release is reachable by passing its descriptor in a
* pi `models.json` (the CLI's `--models-config`), which merges over the catalogue. The
* credential store below outranks any `apiKey` that file carries, so it describes the
* model while the environment still supplies the secret.
*
* The CLI cannot import this module (it ships as a separate bundle), so
* `apps/cli/src/model-spec.ts` mirrors the parse rule and the provider/credential
* tables by hand for its own `status` rendering and setup wizard. The two copies
@@ -32,6 +37,7 @@ import { existsSync } from 'node:fs';
import path from 'node:path';
import type { Api, Credential, CredentialInfo, CredentialStore, Model } from '@earendil-works/pi-ai';
import { getAgentDir, ModelRuntime } from '@earendil-works/pi-coding-agent';
import { MODELS_CONFIG_PATH } from '../paths.js';
/**
* Providers Shannon curates with their own credential variables, config sections,
@@ -196,20 +202,43 @@ export function piAuthPresent(): boolean {
return existsSync(piAuthPath());
}
/** Path of the mounted pi model config, or undefined when the scan supplied none. */
export function modelsConfigPath(): string | undefined {
return existsSync(MODELS_CONFIG_PATH) ? MODELS_CONFIG_PATH : undefined;
}
/**
* Where pi persists remote model catalogues. Pinned to the writable agent dir because pi
* otherwise derives it from `dirname(modelsPath)`, which is a read-only mount.
*/
function modelsStorePath(): string {
return path.join(getAgentDir(), 'models-store.json');
}
/**
* Build a ModelRuntime whose only credential is the one supplied. Model catalogs
* stay offline (`allowModelNetwork` defaults to false) so a scan never blocks on
* a catalog refresh.
*
* `modelsPath` is always explicit, never pi's default of `<agent dir>/models.json`: with no
* `--models-config` it is null, which switches models.json off outright, so a stray file in
* that shared dir cannot feed model definitions to a run that did not ask for them.
*
* When the host's pi auth.json is present, the runtime reads it instead: pi's
* disk-backed store resolves the credential. The mount is writable so OAuth
* refreshes persist to the host for subsequent runs.
*/
export async function createModelRuntime(providerId: string, apiKey: string | undefined): Promise<ModelRuntime> {
const modelsPath = modelsConfigPath();
const modelSources = {
modelsPath: modelsPath ?? null,
...(modelsPath ? { modelsStorePath: modelsStorePath() } : {}),
};
if (piAuthPresent()) {
return ModelRuntime.create({ authPath: piAuthPath() });
return ModelRuntime.create({ ...modelSources, authPath: piAuthPath() });
}
return ModelRuntime.create({ credentials: new RuntimeCredentialStore(providerId, apiKey) });
return ModelRuntime.create({ ...modelSources, credentials: new RuntimeCredentialStore(providerId, apiKey) });
}
export interface ModelSelection {
@@ -221,16 +250,13 @@ export interface ModelSelection {
}
/**
* Resolve a model against a runtime.
* Resolve a model against a runtime, returning undefined when the id is unknown.
*
* Direct to a provider, the model must exist in the catalogue. Behind a custom
* endpoint it need not: a gateway may serve models under its own names, so an
* unknown id is passed through on a descriptor borrowed from the provider's
* catalogue for its API dialect. Cost and context window on such a descriptor
* are the reference model's, so spend figures are approximate there.
*
* Returns undefined when the id is unresolvable — unknown with no endpoint
* override, or a provider carrying no models at all.
* The model must exist in the runtime's registry, whether or not an endpoint override
* is in play — a base URL changes the address and nothing else. A gateway serving a
* model under its own name, or one newer than the pinned pi release, is described in a
* `--models-config` file, which puts a real descriptor in the registry rather than
* guessing one from an unrelated model.
*/
export function resolveModel(
modelRuntime: ModelRuntime,
@@ -239,15 +265,9 @@ export function resolveModel(
baseUrl: string | undefined,
): Model<Api> | undefined {
const found = modelRuntime.getModel(providerId, modelId);
if (found) {
return baseUrl ? { ...found, baseUrl } : found;
}
if (!baseUrl) return undefined;
if (!found) return undefined;
const reference = modelRuntime.getModels(providerId)[0];
if (!reference) return undefined;
return { ...reference, id: modelId, name: modelId, baseUrl };
return baseUrl ? { ...found, baseUrl } : found;
}
/**
@@ -276,7 +276,7 @@ function policy(
}
export const CAPELLA_ACTIVITY_POLICIES = Object.freeze({
capellaArchitecture: policy('architecture', 60 * MINUTE_MS, 60 * MINUTE_MS, 5 * MINUTE_MS, 3, 'large'),
capellaArchitecture: policy('architecture', 90 * MINUTE_MS, 90 * MINUTE_MS, 5 * MINUTE_MS, 3, 'large'),
capellaThreatModel: policy('threat-model', 30 * MINUTE_MS, 30 * MINUTE_MS, 5 * MINUTE_MS, 2, 'medium'),
capellaPlan: policy('plan', 30 * MINUTE_MS, 90 * MINUTE_MS, 5 * MINUTE_MS, 2, 'medium'),
capellaResearch: policy('research', 3 * HOUR_MS, 4.5 * HOUR_MS, 5 * MINUTE_MS, 2, 'small + medium'),
+3
View File
@@ -34,6 +34,9 @@ const SAFE_ERROR_MESSAGES: Readonly<Record<ErrorCode, string>> = {
[ErrorCode.TARGET_UNREACHABLE]: 'The target could not be reached.',
[ErrorCode.AUTH_FAILED]: 'Authentication validation failed.',
[ErrorCode.AUTH_LOGIN_FAILED]: 'The configured login could not be completed.',
[ErrorCode.MODEL_NOT_FOUND]:
'The selected model was not found in the harness catalogue. Check SHANNON_AI_MODEL, or supply the model with --models-config.',
[ErrorCode.MODEL_CONFIG_INVALID]: 'The model configuration file could not be used.',
};
const ERROR_CATEGORIES = new Set<PentestErrorType>([
+6
View File
@@ -15,6 +15,12 @@ export const TYPST_TEMPLATE = path.join(WORKER_ROOT, 'templates', 'typst', 'repo
/** Compiled pi extension dir that enforces bounded `bash` timeouts (resolved from dist/) */
export const BASH_TIMEOUT_EXTENSION_DIR = path.join(import.meta.dirname, 'ai', 'extensions', 'bash-timeout');
/**
* Where the CLI mounts a pi model config passed with `--models-config`; its presence is
* what enables models.json. Must match MODELS_CONFIG_CONTAINER_PATH in the CLI package.
*/
export const MODELS_CONFIG_PATH = '/app/models.json';
/** Default deliverables subdirectory relative to repoPath */
export const DEFAULT_DELIVERABLES_SUBDIR = '.shannon/deliverables';
@@ -332,6 +332,14 @@ function classifyByErrorCode(code: ErrorCode, retryableFromError: boolean): { ty
case ErrorCode.AUTH_FAILED:
return { type: 'AuthenticationError', retryable: false };
// Not AuthenticationError: the credential is not in question, and the pipeline
// appends an "is your API key valid" hint to anything classified that way.
case ErrorCode.MODEL_NOT_FOUND:
return { type: 'ModelNotFoundError', retryable: false };
case ErrorCode.MODEL_CONFIG_INVALID:
return { type: 'ModelConfigError', retryable: false };
case ErrorCode.AUTH_LOGIN_FAILED:
return { type: 'AuthLoginFailedError', retryable: false };
+29 -12
View File
@@ -40,6 +40,7 @@ import {
createModelRuntime,
GENERIC_API_KEY_ENV,
type ModelSpec,
modelsConfigPath,
PI_CATALOG_URL,
piAuthPresent,
resolveModel,
@@ -317,7 +318,7 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
'config',
false,
{},
ErrorCode.AUTH_FAILED,
ErrorCode.MODEL_NOT_FOUND,
),
);
}
@@ -343,28 +344,44 @@ async function validateCredentials(logger: ActivityLogger): Promise<Result<void,
);
}
// 3. Model must exist in the registry, for every provider — Bedrock IDs are the
// easiest to get wrong, since region prefixes and version suffixes differ per
// model (`us.anthropic.claude-opus-5` exists, bare `anthropic.` does not).
// A custom endpoint is exempt: it may serve models under its own names.
// 3. Model must exist in the registry, for every provider and endpoint — Bedrock IDs
// are the easiest to get wrong, since region prefixes and version suffixes differ
// per model (`us.anthropic.claude-opus-5` exists, bare `anthropic.` does not).
// An id the registry lacks is supplied by --models-config, not guessed at here.
const modelRuntime = await createModelRuntime(spec.providerId, credentials.apiKey);
// A model config that fails to parse or compose leaves pi with an empty or fallback
// provider, which would surface below as "model not found" and blame SHANNON_AI_MODEL
// for the file's fault. Report the real cause first.
const modelsConfig = modelsConfigPath();
if (modelsConfig) {
logger.info(`Model config: ${modelsConfig}`);
}
const modelConfigError = modelRuntime.getError();
if (modelConfigError) {
return err(
new PentestError(
`Model configuration is invalid:\n${modelConfigError}`,
'config',
false,
{ providerId: spec.providerId, ...(modelsConfig && { modelsConfig }) },
ErrorCode.MODEL_CONFIG_INVALID,
),
);
}
const baseModel = resolveModel(modelRuntime, spec.providerId, spec.modelId, credentials.baseUrl);
if (!baseModel) {
return err(
new PentestError(
`Model not found in pi registry: provider="${spec.providerId}" model="${spec.modelId}". Check SHANNON_AI_MODEL — browse valid providers and models at ${PI_CATALOG_URL}.`,
`Model not found in pi registry: provider="${spec.providerId}" model="${spec.modelId}". Check SHANNON_AI_MODEL — browse valid providers and models at ${PI_CATALOG_URL}. A model too new for this pi release can be defined in a model config passed with --models-config.`,
'config',
false,
{ providerId: spec.providerId, modelId: spec.modelId },
ErrorCode.AUTH_FAILED,
ErrorCode.MODEL_NOT_FOUND,
),
);
}
if (!modelRuntime.getModel(spec.providerId, spec.modelId)) {
logger.warn(
`Model "${spec.modelId}" is not in the ${spec.providerId} catalogue; passing it to the custom endpoint as given. Cost figures will be approximate.`,
);
}
if (credentials.baseUrl && spec.providerId === 'openai') {
logger.info(`Gateway API: ${baseModel.api}`);
}
@@ -23,6 +23,8 @@ import { ErrorCode } from '../types/errors.js';
*/
const ERROR_TYPE_TO_CODE: Record<string, ErrorCode> = {
AuthenticationError: ErrorCode.AUTH_FAILED,
ModelNotFoundError: ErrorCode.MODEL_NOT_FOUND,
ModelConfigError: ErrorCode.MODEL_CONFIG_INVALID,
ConfigurationError: ErrorCode.CONFIG_VALIDATION_FAILED,
OutputValidationError: ErrorCode.OUTPUT_VALIDATION_FAILED,
AgentExecutionError: ErrorCode.AGENT_EXECUTION_FAILED,
@@ -54,6 +56,8 @@ export function classifyErrorCode(error: unknown): ErrorCode | undefined {
*/
const REMEDIATION_HINTS: Record<string, string> = {
AuthenticationError: "Verify the selected provider's API key is valid and not expired.",
ModelNotFoundError: 'Check SHANNON_AI_MODEL against pi.dev/models, or supply the model with --models-config.',
ModelConfigError: 'Check the --models-config file parses and matches pis models.json schema.',
ConfigurationError: 'Check your CONFIG file path and contents.',
GitError: 'Check repository path and git state.',
InvalidTargetError: 'Verify the target URL is correct and accessible.',
@@ -69,6 +73,8 @@ const REMEDIATION_HINTS: Record<string, string> = {
*/
const SAFE_WORKFLOW_FAILURE_MESSAGES: Readonly<Record<string, string>> = {
AuthenticationError: 'Provider authentication failed.',
ModelNotFoundError: 'The selected model was not found in the harness catalogue.',
ModelConfigError: 'The model configuration file could not be used.',
ConfigurationError: 'The scan configuration is invalid.',
OutputValidationError: 'A scan step returned an unusable result.',
AgentExecutionError: 'An agent could not complete its work.',
+2
View File
@@ -40,6 +40,8 @@ export enum ErrorCode {
TARGET_UNREACHABLE = 'TARGET_UNREACHABLE',
AUTH_FAILED = 'AUTH_FAILED',
AUTH_LOGIN_FAILED = 'AUTH_LOGIN_FAILED',
MODEL_NOT_FOUND = 'MODEL_NOT_FOUND',
MODEL_CONFIG_INVALID = 'MODEL_CONFIG_INVALID',
}
export type PentestErrorType = 'config' | 'network' | 'prompt' | 'filesystem' | 'validation' | 'unknown';