opencode-magi 0.0.0-dev-20260524220537 → 0.0.0-dev-20260525005102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -155,6 +155,20 @@ Add the following content to the configuration file.
155
155
 
156
156
  Entries with `ref` are expanded from `agents.refs`. Fields set alongside `ref` override fields from the preset.
157
157
 
158
+ `model` can be a single `provider/model` string or an ordered candidate array. Candidate arrays are resolved during validation against OpenCode's model catalog; the first available model is selected. Put provider-specific options on object candidates, not on the agent role.
159
+
160
+ ```json
161
+ {
162
+ "model": [
163
+ "anthropic/claude-sonnet-4-5",
164
+ {
165
+ "id": "openai/gpt-5.1",
166
+ "options": { "reasoningEffort": "high" }
167
+ }
168
+ ]
169
+ }
170
+ ```
171
+
158
172
  After refs are expanded, `review.agents[].account` is the GitHub account used to post reviews and approvals. Must be authenticated with `gh auth token --user <account>` and must be unique. `merge.editor.account` is used by `/magi:merge` to push fixes, close PRs, and merge PRs.
159
173
 
160
174
  #### Validate config
@@ -33,6 +33,12 @@ export function triageAgentKey(agent, index) {
33
33
  export function validateReviewerId(id) {
34
34
  return ID_PATTERN.test(id);
35
35
  }
36
+ function normalizedModel(model) {
37
+ if (typeof model !== "string") {
38
+ throw new Error("model must be normalized before resolving agents");
39
+ }
40
+ return model;
41
+ }
36
42
  function clonePermissionValue(value) {
37
43
  return typeof value === "string" ? value : { ...value };
38
44
  }
@@ -86,6 +92,7 @@ export function resolveAgents(config) {
86
92
  editor: editor
87
93
  ? {
88
94
  ...editor,
95
+ model: normalizedModel(editor.model),
89
96
  permission: resolveEditorPermission(agents, editor),
90
97
  }
91
98
  : undefined,
@@ -93,18 +100,21 @@ export function resolveAgents(config) {
93
100
  ...reviewer,
94
101
  key: reviewerKey(reviewer, index),
95
102
  index,
103
+ model: normalizedModel(reviewer.model),
96
104
  permission: resolveReviewerPermission(agents, reviewer),
97
105
  })),
98
106
  triage: (config.triage?.agents ?? []).map((agent, index) => ({
99
107
  ...agent,
100
108
  key: triageAgentKey(agent, index),
101
109
  index,
110
+ model: normalizedModel(agent.model),
102
111
  permission: resolveTriageAgentPermission(agents, agent),
103
112
  })),
104
113
  triageCreator: creator
105
114
  ? {
106
115
  ...creator,
107
116
  account: creator.account ?? "",
117
+ model: normalizedModel(creator.model),
108
118
  permission: resolveTriageCreatorPermission(agents, creator),
109
119
  }
110
120
  : undefined,
@@ -4,7 +4,7 @@ import { access } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { isAbsolute, join } from "node:path";
6
6
  import schema from "../../schema.json" with { type: "json" };
7
- import { resolveAgents, validateReviewerId } from "./resolve";
7
+ import { resolveAgents, reviewerKey, triageAgentKey, validateReviewerId, } from "./resolve";
8
8
  const RESERVED_REVIEWER_KEYS = new Set(["editor", "orchestrator", "system"]);
9
9
  const PERMISSION_ACTIONS = new Set(["allow", "ask", "deny"]);
10
10
  const AJV = new Ajv2020({ allErrors: true, strict: false });
@@ -27,7 +27,6 @@ const REVIEWER_KEYS = new Set([
27
27
  "account",
28
28
  "id",
29
29
  "model",
30
- "options",
31
30
  "permissions",
32
31
  "persona",
33
32
  ]);
@@ -35,7 +34,6 @@ const EDITOR_KEYS = new Set([
35
34
  "account",
36
35
  "author",
37
36
  "model",
38
- "options",
39
37
  "permissions",
40
38
  "persona",
41
39
  ]);
@@ -43,7 +41,6 @@ const TRIAGE_AGENT_KEYS = new Set([
43
41
  "account",
44
42
  "id",
45
43
  "model",
46
- "options",
47
44
  "permissions",
48
45
  "persona",
49
46
  ]);
@@ -51,7 +48,6 @@ const TRIAGE_CREATOR_KEYS = new Set([
51
48
  "account",
52
49
  "author",
53
50
  "model",
54
- "options",
55
51
  "permissions",
56
52
  "persona",
57
53
  ]);
@@ -145,6 +141,7 @@ const TRIAGE_PROMPT_KEYS = new Set([
145
141
  "existingPr",
146
142
  "reconsider",
147
143
  ]);
144
+ const MODEL_CANDIDATE_KEYS = new Set(["id", "options"]);
148
145
  function githubHost(config) {
149
146
  return config.github?.host ?? "github.com";
150
147
  }
@@ -275,26 +272,85 @@ function validatePermissionConfig(permission, path, errors) {
275
272
  }
276
273
  }
277
274
  }
278
- function validateModel(model, path, errors, catalog) {
279
- if (!model)
280
- return;
275
+ function modelValidationError(model, path, catalog) {
281
276
  const slash = model.indexOf("/");
282
- if (slash <= 0 || slash === model.length - 1) {
283
- errors.push(`${path} must be a full OpenCode model ID in provider/model form`);
284
- return;
285
- }
277
+ if (slash <= 0 || slash === model.length - 1)
278
+ return `${path} must be a full OpenCode model ID in provider/model form`;
286
279
  if (!catalog)
287
- return;
280
+ return undefined;
288
281
  const providerId = model.slice(0, slash);
289
282
  const modelId = model.slice(slash + 1);
290
283
  const models = catalog[providerId];
291
- if (!models) {
292
- errors.push(`${path} uses unknown OpenCode provider: ${providerId}`);
284
+ if (!models)
285
+ return `${path} uses unknown OpenCode provider: ${providerId}`;
286
+ if (!models.includes(modelId))
287
+ return `${path} uses unknown OpenCode model: ${model}`;
288
+ return undefined;
289
+ }
290
+ function validateModelId(model, path, errors, catalog) {
291
+ const error = modelValidationError(model, path, catalog);
292
+ if (error) {
293
+ errors.push(error);
294
+ return false;
295
+ }
296
+ return true;
297
+ }
298
+ function readModelCandidate(value, path, errors) {
299
+ if (typeof value === "string")
300
+ return { id: value };
301
+ if (!isPlainObject(value)) {
302
+ errors.push(`${path} must be a string or an object`);
303
+ return undefined;
304
+ }
305
+ validateKnownKeys(value, path, MODEL_CANDIDATE_KEYS, errors);
306
+ if (typeof value.id !== "string") {
307
+ errors.push(`${path}.id must be a string`);
308
+ return undefined;
309
+ }
310
+ if (value.options != null && !isPlainObject(value.options)) {
311
+ errors.push(`${path}.options must be an object`);
312
+ return undefined;
313
+ }
314
+ return { id: value.id, options: value.options };
315
+ }
316
+ function validateAndNormalizeModel(target, path, errors, catalog) {
317
+ const model = target.model;
318
+ if (typeof model === "string") {
319
+ validateModelId(model, path, errors, catalog);
293
320
  return;
294
321
  }
295
- if (!models.includes(modelId)) {
296
- errors.push(`${path} uses unknown OpenCode model: ${model}`);
322
+ if (!Array.isArray(model)) {
323
+ if (model != null)
324
+ errors.push(`${path} must be a string or an array`);
325
+ return;
297
326
  }
327
+ if (!model.length) {
328
+ errors.push(`${path} must contain at least one model candidate`);
329
+ return;
330
+ }
331
+ if (!catalog) {
332
+ errors.push(`${path} requires an OpenCode model catalog`);
333
+ return;
334
+ }
335
+ const candidateErrors = [];
336
+ for (const [index, value] of model.entries()) {
337
+ const candidatePath = `${path}[${index}]`;
338
+ const candidate = readModelCandidate(value, candidatePath, errors);
339
+ if (!candidate)
340
+ continue;
341
+ const idPath = isPlainObject(value) ? `${candidatePath}.id` : candidatePath;
342
+ const error = modelValidationError(candidate.id, idPath, catalog);
343
+ if (!error) {
344
+ target.model = candidate.id;
345
+ if (candidate.options)
346
+ target.options = candidate.options;
347
+ else
348
+ delete target.options;
349
+ return;
350
+ }
351
+ candidateErrors.push(error);
352
+ }
353
+ errors.push(`${path} must contain at least one usable OpenCode model candidate${candidateErrors.length ? ` (${candidateErrors.join("; ")})` : ""}`);
298
354
  }
299
355
  function validateReviewerList(reviewers, path, errors, catalog) {
300
356
  if (reviewers == null)
@@ -315,14 +371,11 @@ function validateReviewerList(reviewers, path, errors, catalog) {
315
371
  validateKnownKeys(reviewer, `${path}[${index}]`, REVIEWER_KEYS, errors);
316
372
  if (!reviewer.model)
317
373
  errors.push(`${path}[${index}].model is required`);
318
- validateString(reviewer.model, `${path}[${index}].model`, errors);
319
- validateModel(reviewer.model, `${path}[${index}].model`, errors, catalog);
374
+ validateAndNormalizeModel(reviewer, `${path}[${index}].model`, errors, catalog);
320
375
  if (!reviewer.account)
321
376
  errors.push(`${path}[${index}].account is required`);
322
377
  validateString(reviewer.account, `${path}[${index}].account`, errors);
323
378
  validateString(reviewer.persona, `${path}[${index}].persona`, errors);
324
- if (reviewer.options != null && !isPlainObject(reviewer.options))
325
- errors.push(`${path}[${index}].options must be an object`);
326
379
  validatePermissionConfig(reviewer.permissions, `${path}[${index}].permissions`, errors);
327
380
  if (reviewer.id) {
328
381
  if (!validateReviewerId(reviewer.id)) {
@@ -353,14 +406,11 @@ function validateTriageAgentList(agents, path, errors, catalog) {
353
406
  validateKnownKeys(agent, `${path}[${index}]`, TRIAGE_AGENT_KEYS, errors);
354
407
  if (!agent.model)
355
408
  errors.push(`${path}[${index}].model is required`);
356
- validateString(agent.model, `${path}[${index}].model`, errors);
357
- validateModel(agent.model, `${path}[${index}].model`, errors, catalog);
409
+ validateAndNormalizeModel(agent, `${path}[${index}].model`, errors, catalog);
358
410
  if (!agent.account)
359
411
  errors.push(`${path}[${index}].account is required`);
360
412
  validateString(agent.account, `${path}[${index}].account`, errors);
361
413
  validateString(agent.persona, `${path}[${index}].persona`, errors);
362
- if (agent.options != null && !isPlainObject(agent.options))
363
- errors.push(`${path}[${index}].options must be an object`);
364
414
  validatePermissionConfig(agent.permissions, `${path}[${index}].permissions`, errors);
365
415
  if (agent.id) {
366
416
  if (!validateReviewerId(agent.id)) {
@@ -406,15 +456,11 @@ function validateEditor(editor, path, errors, catalog) {
406
456
  if (!editor.model)
407
457
  errors.push(`${path}.model is required`);
408
458
  validateKnownKeys(editor, path, EDITOR_KEYS, errors);
409
- validateString(editor.model, `${path}.model`, errors);
459
+ validateAndNormalizeModel(editor, `${path}.model`, errors, catalog);
410
460
  validateString(editor.account, `${path}.account`, errors);
411
461
  validateString(editor.persona, `${path}.persona`, errors);
412
- validateModel(editor.model, `${path}.model`, errors, catalog);
413
462
  if (!editor.account)
414
463
  errors.push(`${path}.account is required`);
415
- if (editor.options != null && !isPlainObject(editor.options)) {
416
- errors.push(`${path}.options must be an object`);
417
- }
418
464
  validatePermissionConfig(editor.permissions, `${path}.permissions`, errors);
419
465
  const author = editor.author;
420
466
  if (!author || !isPlainObject(author)) {
@@ -450,12 +496,8 @@ function validateTriageCreator(creator, path, errors, catalog) {
450
496
  if (!creator.model)
451
497
  errors.push(`${path}.model is required`);
452
498
  validateString(creator.account, `${path}.account`, errors);
453
- validateString(creator.model, `${path}.model`, errors);
499
+ validateAndNormalizeModel(creator, `${path}.model`, errors, catalog);
454
500
  validateString(creator.persona, `${path}.persona`, errors);
455
- validateModel(creator.model, `${path}.model`, errors, catalog);
456
- if (creator.options != null && !isPlainObject(creator.options)) {
457
- errors.push(`${path}.options must be an object`);
458
- }
459
501
  validatePermissionConfig(creator.permissions, `${path}.permissions`, errors);
460
502
  const author = creator.author;
461
503
  if (!author || !isPlainObject(author)) {
@@ -684,7 +726,12 @@ function validateTriage(config, errors, options) {
684
726
  errors.push("triage.agents is required");
685
727
  validateTriageAgentList(triage.agents, "triage.agents", errors, options.modelCatalog);
686
728
  if (Array.isArray(triage.agents)) {
687
- const resolvedTriageAgents = resolveAgents(config).triage ?? [];
729
+ const resolvedTriageAgents = triage.agents.map((agent, index) => ({
730
+ account: agent && typeof agent === "object" && typeof agent.account === "string"
731
+ ? agent.account
732
+ : "",
733
+ key: agent && typeof agent === "object" ? triageAgentKey(agent, index) : "",
734
+ }));
688
735
  validateResolvedTriageAgents(resolvedTriageAgents, "triage.resolvedAgents", errors);
689
736
  if (reporter != null &&
690
737
  !resolvedTriageAgents.some((agent) => agent.key === reporter)) {
@@ -778,10 +825,10 @@ async function fetchPermissions(config, exec, account) {
778
825
  return JSON.parse(raw);
779
826
  }
780
827
  async function validateWorktreeConfig(config, exec, options, errors) {
781
- const agents = resolveAgents(config);
782
- const checkEditor = Boolean(agents.editor && (options.requireEditor || options.requireWorktreeConfig));
828
+ const checkEditor = Boolean(config.merge?.editor &&
829
+ (options.requireEditor || options.requireWorktreeConfig));
783
830
  const checkTriageCreator = Boolean(config.triage?.automation?.create &&
784
- agents.triageCreator &&
831
+ config.triage?.creator &&
785
832
  (options.requireTriage || options.requireWorktreeConfig));
786
833
  if (!checkEditor && !checkTriageCreator)
787
834
  return;
@@ -853,6 +900,9 @@ export async function validateConfig(config, options = {}) {
853
900
  const warnings = [];
854
901
  if (!config || typeof config !== "object")
855
902
  errors.push("config must be an object");
903
+ if (options.requireModelCatalog && !options.modelCatalog) {
904
+ errors.push("OpenCode model catalog could not be loaded");
905
+ }
856
906
  expandAgentRefs(config, errors);
857
907
  if (config && typeof config === "object")
858
908
  validateJsonSchema(config, errors);
@@ -880,7 +930,16 @@ export async function validateConfig(config, options = {}) {
880
930
  errors.push("review.agents is required");
881
931
  validateReviewerList(config.review.agents, "review.agents", errors, options.modelCatalog);
882
932
  if (Array.isArray(config.review.agents)) {
883
- validateResolvedReviewers(resolveAgents(config).reviewers, "review.resolvedAgents", errors);
933
+ validateResolvedReviewers(config.review.agents.map((reviewer, index) => ({
934
+ account: reviewer &&
935
+ typeof reviewer === "object" &&
936
+ typeof reviewer.account === "string"
937
+ ? reviewer.account
938
+ : "",
939
+ key: reviewer && typeof reviewer === "object"
940
+ ? reviewerKey(reviewer, index)
941
+ : "",
942
+ })), "review.resolvedAgents", errors);
884
943
  }
885
944
  }
886
945
  if (options.requireTriage && !config.triage) {
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { loadConfig, mergeMagiConfig } from "./config/load";
9
9
  import { outputBaseDirs } from "./config/output";
10
10
  import { worktreeBaseDirs } from "./config/worktree";
11
11
  import { resolveRepository } from "./config/resolve";
12
- import { validateConfig } from "./config/validate";
12
+ import { validateConfig, } from "./config/validate";
13
13
  import { withGitHubApiRetry } from "./github/retry";
14
14
  import { mapPool } from "./orchestrator/pool";
15
15
  import { MagiRunManager } from "./orchestrator/run-manager";
@@ -317,6 +317,9 @@ function issueMarkdownLink(repository, issue) {
317
317
  const url = `https://${host}/${repository.github.owner}/${repository.github.repo}/issues/${issue}`;
318
318
  return `[#${issue}](${url})`;
319
319
  }
320
+ function validationError(validation) {
321
+ return new Error(JSON.stringify(validation, null, 2));
322
+ }
320
323
  function isPlainObject(value) {
321
324
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
322
325
  }
@@ -379,6 +382,7 @@ export async function validateMagiConfigFiles(directory, options = {}) {
379
382
  : undefined,
380
383
  modelCatalog: options.modelCatalog,
381
384
  requireGithub: hasProjectConfig && Boolean(mergedConfig.review?.agents),
385
+ requireModelCatalog: true,
382
386
  requireWorktreeConfig: true,
383
387
  });
384
388
  loadedFrom = existing.map((status) => status.path).join(", ");
@@ -430,7 +434,8 @@ export const MagiPlugin = async ({ client, directory }) => {
430
434
  .then(extractModelCatalog)
431
435
  .catch(() => catalogClient.provider
432
436
  ?.list({ query: { directory } })
433
- .then(extractModelCatalog));
437
+ .then(extractModelCatalog))
438
+ .catch(() => undefined);
434
439
  return modelCatalogPromise;
435
440
  }
436
441
  return {
@@ -475,9 +480,10 @@ export const MagiPlugin = async ({ client, directory }) => {
475
480
  exec: retryingExec,
476
481
  modelCatalog: await modelCatalog(),
477
482
  requireEditor: true,
483
+ requireModelCatalog: true,
478
484
  });
479
485
  if (!validation.ok)
480
- return JSON.stringify(validation, null, 2);
486
+ throw validationError(validation);
481
487
  const repository = resolveRepository(config);
482
488
  const sync = parsed.sync || args.sync === true;
483
489
  const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startMerge({
@@ -517,9 +523,10 @@ export const MagiPlugin = async ({ client, directory }) => {
517
523
  directory,
518
524
  exec: retryingExec,
519
525
  modelCatalog: await modelCatalog(),
526
+ requireModelCatalog: true,
520
527
  });
521
528
  if (!validation.ok)
522
- return JSON.stringify(validation, null, 2);
529
+ throw validationError(validation);
523
530
  const repository = resolveRepository(config);
524
531
  const sync = parsed.sync || args.sync === true;
525
532
  const states = await mapPool(parsed.prs, repository.concurrency.runs, (pr) => runManager.startReview({
@@ -557,12 +564,13 @@ export const MagiPlugin = async ({ client, directory }) => {
557
564
  exec: retryingExec,
558
565
  modelCatalog: await modelCatalog(),
559
566
  requireEditor: config.triage?.automation?.merge === true,
567
+ requireModelCatalog: true,
560
568
  requireReview: config.triage?.automation?.review === true ||
561
569
  config.triage?.automation?.merge === true,
562
570
  requireTriage: true,
563
571
  });
564
572
  if (!validation.ok)
565
- return JSON.stringify(validation, null, 2);
573
+ throw validationError(validation);
566
574
  const repository = resolveRepository(config);
567
575
  if (!repository.triage)
568
576
  return JSON.stringify({ errors: ["triage configuration is required"], ok: false }, null, 2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-magi",
3
- "version": "0.0.0-dev-20260524220537",
3
+ "version": "0.0.0-dev-20260525005102",
4
4
  "description": "Multi-agent PR review and merge orchestration plugin for OpenCode.",
5
5
  "license": "MIT",
6
6
  "author": "Hirotomo Yamada <hirotomo.yamada@avap.co.jp>",
package/schema.json CHANGED
@@ -60,8 +60,7 @@
60
60
  "additionalProperties": false,
61
61
  "properties": {
62
62
  "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
63
- "model": { "type": "string", "minLength": 1 },
64
- "options": { "type": "object", "additionalProperties": true },
63
+ "model": { "$ref": "#/$defs/modelConfig" },
65
64
  "account": { "type": "string", "minLength": 1 },
66
65
  "author": {
67
66
  "type": "object",
@@ -83,8 +82,7 @@
83
82
  "properties": {
84
83
  "ref": { "type": "string", "minLength": 1 },
85
84
  "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
86
- "model": { "type": "string", "minLength": 1 },
87
- "options": { "type": "object", "additionalProperties": true },
85
+ "model": { "$ref": "#/$defs/modelConfig" },
88
86
  "account": { "type": "string", "minLength": 1 },
89
87
  "permissions": { "$ref": "#/$defs/permissions" },
90
88
  "persona": { "type": "string" }
@@ -97,8 +95,7 @@
97
95
  "additionalProperties": false,
98
96
  "properties": {
99
97
  "ref": { "type": "string", "minLength": 1 },
100
- "model": { "type": "string", "minLength": 1 },
101
- "options": { "type": "object", "additionalProperties": true },
98
+ "model": { "$ref": "#/$defs/modelConfig" },
102
99
  "account": { "type": "string", "minLength": 1 },
103
100
  "author": {
104
101
  "type": "object",
@@ -121,9 +118,8 @@
121
118
  "properties": {
122
119
  "ref": { "type": "string", "minLength": 1 },
123
120
  "id": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" },
124
- "model": { "type": "string", "minLength": 1 },
121
+ "model": { "$ref": "#/$defs/modelConfig" },
125
122
  "account": { "type": "string", "minLength": 1 },
126
- "options": { "type": "object", "additionalProperties": true },
127
123
  "permissions": { "$ref": "#/$defs/permissions" },
128
124
  "persona": { "type": "string" }
129
125
  }
@@ -136,8 +132,7 @@
136
132
  "properties": {
137
133
  "ref": { "type": "string", "minLength": 1 },
138
134
  "account": { "type": "string", "minLength": 1 },
139
- "model": { "type": "string", "minLength": 1 },
140
- "options": { "type": "object", "additionalProperties": true },
135
+ "model": { "$ref": "#/$defs/modelConfig" },
141
136
  "author": {
142
137
  "type": "object",
143
138
  "required": ["name", "email"],
@@ -151,6 +146,30 @@
151
146
  "persona": { "type": "string" }
152
147
  }
153
148
  },
149
+ "modelConfig": {
150
+ "oneOf": [
151
+ { "type": "string", "minLength": 1 },
152
+ {
153
+ "type": "array",
154
+ "minItems": 1,
155
+ "items": {
156
+ "oneOf": [
157
+ { "type": "string", "minLength": 1 },
158
+ { "$ref": "#/$defs/modelCandidate" }
159
+ ]
160
+ }
161
+ }
162
+ ]
163
+ },
164
+ "modelCandidate": {
165
+ "type": "object",
166
+ "required": ["id"],
167
+ "additionalProperties": false,
168
+ "properties": {
169
+ "id": { "type": "string", "minLength": 1 },
170
+ "options": { "type": "object", "additionalProperties": true }
171
+ }
172
+ },
154
173
  "automation": {
155
174
  "type": "object",
156
175
  "additionalProperties": false,