Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-models-command.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions cmd/gh-aw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ type commandSet struct {
addCmd, addWizardCmd, updateCmd, deployCmd, trialCmd, initCmd, statusCmd, listCmd *cobra.Command
mcpCmd, logsCmd, auditCmd, viewCmd, healthCmd, outcomesCmd, mcpServerCmd, prCmd, secretsCmd *cobra.Command
fixCmd, upgradeCmd, completionCmd, hashCmd, projectCmd, doctorCmd, checksCmd, validateCmd, lintCmd *cobra.Command
domainsCmd, experimentsCmd, forecastCmd, envCmd *cobra.Command
domainsCmd, experimentsCmd, forecastCmd, envCmd, modelsCmd *cobra.Command
}

func fixPathForCommand(s string) string {
Expand Down Expand Up @@ -736,6 +736,7 @@ func createCommandSet() commandSet {
experimentsCmd: cli.NewExperimentsCommand(),
forecastCmd: cli.NewForecastCommand(),
envCmd: cli.NewEnvCommand(),
modelsCmd: cli.NewModelsCommand(),
}
cli.RegisterEngineFlagCompletion(cmds.initCmd)
return cmds
Expand Down Expand Up @@ -850,7 +851,7 @@ func assignCommandGroups(cmds commandSet) {
runCmd.GroupID, enableCmd.GroupID, disableCmd.GroupID, cmds.trialCmd.GroupID = "execution", "execution", "execution", "execution"
cmds.logsCmd.GroupID, cmds.auditCmd.GroupID, cmds.viewCmd.GroupID = "analysis", "analysis", "analysis"
cmds.healthCmd.GroupID, cmds.outcomesCmd.GroupID, cmds.checksCmd.GroupID = "analysis", "analysis", "analysis"
cmds.statusCmd.GroupID, cmds.listCmd.GroupID, cmds.experimentsCmd.GroupID, cmds.forecastCmd.GroupID = "analysis", "analysis", "analysis", "analysis"
cmds.statusCmd.GroupID, cmds.listCmd.GroupID, cmds.experimentsCmd.GroupID, cmds.forecastCmd.GroupID, cmds.modelsCmd.GroupID = "analysis", "analysis", "analysis", "analysis", "analysis"
cmds.mcpServerCmd.GroupID, cmds.prCmd.GroupID, cmds.completionCmd.GroupID, cmds.hashCmd.GroupID, cmds.projectCmd.GroupID = "utilities", "utilities", "utilities", "utilities", "utilities"
}

Expand All @@ -860,7 +861,7 @@ func addCommandsToRoot(cmds commandSet) {
runCmd, removeCmd, cmds.statusCmd, cmds.listCmd, enableCmd, disableCmd, cmds.logsCmd, cmds.auditCmd, cmds.viewCmd,
cmds.healthCmd, cmds.outcomesCmd, cmds.checksCmd, cmds.mcpCmd, cmds.mcpServerCmd, cmds.prCmd, versionCmd, cmds.secretsCmd,
cmds.fixCmd, cmds.validateCmd, cmds.lintCmd, cmds.completionCmd, cmds.hashCmd, cmds.projectCmd, cmds.doctorCmd,
cmds.domainsCmd, cmds.experimentsCmd, cmds.forecastCmd, cmds.envCmd,
cmds.domainsCmd, cmds.experimentsCmd, cmds.forecastCmd, cmds.modelsCmd, cmds.envCmd,
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# ADR-55148: `gh aw models` Command for Catalog Pricing, Aliases, and Observed Models

**Date**: 2026-08-23
**Status**: Draft
**Deciders**: pelikhan, copilot-swe-agent

---

### Context

Choosing a model for a workflow currently requires reading three disconnected sources: the embedded models catalog (`pkg/cli/model_costs.go`, providing per-token pricing), the built-in alias map in `pkg/workflow` (which alias resolves to which ordered list of concrete model IDs), and downloaded run artifacts (`summary.json`, per-run token usage files, and `awf-reflect.json`) that show which models automation has actually used. Agents and maintainers had no single command that answers "what can I pick, what does it cost, and what is actually in use here?", so model selection relied on grepping the repository or reading raw JSON artifacts.

### Decision

We add a dedicated read-only `gh aw models` CLI command in the analysis command group (`pkg/cli/models_command.go`) that renders three sections — catalog pricing, alias resolution order, and models observed in local automation artifacts — with `--json` for machine consumption.

Observed-model discovery aggregates three artifact sources under a shared record keyed by normalized `provider/model`, merging provenance labels and occurrence counts. Because `summary.json` is generated from the sibling `run-*` directories, run IDs recorded in the summary are skipped when walking run directories, so a run's requests are counted once rather than twice.

Catalog membership is provider-scoped: an observation with a known provider is matched against `provider/model` catalog IDs only, and the bare model-name index is consulted only for observations with no provider, so an unrelated `other/gpt-5.4` is not reported as catalog-backed just because `gpt-5.4` exists under another provider.

For the optional artifact refresh (`--refresh-observed`, on by default), the command reuses `DownloadWorkflowLogs` with a new `SuppressRender` option. That option stops the logs orchestrator after artifacts and the summary file are written, so the refresh does not emit its own report onto stdout and `gh aw models --json` stays a single valid JSON document.

### Alternatives Considered

#### Alternative 1: Extend `gh aw logs` or `gh aw audit` With a Models View

Add a `--models` flag to an existing analysis command instead of introducing a new one. This avoids growing the command surface, but both commands are run-centric (they take run selectors, dates, and artifact filters), whereas catalog pricing and alias resolution are static repository data unrelated to any run. Overloading them would make the flag semantics conditional on unrelated options and would still require the same suppression work for JSON output. Rejected as a worse fit for the data being reported.

#### Alternative 2: Capture Rendered Output Instead of Adding `SuppressRender`

Redirect `os.Stdout` around the refresh call and discard whatever the logs orchestrator prints. This keeps the change local to the new command, but process-global stdout swapping is not concurrency-safe, hides genuine errors, and silently discards warnings that the orchestrator writes intentionally. Rejected in favour of separating downloading from rendering with an explicit option.

#### Alternative 3: Read Only `summary.json` for Observed Models

Restrict discovery to the aggregated summary file, avoiding both the run-directory walk and the de-duplication problem entirely. This loses models seen only in `awf-reflect.json` endpoint lists (models the sandbox exposed but the summary never attributed) and produces nothing at all when a logs directory contains run artifacts without a generated summary. Rejected because endpoint-level model availability is a primary reason to run the command.

### Consequences

#### Positive
- One command answers model selection questions that previously required reading three separate sources.
- `--json` output is a single valid document, so agents can pipe it directly into `jq`.
- `SuppressRender` gives any future caller a supported way to download artifacts without emitting a report, instead of each caller inventing its own stdout suppression.

#### Negative
- Observed-model results depend on artifact layout conventions (`run-<id>` directories, `summary.json` `run_id` fields, `awf-reflect.json` endpoint shape); a change to any of those degrades the observed section until the collectors are updated.
- The default refresh performs network calls, so the command is slower than a purely local report unless `--refresh-observed=false` is passed.

#### Neutral
- The command is read-only: it never writes workflow files and only writes artifacts through the existing logs download path.
- Provider-scoped catalog matching means observations from providers absent from the embedded catalog are reported as not-in-catalog rather than being matched by model name alone.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
16 changes: 16 additions & 0 deletions docs/src/content/docs/setup/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,22 @@ gh aw outcomes history --repo owner/repo --json # JSON output for another re

**Options:** `--limit`, `--source`, `--json/-j`, `--repo/-r`

#### `models`

List model catalog pricing, built-in aliases and their resolution order, and models observed in local automation artifacts.

```bash wrap
gh aw models # Catalog, aliases, and observed models
gh aw models --json # JSON output
gh aw models --logs-dir .github/aw/logs # Read observed models from another logs directory
gh aw models --refresh-count 50 # Inspect more recent runs when refreshing
gh aw models --refresh-observed=false # Skip the artifact refresh (local data only)
```

Observed models are aggregated from `summary.json` token usage, per-run token usage artifacts, and `awf-reflect.json` endpoint model lists. By default the command first refreshes those artifacts from recent runs; the refresh writes no report of its own, so `--json` output stays a single JSON document.

**Options:** `--json/-j`, `--logs-dir`, `--refresh-observed`, `--refresh-count`, `--repo/-r`

#### `health`

Display workflow health metrics and success rates.
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/logs_orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,5 +323,6 @@ func DownloadWorkflowLogs(ctx context.Context, opts LogsDownloadOptions) error {
endDate: opts.EndDate,
checkStaleness: true,
countLimitReached: countLimitReached,
suppressRender: opts.SuppressRender,
})
}
3 changes: 3 additions & 0 deletions pkg/cli/logs_orchestrator_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ func renderLogsOutput(processedRuns []ProcessedRun, opts renderLogsOutputOptions
}

// Render output based on format preference.
if opts.suppressRender {
return nil
}
switch opts.format {
case "tsv":
if opts.verbose {
Expand Down
8 changes: 8 additions & 0 deletions pkg/cli/logs_orchestrator_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ type LogsDownloadOptions struct {
ArtifactSets []string
After string
ReportFile string
// SuppressRender downloads and processes runs (including writing the summary
// file) without emitting any report to stdout. Callers that only need the
// downloaded artifacts, and that own stdout themselves, set this so their own
// output is not interleaved with the logs report.
SuppressRender bool
}

// StdinLogsOptions holds parameters for DownloadWorkflowLogsFromStdin.
Expand Down Expand Up @@ -108,4 +113,7 @@ type renderLogsOutputOptions struct {
// scope dateRangeCoverageWarning to the cause it actually describes, rather
// than firing for timeout-driven continuations too.
countLimitReached bool
// suppressRender skips all report rendering after the summary file has been
// written, for callers that only want the downloaded artifacts.
suppressRender bool
}
27 changes: 27 additions & 0 deletions pkg/cli/logs_output_hint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,30 @@ func TestRenderLogsOutputStaleWarningGatedByCheckStaleness(t *testing.T) {
assert.NotContains(t, stdout, "No start_date/end_date was specified")
})
}

func TestRenderLogsOutputSuppressRenderWritesNothing(t *testing.T) {
processedRuns := []ProcessedRun{{
Run: WorkflowRun{
DatabaseID: 1,
Status: "completed",
WorkflowName: "logs",
CreatedAt: time.Now(),
},
}}

for _, format := range []string{"", "console", "tsv", "markdown", "pretty"} {
t.Run(format, func(t *testing.T) {
stdout, stderr := captureOutput(t, func() error {
return renderLogsOutput(processedRuns, renderLogsOutputOptions{
outputDir: t.TempDir(),
format: format,
artifactFilter: []string{"usage"},
suppressRender: true,
})
})

assert.Empty(t, stdout, "suppressed rendering must not write to stdout")
assert.Empty(t, stderr, "suppressed rendering must not write to stderr")
})
}
}
Loading
Loading