mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* feat(codex): --effort <level> sets GSTACK_CODEX_EFFORT for one run /codex review --effort medium (or --effort=medium) prefixes each run-with-timeout dispatch with GSTACK_CODEX_EFFORT=<level>, which outranks codex_effort and the per-mode default; --xhigh becomes shorthand for --effort xhigh. The probe keeps owning validation. A missing value, a value that is not one lowercase word, or conflicting flags stop instead of falling back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(codex): per-run effort prefix reaches the rendered /codex review dispatch Runs the rendered review fence against the fake codex: no prefix uses codex_effort, GSTACK_CODEX_EFFORT=medium|xhigh wins over it, an unknown level never reaches codex. Step 1 check pins --effort tokens and the stop rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(codex): document codex_effort and --effort; Challenge runs at high, not xhigh Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): [Unreleased] entry for /codex --effort Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(codex): --effort reaches the probe as an allowlisted request level, parsed before the mode and checked before the paid probe The level now travels as run-with-timeout --request-effort '<effort-level>' (placeholder grammar ^[a-z]*$) instead of an agent-built env prefix, and the probe names /codex --effort when it rejects one. Effort flags count only right after /codex or its mode word and are removed before mode detection. Step 0.5 runs check-effort before probe-model, so a bad level makes no Codex call even on a cold cache. Tests cover all five dispatch fences and the zero-call cases. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(codex): per-request --effort in model-policy, CHANGELOG behavior changes, TODOS follow-ups Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: stamp v1.91.93.0 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(codex): effort flags sit among /codex's leading flags; the probe names an unfilled '<effort-level>' placeholder Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(codex): per-request row in the effort precedence table, in-tool help names /codex --effort, late-flag notice Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(codex): binding tripwire sees the request-effort dispatch; valid and unfilled levels through Step 0.5 and every fence; doc accuracy Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: John Nunemaker <nunemaker@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
290 lines
16 KiB
TypeScript
290 lines
16 KiB
TypeScript
import * as path from 'node:path';
|
|
import { CODEX_EFFORT_LEVELS, MODEL_CATALOG, MODEL_CATALOG_VERSION, MODEL_PROVIDERS, MODEL_TIERS, modelCatalogSha256, type ModelProvider } from './model-catalog';
|
|
import {
|
|
CUSTOM_PROVIDER_UNSCANNED,
|
|
describeSelection,
|
|
MODEL_OVERRIDE_KEYS,
|
|
ModelPolicyError,
|
|
modelPolicyCommands,
|
|
readModelPolicyConfig,
|
|
resolveImplementationModels,
|
|
resolvePlanReviewModel,
|
|
type ModelPolicyConfig,
|
|
type ModelPolicyErrorJson,
|
|
type ModelRole,
|
|
type ModelSelection,
|
|
} from './model-policy';
|
|
import { readConfigKey, type StateRootEnv } from './state-root';
|
|
|
|
export interface ModelsCliResult {
|
|
code: 0 | 1 | 2;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
type CliError = (ModelPolicyErrorJson | { reason: 'usage'; problem: string; cause: string; key: null; source: null; repair: string[]; docs: string }) & { role?: ModelRole; provider?: ModelProvider };
|
|
|
|
const OFFLINE_NOTE = 'offline: no network, no paid model call and no writes; this does not prove account entitlement, CLI availability or current vendor status';
|
|
const DOCS = 'https://github.com/garrytan/gstack/blob/main/docs/model-policy.md';
|
|
const CONFIG_BIN = path.join(import.meta.dir, '..', 'bin', 'gstack-config');
|
|
|
|
export const MODELS_HELP = `Usage:
|
|
gstack-models [list] [--json]
|
|
gstack-models resolve --role plan-review --provider anthropic|openai [--json]
|
|
gstack-models resolve --role implementation [--provider anthropic|openai] [--json]
|
|
gstack-models resolve --role eval-arms [--provider anthropic|openai] [--json]
|
|
gstack-models --help
|
|
|
|
Shows which model each gstack role uses and why. It reads config.yaml, the
|
|
GSTACK_CLAUDE_MODEL / GSTACK_CODEX_MODEL environment variables and native
|
|
provider settings, then exits. It is offline: no network, no paid model call,
|
|
no writes. A resolved model is not proof that your account can use it, that
|
|
the CLI is installed, or that the vendor still offers it.
|
|
|
|
Roles:
|
|
plan-review The opposing-provider reviewer for plan reviews. Default tier
|
|
frontier. Precedence: explicit request, then
|
|
GSTACK_CLAUDE_MODEL (anthropic) or GSTACK_CODEX_MODEL (openai),
|
|
then model_<tier>_<claude|openai>, then the shipped catalog.
|
|
plan_review_tier=host delegates to the host CLI's own settings:
|
|
the model is host-controlled and unknown until the review runs.
|
|
implementation The model gstack recommends for implementing an approved plan.
|
|
Default tier smart. Precedence: explicit request, then
|
|
model_<tier>_<claude|openai>, then the shipped catalog.
|
|
Environment overrides do not apply. Without --provider both
|
|
provider recommendations are shown. gstack never switches your
|
|
session's model.
|
|
eval-arms The arms /eval-plan compares: the implementation-tier model of
|
|
each provider under the same precedence (plan E1). Pricing per
|
|
arm comes from lib/pricing.ts via gstack-eval-plan arms.
|
|
|
|
Settings (gstack-config set|unset <key>):
|
|
plan_review_tier frontier | smart | host (default frontier)
|
|
implementation_tier frontier | smart (default smart)
|
|
model_frontier_claude, model_frontier_openai,
|
|
model_smart_claude, model_smart_openai (default: shipped catalog ID)
|
|
codex_effort ${CODEX_EFFORT_LEVELS.join(' | ')}
|
|
(default: each gate's own effort)
|
|
|
|
Codex reasoning effort: /codex --effort <level> (one request), then
|
|
GSTACK_CODEX_EFFORT (per run), then codex_effort, then the gate's default (high
|
|
for reviews, medium for /codex consult and design direction). /spec keeps medium
|
|
inside its 120 s budget.
|
|
|
|
A detected custom Anthropic or OpenAI endpoint (Bedrock, Vertex, Foundry, a base-URL
|
|
override or a Codex model_provider) never receives a shipped public ID: set a
|
|
per-tier model, an environment override (plan-review only) or host mode.
|
|
Detection reads the environment, the env block of Claude's user, project
|
|
(.claude/settings.json, .claude/settings.local.json in the working directory
|
|
and repository root) and managed settings files, and Codex's user and system
|
|
config.toml. Project-local Codex config is not inspected.
|
|
It cannot see server-managed or MDM policy, claude --settings, or
|
|
codex -c/--profile flags: if you route those to a custom endpoint, set a
|
|
per-tier model or host mode.
|
|
|
|
Output: --json prints one JSON document (schemaVersion 1) with a selections
|
|
array and structured errors. Human diagnostics go to stderr.
|
|
Exit: 0 valid resolution (including host delegation), 1 config or selection
|
|
failure, 2 invalid usage.
|
|
Docs: ${DOCS}
|
|
`;
|
|
|
|
function usage(problem: string, json: boolean): ModelsCliResult {
|
|
const error: CliError = { reason: 'usage', problem, cause: 'see gstack-models --help', key: null, source: null, repair: ['gstack-models --help'], docs: DOCS };
|
|
if (json) return { code: 2, stdout: `${JSON.stringify(document(null, null, [], [error]), null, 2)}\n`, stderr: '' };
|
|
return { code: 2, stdout: '', stderr: `gstack-models: ${problem}\n${MODELS_HELP.split('\n\n')[0]}\n` };
|
|
}
|
|
|
|
function parseProvider(value: string | undefined): ModelProvider | null {
|
|
return value === 'anthropic' || value === 'openai' ? value : null;
|
|
}
|
|
|
|
/** The effective Codex effort override, same precedence as gstack-codex-probe check-effort; null = each gate's default. */
|
|
export function codexEffortSetting(env: StateRootEnv): { value: string | null; source: 'env' | 'config' | 'gate-default'; valid: boolean } {
|
|
const pick = (value: string, source: 'env' | 'config') => ({ value, source, valid: (CODEX_EFFORT_LEVELS as readonly string[]).includes(value) });
|
|
if (env.GSTACK_CODEX_EFFORT) return pick(env.GSTACK_CODEX_EFFORT, 'env');
|
|
const stored = readConfigKey('codex_effort', env);
|
|
return stored ? pick(stored, 'config') : { value: null, source: 'gate-default', valid: true };
|
|
}
|
|
|
|
function document(command: 'list' | 'resolve' | null, config: ModelPolicyConfig | null, selections: ModelSelection[], errors: CliError[], env: StateRootEnv = {}) {
|
|
return {
|
|
schemaVersion: 1,
|
|
command,
|
|
ok: errors.length === 0,
|
|
offline: true,
|
|
note: OFFLINE_NOTE,
|
|
configPath: config?.path ?? null,
|
|
catalog: { version: MODEL_CATALOG_VERSION, sha256: modelCatalogSha256(), entries: MODEL_CATALOG },
|
|
settings: config ? settingsOf(config) : null,
|
|
tiers: config ? tiersOf(config) : null,
|
|
codexEffort: config ? codexEffortSetting(env) : null,
|
|
providerDetection: { scope: 'declared local settings files and environment only', unscanned: CUSTOM_PROVIDER_UNSCANNED },
|
|
selections,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
function settingsOf(config: ModelPolicyConfig) {
|
|
const settings: Record<string, { value: string | null; effective: string; origin: 'config' | 'default' }> = {
|
|
plan_review_tier: { value: config.planReviewTier.origin === 'config' ? config.planReviewTier.value : null, effective: config.planReviewTier.value, origin: config.planReviewTier.origin },
|
|
implementation_tier: { value: config.implementationTier.origin === 'config' ? config.implementationTier.value : null, effective: config.implementationTier.value, origin: config.implementationTier.origin },
|
|
};
|
|
for (const entry of MODEL_CATALOG) {
|
|
const override = config.overrides[entry.provider][entry.tier];
|
|
settings[MODEL_OVERRIDE_KEYS[entry.provider][entry.tier]] = { value: override, effective: override ?? entry.model, origin: override ? 'config' : 'default' };
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
function tiersOf(config: ModelPolicyConfig) {
|
|
return MODEL_CATALOG.map(entry => ({
|
|
tier: entry.tier,
|
|
provider: entry.provider,
|
|
catalogModel: entry.model,
|
|
verifiedAt: entry.verifiedAt,
|
|
sourceUrl: entry.sourceUrl,
|
|
overrideKey: MODEL_OVERRIDE_KEYS[entry.provider][entry.tier],
|
|
override: config.overrides[entry.provider][entry.tier],
|
|
configuredModel: config.overrides[entry.provider][entry.tier] ?? entry.model,
|
|
}));
|
|
}
|
|
|
|
function attempt(role: ModelRole, provider: ModelProvider, run: () => ModelSelection[], selections: ModelSelection[], errors: CliError[]): void {
|
|
try {
|
|
selections.push(...run());
|
|
} catch (error) {
|
|
if (!(error instanceof ModelPolicyError)) throw error;
|
|
errors.push({ ...error.toJSON(), role, provider });
|
|
}
|
|
}
|
|
|
|
function renderError(error: CliError): string {
|
|
const scope = error.role ? ` (${error.role}${error.provider ? ` via ${error.provider}` : ''})` : '';
|
|
return [
|
|
`gstack-models: ${error.problem}${scope}`,
|
|
` cause: ${error.cause}`,
|
|
...(error.key ? [` source: ${error.key}`] : []),
|
|
...error.repair.map(step => ` fix: ${step}`),
|
|
` docs: ${error.docs}`,
|
|
].join('\n') + '\n';
|
|
}
|
|
|
|
function renderSelections(role: ModelRole, config: ModelPolicyConfig, selections: ModelSelection[]): string[] {
|
|
const choice = role === 'plan-review' ? config.planReviewTier : config.implementationTier;
|
|
const purpose = role === 'plan-review' ? 'opposing-provider plan evaluation' : 'recommendation only; gstack never switches your session';
|
|
const lines = [`${role}: tier ${choice.value} (${choice.origin === 'config' ? `${choice.key} in ${choice.path}` : `default; set ${choice.key}`}) - ${purpose}`];
|
|
for (const selection of selections.filter(s => s.role === role)) {
|
|
lines.push(` ${describeSelection(selection)}`);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function renderList(config: ModelPolicyConfig, selections: ModelSelection[], env: StateRootEnv): string {
|
|
const effort = codexEffortSetting(env);
|
|
const commands = modelPolicyCommands();
|
|
const configCommand = `'${CONFIG_BIN.replaceAll('\\', '/').replaceAll("'", "'\\''")}'`;
|
|
const lines = [
|
|
`gstack model policy (${OFFLINE_NOTE})`,
|
|
`config: ${config.path}${config.exists ? '' : ' (not created yet; defaults apply)'}`,
|
|
'',
|
|
...renderSelections('plan-review', config, selections),
|
|
...renderSelections('implementation', config, selections),
|
|
'',
|
|
`codex effort: ${effort.value ?? 'each gate\'s default'} (${effort.source === 'env' ? 'GSTACK_CODEX_EFFORT' : effort.source === 'config' ? `codex_effort in ${config.path}` : 'default; set codex_effort'})${effort.valid ? '' : ` - invalid; Codex calls stop before sending. Use ${CODEX_EFFORT_LEVELS.join(', ')}`}; /spec keeps medium`,
|
|
'',
|
|
`catalog v${MODEL_CATALOG_VERSION} (sha256 ${modelCatalogSha256().slice(0, 12)})`,
|
|
];
|
|
for (const tier of MODEL_TIERS) {
|
|
for (const provider of MODEL_PROVIDERS) {
|
|
const entry = MODEL_CATALOG.find(e => e.tier === tier && e.provider === provider)!;
|
|
const override = config.overrides[provider][tier];
|
|
lines.push(` ${tier.padEnd(8)} ${provider.padEnd(9)} ${entry.model.padEnd(18)} verified ${entry.verifiedAt} ${MODEL_OVERRIDE_KEYS[provider][tier]}: ${override ?? 'unset'}`);
|
|
lines.push(` ${configCommand} set ${MODEL_OVERRIDE_KEYS[provider][tier]} '${override ?? entry.model}'`);
|
|
lines.push(` ${configCommand} unset ${MODEL_OVERRIDE_KEYS[provider][tier]}`);
|
|
}
|
|
}
|
|
lines.push(
|
|
'',
|
|
'change (gstack-config is ' + CONFIG_BIN + '):',
|
|
` ${commands.useSmart}`,
|
|
` ${commands.useHost}`,
|
|
` ${commands.restoreDefault}`,
|
|
'environment overrides GSTACK_CLAUDE_MODEL / GSTACK_CODEX_MODEL outrank plan-review tier settings',
|
|
'custom-endpoint detection reads local settings files only; server-managed/MDM policy and CLI flags are not visible (see --help)',
|
|
);
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
function renderEvalArms(config: ModelPolicyConfig, selections: ModelSelection[]): string {
|
|
const choice = config.implementationTier;
|
|
const lines = [`eval-arms: tier ${choice.value} (${choice.origin === 'config' ? `${choice.key} in ${choice.path}` : `default; set ${choice.key}`}) - one arm per provider; price them with gstack-eval-plan arms`];
|
|
for (const s of selections) lines.push(` ${s.provider}: ${s.requestedModel ?? 'host-controlled'}`);
|
|
lines.push(`(${OFFLINE_NOTE})`);
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
function renderResolve(role: ModelRole, config: ModelPolicyConfig, selections: ModelSelection[]): string {
|
|
const lines = renderSelections(role, config, selections);
|
|
if (selections.some(s => s.status === 'delegated-host')) {
|
|
lines.push(' host-controlled: gstack passes no model; the actual model is reported after the review runs');
|
|
}
|
|
lines.push(`(${OFFLINE_NOTE})`);
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
export function modelsMain(args: string[], env: StateRootEnv = process.env, platform: NodeJS.Platform = process.platform, cwd: string = process.cwd()): ModelsCliResult {
|
|
const json = args.includes('--json');
|
|
if (args.includes('--help') || args.includes('-h')) return { code: 0, stdout: MODELS_HELP, stderr: '' };
|
|
const rest = [...args];
|
|
const command = rest[0] === undefined || rest[0].startsWith('--') ? 'list' : rest.shift()!;
|
|
if (command !== 'list' && command !== 'resolve') return usage(`unknown command '${command}'`, json);
|
|
const options: Record<string, string> = {};
|
|
while (rest.length) {
|
|
const arg = rest.shift()!;
|
|
if (arg === '--json') continue;
|
|
const [name, inline] = arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined];
|
|
if (command === 'list' || (name !== '--role' && name !== '--provider')) return usage(`unknown argument '${arg}' for ${command}`, json);
|
|
if (options[name] !== undefined) return usage(`${name} given twice`, json);
|
|
const value = inline ?? rest.shift();
|
|
if (value === undefined || value === '' || value.startsWith('--')) return usage(`${name} needs a value`, json);
|
|
options[name] = value;
|
|
}
|
|
const role = options['--role'];
|
|
const providerArg = options['--provider'];
|
|
const provider = parseProvider(providerArg);
|
|
if (command === 'resolve') {
|
|
if (role !== 'plan-review' && role !== 'implementation' && role !== 'eval-arms') return usage(role === undefined ? 'resolve needs --role plan-review|implementation|eval-arms' : `unknown role '${role}': use plan-review, implementation or eval-arms`, json);
|
|
if (providerArg !== undefined && !provider) return usage(`unknown provider '${providerArg}': use anthropic or openai`, json);
|
|
if (role === 'plan-review' && !provider) return usage('resolve --role plan-review needs --provider anthropic|openai (the opposing provider for your host)', json);
|
|
}
|
|
|
|
let config: ModelPolicyConfig;
|
|
try {
|
|
config = readModelPolicyConfig({ env, platform });
|
|
} catch (error) {
|
|
if (!(error instanceof ModelPolicyError)) throw error;
|
|
const errors: CliError[] = [error.toJSON()];
|
|
return json
|
|
? { code: 1, stdout: `${JSON.stringify(document(command, null, [], errors), null, 2)}\n`, stderr: '' }
|
|
: { code: 1, stdout: '', stderr: errors.map(renderError).join('') };
|
|
}
|
|
|
|
const selections: ModelSelection[] = [];
|
|
const errors: CliError[] = [];
|
|
const roles: ModelRole[] = command === 'list' ? ['plan-review', 'implementation'] : [role === 'eval-arms' ? 'implementation' : role as ModelRole];
|
|
for (const r of roles) {
|
|
for (const p of provider ? [provider] : MODEL_PROVIDERS) {
|
|
attempt(r, p, () => r === 'plan-review'
|
|
? [resolvePlanReviewModel({ provider: p, env, platform, cwd, config })]
|
|
: resolveImplementationModels({ provider: p, env, platform, cwd, config }), selections, errors);
|
|
}
|
|
}
|
|
// eval-arms (plan E1): the implementation-tier resolution of each provider, one arm each.
|
|
if (role === 'eval-arms') for (const s of selections) (s as { role: string }).role = 'eval-arms';
|
|
const code = errors.length ? 1 : 0;
|
|
if (json) return { code, stdout: `${JSON.stringify(document(command, config, selections, errors, env), null, 2)}\n`, stderr: '' };
|
|
const stdout = command === 'list' ? renderList(config, selections, env) : role === 'eval-arms' ? renderEvalArms(config, selections) : renderResolve(role as ModelRole, config, selections);
|
|
return { code, stdout, stderr: errors.map(renderError).join('') };
|
|
}
|