opencode-magi 0.0.0-dev-20260524220537 → 0.0.0-dev-20260524221727

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
@@ -379,6 +379,7 @@ export async function validateMagiConfigFiles(directory, options = {}) {
379
379
  : undefined,
380
380
  modelCatalog: options.modelCatalog,
381
381
  requireGithub: hasProjectConfig && Boolean(mergedConfig.review?.agents),
382
+ requireModelCatalog: true,
382
383
  requireWorktreeConfig: true,
383
384
  });
384
385
  loadedFrom = existing.map((status) => status.path).join(", ");
@@ -430,7 +431,8 @@ export const MagiPlugin = async ({ client, directory }) => {
430
431
  .then(extractModelCatalog)
431
432
  .catch(() => catalogClient.provider
432
433
  ?.list({ query: { directory } })
433
- .then(extractModelCatalog));
434
+ .then(extractModelCatalog))
435
+ .catch(() => undefined);
434
436
  return modelCatalogPromise;
435
437
  }
436
438
  return {
@@ -475,6 +477,7 @@ export const MagiPlugin = async ({ client, directory }) => {
475
477
  exec: retryingExec,
476
478
  modelCatalog: await modelCatalog(),
477
479
  requireEditor: true,
480
+ requireModelCatalog: true,
478
481
  });
479
482
  if (!validation.ok)
480
483
  return JSON.stringify(validation, null, 2);
@@ -517,6 +520,7 @@ export const MagiPlugin = async ({ client, directory }) => {
517
520
  directory,
518
521
  exec: retryingExec,
519
522
  modelCatalog: await modelCatalog(),
523
+ requireModelCatalog: true,
520
524
  });
521
525
  if (!validation.ok)
522
526
  return JSON.stringify(validation, null, 2);
@@ -557,6 +561,7 @@ export const MagiPlugin = async ({ client, directory }) => {
557
561
  exec: retryingExec,
558
562
  modelCatalog: await modelCatalog(),
559
563
  requireEditor: config.triage?.automation?.merge === true,
564
+ requireModelCatalog: true,
560
565
  requireReview: config.triage?.automation?.review === true ||
561
566
  config.triage?.automation?.merge === true,
562
567
  requireTriage: true,
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-20260524221727",
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,