agentxchain 2.21.0 → 2.23.0

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
@@ -214,7 +214,7 @@ The first-party governed workflow kit includes `.planning/SYSTEM_SPEC.md` alongs
214
214
  - `manual`: implemented
215
215
  - `local_cli`: implemented
216
216
  - `mcp`: implemented for stdio and streamable HTTP tool-contract dispatch
217
- - `api_proxy`: implemented for synchronous review-only turns and stages a provider-backed result during `step`
217
+ - `api_proxy`: implemented for synchronous `review_only` and `proposed` write-authority turns; stages a provider-backed result during `step`
218
218
 
219
219
  ## Legacy IDE Mode
220
220
 
@@ -68,6 +68,7 @@ import { resumeCommand } from '../src/commands/resume.js';
68
68
  import { escalateCommand } from '../src/commands/escalate.js';
69
69
  import { acceptTurnCommand } from '../src/commands/accept-turn.js';
70
70
  import { rejectTurnCommand } from '../src/commands/reject-turn.js';
71
+ import { proposalListCommand, proposalDiffCommand, proposalApplyCommand, proposalRejectCommand } from '../src/commands/proposal.js';
71
72
  import { stepCommand } from '../src/commands/step.js';
72
73
  import { runCommand } from '../src/commands/run.js';
73
74
  import { approveTransitionCommand } from '../src/commands/approve-transition.js';
@@ -535,4 +536,35 @@ intakeCmd
535
536
  .option('-j, --json', 'Output as JSON')
536
537
  .action(intakeStatusCommand);
537
538
 
539
+ // --- Proposal operations ----------------------------------------------------
540
+
541
+ const proposalCmd = program
542
+ .command('proposal')
543
+ .description('Manage proposed changes from api_proxy agents');
544
+
545
+ proposalCmd
546
+ .command('list')
547
+ .description('List all proposals and their status')
548
+ .action(proposalListCommand);
549
+
550
+ proposalCmd
551
+ .command('diff <turn_id>')
552
+ .description('Show diff between proposed files and current workspace')
553
+ .option('--file <path>', 'Show diff for a single file only')
554
+ .action(proposalDiffCommand);
555
+
556
+ proposalCmd
557
+ .command('apply <turn_id>')
558
+ .description('Apply proposed changes to the workspace')
559
+ .option('--file <path>', 'Apply only a specific file')
560
+ .option('--dry-run', 'Show what would change without writing')
561
+ .option('--force', 'Override proposal conflicts or unverifiable legacy proposals')
562
+ .action(proposalApplyCommand);
563
+
564
+ proposalCmd
565
+ .command('reject <turn_id>')
566
+ .description('Reject a proposal without applying changes')
567
+ .option('--reason <reason>', 'Reason for rejection (required)')
568
+ .action(proposalRejectCommand);
569
+
538
570
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentxchain",
3
- "version": "2.21.0",
3
+ "version": "2.23.0",
4
4
  "description": "CLI for AgentXchain — governed multi-agent software delivery",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,6 +6,7 @@ set -euo pipefail
6
6
 
7
7
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
8
  CLI_DIR="${SCRIPT_DIR}/.."
9
+ REPO_ROOT="$(cd "${CLI_DIR}/.." && pwd)"
9
10
  cd "$CLI_DIR"
10
11
 
11
12
  TARGET_VERSION=""
@@ -46,13 +47,56 @@ fi
46
47
  echo "AgentXchain Release Identity: ${TARGET_VERSION}"
47
48
  echo "============================================="
48
49
 
49
- # 1. Assert clean tree
50
- echo "[1/7] Checking git tree cleanliness..."
51
- if ! git diff --quiet HEAD 2>/dev/null || [ -n "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
52
- echo "FAIL: Working tree is not clean. Commit or stash changes before creating release identity." >&2
50
+ TARGET_RELEASE_DOC="website-v2/docs/releases/v${TARGET_VERSION//./-}.mdx"
51
+ ALLOWED_RELEASE_PATHS=(
52
+ "cli/CHANGELOG.md"
53
+ "${TARGET_RELEASE_DOC}"
54
+ "website-v2/sidebars.ts"
55
+ "website-v2/src/pages/index.tsx"
56
+ ".agentxchain-conformance/capabilities.json"
57
+ "website-v2/docs/protocol-implementor-guide.mdx"
58
+ ".planning/LAUNCH_EVIDENCE_REPORT.md"
59
+ )
60
+
61
+ is_allowed_release_path() {
62
+ local candidate="$1"
63
+ local allowed
64
+ for allowed in "${ALLOWED_RELEASE_PATHS[@]}"; do
65
+ if [[ "$candidate" == "$allowed" ]]; then
66
+ return 0
67
+ fi
68
+ done
69
+ return 1
70
+ }
71
+
72
+ stage_if_present() {
73
+ local rel_path="$1"
74
+ if [[ -e "${REPO_ROOT}/${rel_path}" ]]; then
75
+ git -C "$REPO_ROOT" add -- "$rel_path"
76
+ return 0
77
+ fi
78
+ if git -C "$REPO_ROOT" ls-files --error-unmatch "$rel_path" >/dev/null 2>&1; then
79
+ git -C "$REPO_ROOT" add -- "$rel_path"
80
+ fi
81
+ }
82
+
83
+ # 1. Assert only allowed release-surface dirt is present
84
+ echo "[1/7] Checking release-prep tree state..."
85
+ DISALLOWED_DIRTY=()
86
+ while IFS= read -r status_line; do
87
+ [[ -z "$status_line" ]] && continue
88
+ path="${status_line#?? }"
89
+ if ! is_allowed_release_path "$path"; then
90
+ DISALLOWED_DIRTY+=("$path")
91
+ fi
92
+ done < <(git -C "$REPO_ROOT" status --porcelain)
93
+
94
+ if [[ "${#DISALLOWED_DIRTY[@]}" -gt 0 ]]; then
95
+ echo "FAIL: Working tree contains changes outside the allowed release surfaces:" >&2
96
+ printf ' - %s\n' "${DISALLOWED_DIRTY[@]}" >&2
53
97
  exit 1
54
98
  fi
55
- echo " OK: tree is clean"
99
+ echo " OK: tree contains only allowed release-prep changes"
56
100
 
57
101
  # 2. Assert not already at target version
58
102
  echo "[2/7] Checking current version..."
@@ -78,11 +122,14 @@ echo " OK: package.json updated to ${TARGET_VERSION}"
78
122
 
79
123
  # 5. Stage version files
80
124
  echo "[5/7] Staging version files..."
81
- git add package.json
125
+ git add -- package.json
82
126
  if [[ -f package-lock.json ]]; then
83
- git add package-lock.json
127
+ git add -- package-lock.json
84
128
  fi
85
- echo " OK: version files staged"
129
+ for rel_path in "${ALLOWED_RELEASE_PATHS[@]}"; do
130
+ stage_if_present "$rel_path"
131
+ done
132
+ echo " OK: version files and allowed release surfaces staged"
86
133
 
87
134
  # 6. Create release commit
88
135
  echo "[6/7] Creating release commit..."
@@ -35,7 +35,7 @@ export async function escalateCommand(opts) {
35
35
  process.exit(1);
36
36
  }
37
37
 
38
- const recovery = deriveRecoveryDescriptor(result.state);
38
+ const recovery = deriveRecoveryDescriptor(result.state, config);
39
39
 
40
40
  console.log('');
41
41
  console.log(chalk.yellow(' Run Escalated'));
@@ -0,0 +1,144 @@
1
+ import chalk from 'chalk';
2
+ import { loadProjectContext } from '../lib/config.js';
3
+ import { listProposals, diffProposal, applyProposal, rejectProposal } from '../lib/proposal-ops.js';
4
+
5
+ export async function proposalListCommand() {
6
+ const context = requireGovernedContext();
7
+ const result = listProposals(context.root);
8
+ if (!result.ok) {
9
+ console.log(chalk.red(result.error));
10
+ process.exit(1);
11
+ }
12
+
13
+ if (result.proposals.length === 0) {
14
+ console.log(chalk.dim(' No proposals found.'));
15
+ return;
16
+ }
17
+
18
+ console.log('');
19
+ console.log(chalk.bold(' Proposals'));
20
+ console.log(chalk.dim(' ' + '─'.repeat(60)));
21
+ for (const p of result.proposals) {
22
+ const statusColor = p.status === 'applied' ? chalk.green : p.status === 'rejected' ? chalk.red : chalk.yellow;
23
+ console.log(` ${chalk.dim(p.turn_id)} ${p.role} ${p.file_count} files ${statusColor(p.status)}`);
24
+ }
25
+ console.log('');
26
+ }
27
+
28
+ export async function proposalDiffCommand(turnId, opts) {
29
+ const context = requireGovernedContext();
30
+ if (!turnId) {
31
+ console.log(chalk.red('Usage: agentxchain proposal diff <turn_id>'));
32
+ process.exit(1);
33
+ }
34
+
35
+ const result = diffProposal(context.root, turnId, opts.file);
36
+ if (!result.ok) {
37
+ console.log(chalk.red(result.error));
38
+ process.exit(1);
39
+ }
40
+
41
+ for (const d of result.diffs) {
42
+ console.log('');
43
+ console.log(chalk.bold(` ${d.path}`) + chalk.dim(` (${d.action})`));
44
+ console.log(chalk.dim(' ' + '─'.repeat(50)));
45
+ for (const line of d.preview.split('\n')) {
46
+ if (line.startsWith('+')) console.log(chalk.green(` ${line}`));
47
+ else if (line.startsWith('-')) console.log(chalk.red(` ${line}`));
48
+ else console.log(chalk.dim(` ${line}`));
49
+ }
50
+ }
51
+ console.log('');
52
+ }
53
+
54
+ export async function proposalApplyCommand(turnId, opts) {
55
+ const context = requireGovernedContext();
56
+ if (!turnId) {
57
+ console.log(chalk.red('Usage: agentxchain proposal apply <turn_id>'));
58
+ process.exit(1);
59
+ }
60
+
61
+ const result = applyProposal(context.root, turnId, {
62
+ file: opts.file,
63
+ dryRun: opts.dryRun,
64
+ force: opts.force,
65
+ });
66
+
67
+ if (!result.ok) {
68
+ if (Array.isArray(result.conflicts) && result.conflicts.length > 0) {
69
+ console.log(chalk.red(` ${result.error}`));
70
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
71
+ for (const conflict of result.conflicts) {
72
+ console.log(` ${chalk.red('•')} ${conflict.path} ${chalk.dim(`(${conflict.reason})`)}`);
73
+ }
74
+ console.log('');
75
+ process.exit(1);
76
+ }
77
+ console.log(chalk.red(result.error));
78
+ process.exit(1);
79
+ }
80
+
81
+ console.log('');
82
+ if (result.dry_run) {
83
+ console.log(chalk.yellow(' Dry Run — No Changes Written'));
84
+ } else if (result.forced) {
85
+ console.log(chalk.yellow(' Proposal Applied With Force'));
86
+ } else {
87
+ console.log(chalk.green(' Proposal Applied'));
88
+ }
89
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
90
+ console.log(` ${chalk.dim('Turn:')} ${turnId}`);
91
+ console.log(` ${chalk.dim('Applied:')} ${result.applied_files.length} files`);
92
+ if (result.applied_files.length > 0) {
93
+ for (const f of result.applied_files) {
94
+ console.log(` ${chalk.dim('•')} ${f}`);
95
+ }
96
+ }
97
+ if (result.skipped_files.length > 0) {
98
+ console.log(` ${chalk.dim('Skipped:')} ${result.skipped_files.length} files`);
99
+ for (const f of result.skipped_files) {
100
+ console.log(` ${chalk.dim('•')} ${f}`);
101
+ }
102
+ }
103
+ if (result.forced && result.overridden_conflicts?.length > 0) {
104
+ console.log(` ${chalk.dim('Forced:')} ${result.overridden_conflicts.length} conflicts overridden`);
105
+ for (const conflict of result.overridden_conflicts) {
106
+ console.log(` ${chalk.dim('•')} ${conflict.path}`);
107
+ }
108
+ }
109
+ console.log('');
110
+ }
111
+
112
+ export async function proposalRejectCommand(turnId, opts) {
113
+ const context = requireGovernedContext();
114
+ if (!turnId) {
115
+ console.log(chalk.red('Usage: agentxchain proposal reject <turn_id> --reason "..."'));
116
+ process.exit(1);
117
+ }
118
+
119
+ const result = rejectProposal(context.root, turnId, opts.reason);
120
+ if (!result.ok) {
121
+ console.log(chalk.red(result.error));
122
+ process.exit(1);
123
+ }
124
+
125
+ console.log('');
126
+ console.log(chalk.yellow(' Proposal Rejected'));
127
+ console.log(chalk.dim(' ' + '─'.repeat(44)));
128
+ console.log(` ${chalk.dim('Turn:')} ${turnId}`);
129
+ console.log(` ${chalk.dim('Reason:')} ${opts.reason}`);
130
+ console.log('');
131
+ }
132
+
133
+ function requireGovernedContext() {
134
+ const context = loadProjectContext();
135
+ if (!context) {
136
+ console.log(chalk.red('No agentxchain.json found. Run `agentxchain init` first.'));
137
+ process.exit(1);
138
+ }
139
+ if (context.config.protocol_mode !== 'governed') {
140
+ console.log(chalk.red('The proposal command is only available for governed projects.'));
141
+ process.exit(1);
142
+ }
143
+ return context;
144
+ }
@@ -21,6 +21,7 @@ import { loadProjectContext, loadProjectState } from '../lib/config.js';
21
21
  import {
22
22
  initializeGovernedRun,
23
23
  assignGovernedTurn,
24
+ deriveAfterDispatchHookRecoveryAction,
24
25
  markRunBlocked,
25
26
  getActiveTurns,
26
27
  getActiveTurnCount,
@@ -358,13 +359,17 @@ function runAfterDispatchHooks(root, hooksConfig, state, turn, config) {
358
359
  || `after_dispatch hook blocked dispatch for turn ${turnId}`;
359
360
  const errorCode = afterDispatchHooks.tamper?.error_code || 'hook_blocked';
360
361
 
362
+ const recoveryAction = deriveAfterDispatchHookRecoveryAction(state, config, {
363
+ turnRetained: true,
364
+ turnId,
365
+ });
361
366
  markRunBlocked(root, {
362
367
  blockedOn: `hook:after_dispatch:${hookName}`,
363
368
  category: 'dispatch_error',
364
369
  recovery: {
365
370
  typed_reason: afterDispatchHooks.tamper ? 'hook_tamper' : 'hook_block',
366
371
  owner: 'human',
367
- recovery_action: `Fix or reconfigure the hook, then rerun agentxchain resume`,
372
+ recovery_action: recoveryAction,
368
373
  turn_retained: true,
369
374
  detail,
370
375
  },
@@ -378,6 +383,7 @@ function runAfterDispatchHooks(root, hooksConfig, state, turn, config) {
378
383
  hookName,
379
384
  error: detail,
380
385
  errorCode,
386
+ recoveryAction,
381
387
  hookResults: afterDispatchHooks,
382
388
  });
383
389
 
@@ -387,7 +393,7 @@ function runAfterDispatchHooks(root, hooksConfig, state, turn, config) {
387
393
  return { ok: true };
388
394
  }
389
395
 
390
- function printDispatchHookFailure({ turnId, roleId, hookName, error, hookResults }) {
396
+ function printDispatchHookFailure({ turnId, roleId, hookName, error, hookResults, recoveryAction }) {
391
397
  const isTamper = hookResults?.tamper;
392
398
  console.log('');
393
399
  console.log(chalk.yellow(' Dispatch Blocked By Hook'));
@@ -399,7 +405,7 @@ function printDispatchHookFailure({ turnId, roleId, hookName, error, hookResults
399
405
  console.log(` ${chalk.dim('Error:')} ${error}`);
400
406
  console.log(` ${chalk.dim('Reason:')} ${isTamper ? 'hook_tamper' : 'hook_block'}`);
401
407
  console.log(` ${chalk.dim('Owner:')} human`);
402
- console.log(` ${chalk.dim('Action:')} Fix or reconfigure the hook, then rerun agentxchain resume`);
408
+ console.log(` ${chalk.dim('Action:')} ${recoveryAction}`);
403
409
  console.log('');
404
410
  }
405
411
 
@@ -168,7 +168,7 @@ function renderGovernedStatus(context, opts) {
168
168
  if (state?.blocked_on) {
169
169
  console.log('');
170
170
  if (state.status === 'blocked') {
171
- const recovery = deriveRecoveryDescriptor(state);
171
+ const recovery = deriveRecoveryDescriptor(state, config);
172
172
  const detail = recovery?.detail || state.blocked_on;
173
173
  console.log(` ${chalk.dim('Blocked:')} ${chalk.red.bold('BLOCKED')} — ${detail}`);
174
174
  } else if (state.blocked_on.startsWith('human_approval:')) {
@@ -179,7 +179,7 @@ function renderGovernedStatus(context, opts) {
179
179
  }
180
180
  }
181
181
 
182
- const recovery = deriveRecoveryDescriptor(state);
182
+ const recovery = deriveRecoveryDescriptor(state, config);
183
183
  if (recovery) {
184
184
  console.log('');
185
185
  console.log(` ${chalk.dim('Reason:')} ${recovery.typed_reason}`);
@@ -28,6 +28,7 @@ import {
28
28
  initializeGovernedRun,
29
29
  assignGovernedTurn,
30
30
  acceptGovernedTurn,
31
+ deriveAfterDispatchHookRecoveryAction,
31
32
  rejectGovernedTurn,
32
33
  markRunBlocked,
33
34
  getActiveTurnCount,
@@ -338,7 +339,7 @@ export async function stepCommand(opts) {
338
339
  });
339
340
 
340
341
  if (!afterDispatchHooks.ok) {
341
- const blocked = blockStepForHookIssue(root, turn, {
342
+ const blocked = blockStepForHookIssue(root, state, turn, {
342
343
  hookResults: afterDispatchHooks,
343
344
  phase: 'after_dispatch',
344
345
  defaultDetail: `after_dispatch hook blocked dispatch for turn ${turn.turn_id}`,
@@ -654,7 +655,7 @@ export async function stepCommand(opts) {
654
655
  });
655
656
 
656
657
  if (!beforeValidationHooks.ok) {
657
- const blocked = blockStepForHookIssue(root, turn, {
658
+ const blocked = blockStepForHookIssue(root, state, turn, {
658
659
  hookResults: beforeValidationHooks,
659
660
  phase: 'before_validation',
660
661
  defaultDetail: `before_validation hook blocked validation for turn ${turn.turn_id}`,
@@ -686,7 +687,7 @@ export async function stepCommand(opts) {
686
687
  });
687
688
 
688
689
  if (!afterValidationHooks.ok) {
689
- const blocked = blockStepForHookIssue(root, turn, {
690
+ const blocked = blockStepForHookIssue(root, state, turn, {
690
691
  hookResults: afterValidationHooks,
691
692
  phase: 'after_validation',
692
693
  defaultDetail: `after_validation hook blocked acceptance for turn ${turn.turn_id}`,
@@ -775,7 +776,7 @@ function loadHookStagedTurn(root, stagingRel) {
775
776
  }
776
777
  }
777
778
 
778
- function blockStepForHookIssue(root, turn, { hookResults, phase, defaultDetail, config }) {
779
+ function blockStepForHookIssue(root, state, turn, { hookResults, phase, defaultDetail, config }) {
779
780
  const hookName = hookResults.blocker?.hook_name
780
781
  || hookResults.results?.find((entry) => entry.hook_name)?.hook_name
781
782
  || 'unknown';
@@ -783,13 +784,17 @@ function blockStepForHookIssue(root, turn, { hookResults, phase, defaultDetail,
783
784
  || hookResults.tamper?.message
784
785
  || defaultDetail;
785
786
  const errorCode = hookResults.tamper?.error_code || 'hook_blocked';
787
+ const recoveryAction = deriveAfterDispatchHookRecoveryAction(state, config, {
788
+ turnRetained: true,
789
+ turnId: turn.turn_id,
790
+ });
786
791
  const blocked = markRunBlocked(root, {
787
792
  blockedOn: `hook:${phase}:${hookName}`,
788
793
  category: phase === 'after_dispatch' ? 'dispatch_error' : 'validation_error',
789
794
  recovery: {
790
795
  typed_reason: hookResults.tamper ? 'hook_tamper' : 'hook_block',
791
796
  owner: 'human',
792
- recovery_action: 'Fix or reconfigure the hook, then run agentxchain step --resume',
797
+ recovery_action: recoveryAction,
793
798
  turn_retained: true,
794
799
  detail,
795
800
  },
@@ -1,10 +1,14 @@
1
1
  /**
2
- * API proxy adapter — review-only synchronous provider calls.
2
+ * API proxy adapter — synchronous provider calls for review and proposed authoring.
3
3
  *
4
- * v1 scope (Session #19 freeze):
5
- * - review_only roles only
4
+ * Supported write authorities:
5
+ * - review_only: single request/response, structured review JSON
6
+ * - proposed: single request/response, structured JSON with proposed_changes[]
7
+ * (orchestrator materializes proposals to .agentxchain/proposed/<turn_id>/)
8
+ *
9
+ * Constraints:
6
10
  * - single request / single response (synchronous within `step`)
7
- * - no tool use, no patch application, no repo writes
11
+ * - no tool use, no direct repo writes
8
12
  * - turn result must arrive as structured JSON
9
13
  * - provider telemetry is authoritative for cost
10
14
  *
@@ -46,13 +50,37 @@ const PROVIDER_ENDPOINTS = {
46
50
  openai: 'https://api.openai.com/v1/chat/completions',
47
51
  };
48
52
 
49
- // Cost rates per million tokens (USD)
50
- const COST_RATES = {
53
+ // Bundled cost rates per million tokens (USD).
54
+ // These are convenience defaults — operators can override via budget.cost_rates in agentxchain.json.
55
+ // Verified: 2026-04-07 (Anthropic via docs.anthropic.com; OpenAI from training knowledge)
56
+ const BUNDLED_COST_RATES = {
57
+ // Anthropic — verified 2026-04-07
51
58
  'claude-sonnet-4-6': { input_per_1m: 3.00, output_per_1m: 15.00 },
52
- 'claude-opus-4-6': { input_per_1m: 15.00, output_per_1m: 75.00 },
53
- 'claude-haiku-4-5-20251001': { input_per_1m: 0.80, output_per_1m: 4.00 },
59
+ 'claude-opus-4-6': { input_per_1m: 5.00, output_per_1m: 25.00 },
60
+ 'claude-haiku-4-5-20251001': { input_per_1m: 1.00, output_per_1m: 5.00 },
61
+ // OpenAI — verified 2026-04-07 (training knowledge, could not live-verify openai.com)
62
+ 'gpt-4o': { input_per_1m: 2.50, output_per_1m: 10.00 },
63
+ 'gpt-4o-mini': { input_per_1m: 0.15, output_per_1m: 0.60 },
64
+ 'gpt-4.1': { input_per_1m: 2.00, output_per_1m: 8.00 },
65
+ 'gpt-4.1-mini': { input_per_1m: 0.40, output_per_1m: 1.60 },
66
+ 'gpt-4.1-nano': { input_per_1m: 0.10, output_per_1m: 0.40 },
67
+ 'o3': { input_per_1m: 2.00, output_per_1m: 8.00 },
68
+ 'o3-mini': { input_per_1m: 1.10, output_per_1m: 4.40 },
69
+ 'o4-mini': { input_per_1m: 1.10, output_per_1m: 4.40 },
54
70
  };
55
71
 
72
+ // Resolve cost rates: operator-supplied cost_rates override bundled defaults
73
+ function getCostRates(model, config) {
74
+ const operatorRates = config?.budget?.cost_rates;
75
+ if (operatorRates && typeof operatorRates === 'object' && operatorRates[model]) {
76
+ const r = operatorRates[model];
77
+ if (Number.isFinite(r.input_per_1m) && Number.isFinite(r.output_per_1m)) {
78
+ return r;
79
+ }
80
+ }
81
+ return BUNDLED_COST_RATES[model] || null;
82
+ }
83
+
56
84
  const RETRYABLE_ERROR_CLASSES = [
57
85
  'rate_limited',
58
86
  'network_failure',
@@ -405,7 +433,7 @@ function emptyUsageTotals() {
405
433
  };
406
434
  }
407
435
 
408
- function usageFromTelemetry(provider, model, usage) {
436
+ function usageFromTelemetry(provider, model, usage, config) {
409
437
  if (!usage || typeof usage !== 'object') return null;
410
438
 
411
439
  let inputTokens = 0;
@@ -419,7 +447,7 @@ function usageFromTelemetry(provider, model, usage) {
419
447
  outputTokens = Number.isFinite(usage.output_tokens) ? usage.output_tokens : 0;
420
448
  }
421
449
 
422
- const rates = COST_RATES[model];
450
+ const rates = getCostRates(model, config);
423
451
  const usd = rates
424
452
  ? (inputTokens / 1_000_000) * rates.input_per_1m + (outputTokens / 1_000_000) * rates.output_per_1m
425
453
  : 0;
@@ -566,6 +594,7 @@ async function executeApiCall({
566
594
  requestBody,
567
595
  timeoutSeconds,
568
596
  signal,
597
+ config,
569
598
  }) {
570
599
  const timeoutMs = timeoutSeconds * 1000;
571
600
  const controller = new AbortController();
@@ -662,7 +691,7 @@ async function executeApiCall({
662
691
  };
663
692
  }
664
693
 
665
- const usage = usageFromTelemetry(provider, model, responseData.usage);
694
+ const usage = usageFromTelemetry(provider, model, responseData.usage, config);
666
695
  const extraction = extractTurnResult(responseData, provider);
667
696
 
668
697
  if (!extraction.ok) {
@@ -730,9 +759,9 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
730
759
  return { ok: false, error: `Runtime "${runtimeId}" is not an api_proxy runtime` };
731
760
  }
732
761
 
733
- // Enforce v1 restriction: review_only only
734
- if (role?.write_authority !== 'review_only') {
735
- return { ok: false, error: `v1 api_proxy only supports review_only roles (got "${role?.write_authority}")` };
762
+ // Enforce api_proxy restriction: review_only or proposed only
763
+ if (role?.write_authority !== 'review_only' && role?.write_authority !== 'proposed') {
764
+ return { ok: false, error: `api_proxy only supports review_only and proposed roles (got "${role?.write_authority}")` };
736
765
  }
737
766
 
738
767
  // Read dispatch bundle
@@ -869,6 +898,7 @@ export async function dispatchApiProxy(root, state, config, options = {}) {
869
898
  requestBody,
870
899
  timeoutSeconds,
871
900
  signal,
901
+ config,
872
902
  });
873
903
 
874
904
  const attemptCompletedAt = new Date().toISOString();
@@ -1170,5 +1200,7 @@ export {
1170
1200
  buildOpenAiRequest,
1171
1201
  classifyError,
1172
1202
  classifyHttpError,
1173
- COST_RATES,
1203
+ BUNDLED_COST_RATES,
1204
+ BUNDLED_COST_RATES as COST_RATES, // backward compat alias
1205
+ getCostRates,
1174
1206
  };