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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,27 @@ The first resolution sets a silent baseline. When an alias later changes target,
a toast and `/model-aliases` shows the previous target, the current target and when the change
was detected. Changing an alias's match or filter rules resets its baseline.

### Explain one alias

Run `/model-aliases explain github-copilot/sonnet` to see matching patterns,
rejected candidates and their reasons, and why the winner was selected. Eligible
runners-up show whether they lost on release date or the descending model ID
tie-break. Models outside the alias provider/include patterns are counted rather
than listed. The regular `/model-aliases` view remains compact.

The backend exposes `explain({ alias: "github-copilot/sonnet" })` on the existing
`opencode-model-aliases` RPC. Its structured report comes from the same resolution
that materialized the alias, confirmed against the final catalog. It contains
public decision fields only, with stable reason codes and deterministic ordering.
An unresolved alias identifies the failed stage; an inactive alias retains its
selection explanation. Failed refreshes or conflicting downstream rewrites return
`unavailable`, and an unconfigured key returns `unknown-alias`.

Explanation uses the catalog visible to the plugin's transform; the existing
config-disabled model limitation still applies. In strict mode, a startup failure
prevents the plugin and its RPC from becoming available. Explanation performs no
model requests and adds no persistent history or automatic logging.

## Limitations

- Only the `latest` strategy exists. The plugin can't rank by price or quality.
Expand Down
41 changes: 41 additions & 0 deletions scripts/smoke-opencode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,47 @@ async function main() {
}
}

// Explain the same packed product through the real host's RPC boundary.
const explained = await runSpawn(
"opencode",
[
"api",
"--standalone",
"post",
"/api/rpc/opencode-model-aliases/explain",
"--data",
JSON.stringify({ input: { alias: ALIAS_KEY } }),
],
{ cwd: project, env, timeoutMs: CLI_TIMEOUT_MS },
);
let explanation;
try {
explanation = JSON.parse(explained.stdout).output;
} catch {}
const selected = explanation?.explanation?.candidates?.filter(
(candidate) => candidate.outcome === "selected",
);
if (
explained.code !== 0 ||
explained.timedOut ||
explained.spawnError ||
explanation?.status !== "active" ||
explanation?.explanation?.winner !== `${PROVIDER}/${TARGET}` ||
selected?.length !== 1 ||
selected[0]?.id !== `${PROVIDER}/${TARGET}` ||
selected[0]?.reasons?.[0]?.code !== "newest-release"
) {
problems.push(
`explain RPC did not describe the actual latest winner (exit=${explained.code}): ${explained.stdout.slice(0, 3000)} ${explained.stderr
.split("\n")
.filter((line) => /error|invalid|Error/.test(line))
.slice(0, 5)
.join("\n")}`,
);
}
if (JSON.stringify(explanation ?? {}).includes(DUMMY_KEY))
problems.push("explain RPC exposed provider credentials");

if (problems.length === 0) {
const baseline = reportRows.find((row) => row.key === ALIAS_KEY);
if (baseline.transition !== undefined)
Expand Down
161 changes: 161 additions & 0 deletions src/explain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { isPlainObject } from "./config.js";
import { sanitize } from "./report.js";
import type { Stage } from "./resolve.js";

export interface ExplanationReason {
code: string;
message: string;
}

export interface CandidateExplanation {
id: string;
matchedPatterns: string[];
stage: Stage["name"];
outcome: "selected" | "eligible" | "rejected";
reasons: ExplanationReason[];
released?: number;
}

/** Public, primitive-only snapshot of the decisions made by one resolver run. */
export interface ResolutionExplanation {
alias: string;
strategy: "latest";
stages: Stage[];
unmatched: number;
candidates: CandidateExplanation[];
winner?: string;
failure?: ExplanationReason & { stage: Stage["name"] };
}

export type ExplainResponse =
| { status: "active" | "inactive" | "unresolved"; explanation: ResolutionExplanation }
| { status: "unknown-alias" }
| { status: "unavailable" };

function isStage(value: unknown): value is Stage["name"] {
return value === "matching" || value === "filtering" || value === "selection";
}
function reason(value: unknown): value is ExplanationReason {
return (
isPlainObject(value) && typeof value.code === "string" && typeof value.message === "string"
);
}
function count(value: unknown): boolean {
return typeof value === "number" && Number.isInteger(value) && value >= 0;
}

/** Validate the transport before rendering; the UI never evaluates selection rules. */
export function isExplainResponse(value: unknown): value is ExplainResponse {
if (!isPlainObject(value)) return false;
if (value.status === "unknown-alias" || value.status === "unavailable") return true;
if (value.status !== "active" && value.status !== "inactive" && value.status !== "unresolved")
return false;
const report = value.explanation;
if (
!isPlainObject(report) ||
typeof report.alias !== "string" ||
report.strategy !== "latest" ||
!count(report.unmatched) ||
!Array.isArray(report.stages) ||
!Array.isArray(report.candidates)
)
return false;
if (report.winner !== undefined && typeof report.winner !== "string") return false;
if (
report.failure !== undefined &&
(!isPlainObject(report.failure) || !reason(report.failure) || !isStage(report.failure.stage))
)
return false;
if (
value.status === "unresolved"
? report.failure === undefined || report.winner !== undefined
: report.winner === undefined || report.failure !== undefined
)
return false;
return (
report.stages.every(
(stage) => isPlainObject(stage) && isStage(stage.name) && count(stage.accepted),
) &&
report.candidates.every(
(candidate) =>
isPlainObject(candidate) &&
typeof candidate.id === "string" &&
Array.isArray(candidate.matchedPatterns) &&
candidate.matchedPatterns.every((pattern) => typeof pattern === "string") &&
isStage(candidate.stage) &&
(candidate.outcome === "selected" ||
candidate.outcome === "eligible" ||
candidate.outcome === "rejected") &&
Array.isArray(candidate.reasons) &&
candidate.reasons.every(reason) &&
(candidate.released === undefined ||
(typeof candidate.released === "number" &&
Number.isFinite(candidate.released) &&
candidate.released > 0)),
)
);
}

export function formatCandidateDetail(candidate: CandidateExplanation): string {
const marker =
candidate.outcome === "selected" ? "✓" : candidate.outcome === "rejected" ? "✗" : "·";
const lines = [
`${marker} ${sanitize(candidate.id)} (${candidate.outcome})`,
` matched: ${candidate.matchedPatterns.map(sanitize).join(", ")}`,
];
if (candidate.stage === "selection")
lines.push(" passed enabled/status and configured requirement filters");
if (candidate.outcome === "rejected") lines.push(` rejected at: ${candidate.stage}`);
if (candidate.released !== undefined) {
const date = new Date(candidate.released);
lines.push(
` released: ${Number.isFinite(date.getTime()) ? date.toISOString() : candidate.released}`,
);
}
for (const reason of candidate.reasons) lines.push(` ${sanitize(reason.message)}`);
return lines.join("\n");
}

export function formatExplanationOverview(
response: Extract<ExplainResponse, { explanation: ResolutionExplanation }>,
): string {
const report = response.explanation;
const lines = [
`Alias: ${sanitize(report.alias)}`,
`Strategy: ${report.strategy}`,
`Status: ${response.status}`,
report.stages.map((stage) => `${stage.name}: ${stage.accepted}`).join(" → "),
];
if (report.winner) lines.push(`Winner: ${sanitize(report.winner)}`);
if (report.failure)
lines.push(`Failed at ${report.failure.stage}: ${sanitize(report.failure.message)}`);
lines.push(
`Candidates: ${report.candidates.length}`,
`Other catalog models: ${report.unmatched} did not match the alias provider/include patterns.`,
);
return lines.join("\n");
}

export function formatExplanation(response: ExplainResponse): string {
if (response.status === "unknown-alias")
return "Unknown alias. Use /model-aliases to view configured aliases.";
if (response.status === "unavailable")
return "Model alias explanation is unavailable: the current mapping could not be confirmed.";
const report = response.explanation;
const lines = [
`Alias: ${sanitize(report.alias)}`,
`Strategy: ${report.strategy}`,
`Status: ${response.status}`,
];
lines.push(report.stages.map((stage) => `${stage.name}: ${stage.accepted}`).join(" → "));
if (report.failure)
lines.push(`Failed at ${report.failure.stage}: ${sanitize(report.failure.message)}`);
for (const candidate of report.candidates) {
lines.push("", formatCandidateDetail(candidate));
}
lines.push(
"",
`Other catalog models: ${report.unmatched} did not match the alias provider/include patterns.`,
);
return lines.join("\n");
}
71 changes: 60 additions & 11 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type Model, Plugin } from "@opencode/plugin";
import { isPlainObject, type NormalizedConfig, type Options } from "./config.js";
import { loadConfigFile } from "./config-file.js";
import type { ExplainResponse, ResolutionExplanation } from "./explain.js";
import { createHistory } from "./history.js";
import { aliasDisplayName } from "./names.js";
import { normalizeOptions } from "./normalize.js";
Expand Down Expand Up @@ -40,7 +41,12 @@ interface FloatingEditor {
* materialize; the caller only publishes them if the whole replay (including
* materialization) succeeded.
*/
function replay(config: NormalizedConfig, editor: FloatingEditor): AliasReportRow[] {
interface ReplayReport {
rows: readonly AliasReportRow[];
explanations: ReadonlyMap<string, ResolutionExplanation>;
}

function replay(config: NormalizedConfig, editor: FloatingEditor): ReplayReport {
const snapshot = editor.list();

// Configuration collision: the alias id already exists as a source model.
Expand Down Expand Up @@ -107,7 +113,10 @@ function replay(config: NormalizedConfig, editor: FloatingEditor): AliasReportRo
}
}

return buildRows(results);
return {
rows: buildRows(results),
explanations: new Map(results.map(({ alias, result }) => [alias.key, result.explanation])),
};
}

export default Plugin.define({
Expand Down Expand Up @@ -189,16 +198,15 @@ export default Plugin.define({
// describable current mapping. It is replaced once per replay, only if
// the whole replay/materialization succeeded; on any failure it is
// cleared so partial or stale mappings are never described as current.
let reportRows: readonly AliasReportRow[] | null = null;
let report: ReplayReport | null = null;

const registration = await ctx.model.transform((hostEditor) => {
try {
// Single host→adapter boundary: the host DeepMutable degrades strings
// with brand; here it is adapted to the clean editor view.
const rows = replay(normalized.config, hostEditor as unknown as FloatingEditor);
reportRows = rows;
report = replay(normalized.config, hostEditor as unknown as FloatingEditor);
} catch (error) {
reportRows = null;
report = null;
if (initializing && !hasInitialError) {
hasInitialError = true;
initialError = error;
Expand Down Expand Up @@ -242,10 +250,11 @@ export default Plugin.define({
// one; unavailability text and empty rows.
return { text: UNAVAILABLE_REPORT, rows: [] };
}
const snapshot = reportRows;
if (snapshot === null) {
const current = report;
if (current === null) {
return { text: UNAVAILABLE_REPORT, rows: [] };
}
const snapshot = current.rows;
// Final visibility by primitives: an alias disabled by a later
// policy is not labeled active; a retired alias keeps the existing
// behavior (inactive).
Expand All @@ -269,17 +278,57 @@ export default Plugin.define({
}
}
const rows = await history.observe(snapshot, visible);
// Storage is asynchronous: a host replay may supersede this decision while
// history is being saved. Never present that old mapping as current.
if (report !== current) return { text: UNAVAILABLE_REPORT, rows: [] };
return {
text: formatReport(rows, visible),
rows: buildInspectRows(rows, visible),
report: current,
visible,
};
};
let pending = Promise.resolve();
let stopped = false;
const inspect = () => {
const result = pending.then(() =>
stopped ? { text: UNAVAILABLE_REPORT, rows: [] } : readInspection(),
const result = pending.then(async () => {
const response = stopped ? { text: UNAVAILABLE_REPORT, rows: [] } : await readInspection();
return { text: response.text, rows: response.rows };
});
pending = result.then(
() => {},
() => {},
);
return result;
};
const explain = (input: unknown): Promise<ExplainResponse> => {
if (
!isPlainObject(input) ||
typeof input.alias !== "string" ||
input.alias.length === 0 ||
Object.keys(input).some((key) => key !== "alias")
) {
return Promise.reject(new Error("Expected { alias: <provider/alias> }"));
}
const alias = input.alias;
const result = pending.then(async (): Promise<ExplainResponse> => {
if (stopped) return { status: "unavailable" };
if (!normalized.config.aliases.some((entry) => entry.key === alias))
return { status: "unknown-alias" };
const inspection = await readInspection();
const explanation = inspection.report?.explanations.get(alias);
// Escaped display keys are not identities: different raw keys can render
// identically (for example a newline and a literal "\\u000a").
const row = inspection.report?.rows.find((entry) => entry.key === alias);
if (!explanation || !row) return { status: "unavailable" };
const status =
row.status === "unresolved"
? "unresolved"
: inspection.visible?.has(alias)
? "active"
: "inactive";
return { status, explanation: structuredClone(explanation) };
});
pending = result.then(
() => {},
() => {},
Expand All @@ -288,7 +337,7 @@ export default Plugin.define({
};
let rpcRegistration: { dispose: () => Promise<void> };
try {
rpcRegistration = await ctx.rpc.register(ModelAliasesRpc, { inspect });
rpcRegistration = await ctx.rpc.register(ModelAliasesRpc, { inspect, explain });
} catch (error) {
await registration.dispose();
throw error;
Expand Down
Loading
Loading