@acruxcoreai/mcp-server 0.1.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ All notable changes to `@acruxcoreai/mcp-server` are documented here.
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
6
6
  package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.3.0 — 2026-10-22
11
+
12
+ ### Added
13
+
14
+ - `judge_prompt` on `run_experiment` and `optimize_prompt`: the name or UUID of one of your
15
+ prompts that grades instead of the built-in judge. The optimizer also reads it.
16
+ - `get_optimize_report` returns `judgePromptVersionId`, the judge prompt version that graded
17
+ the run, or `null` for the built-in judge.
18
+ - `get_optimize_report` returns `rejectedRewrites`, why each dropped rewrite was dropped, and
19
+ its summary says how many there were.
20
+
21
+ ### Fixed
22
+
23
+ - `run_experiment` failed on every call with `judge_model is required`: it sent no judge model.
24
+ It now takes a required `judge_model`, like `optimize_prompt`.
25
+
26
+ ## 0.2.0 — 2026-10-22
27
+
28
+ ### Added
29
+
30
+ - **`optimize_prompt`**, **`get_optimize_report`** and **`promote_optimize_candidate`**
31
+ (`evaluations` toolset). Start an optimizer run, read each rewrite's score against the
32
+ baseline with the candidate id and rationale, and ship one as a new version.
33
+ - `promote_optimize_candidate` refuses a rewrite that did not improve on at least one model,
34
+ or that regressed or has no scores on any, unless `promote_without_improvement` is set.
35
+ Read-only mode hides `optimize_prompt` and `promote_optimize_candidate`.
36
+ - `promote_optimize_candidate` moves the alias the run was scored against by default. It
37
+ refuses when that alias now points at another version than the one scored, since nothing
38
+ compared the rewrite with it; `promote_without_improvement` overrides this too.
39
+ - When six long rewrites do not fit in one result, `get_optimize_report` leaves out their
40
+ messages (`messagesOmitted`) and keeps every id and score; `candidate_id` reads one in full.
41
+ - **`get_spending_limits`** (`gateway` toolset, read-only). Lists every budget with its
42
+ period, limit, live spend, `remainingUsd`, `exhausted` and reset time, names the virtual
43
+ key a budget belongs to, and lists the keys masked to their last four characters.
44
+
45
+ ### Changed
46
+
47
+ - `run_prompt` and `chat_completion` now name the trace id on their summary line, as
48
+ `trace_id <uuid>`. The JSON part also carries `request-id`, another UUID, and a client
49
+ that took the first UUID it saw passed that one to `get_trace` and got a 404.
50
+
51
+ ### Fixed
52
+
53
+ - `get_experiment_run` always reported "0 result(s)" on its summary line. It read
54
+ `results.length`, but the API returns `results` as `{ total, succeeded, errored }`.
55
+
8
56
  ## 0.1.0 — 2026-10-18
9
57
 
10
58
 
package/README.md CHANGED
@@ -76,7 +76,7 @@ into running — which is the point of handing an agent a key it should not writ
76
76
  **Prompts** — `list_prompts`, `get_prompt`, `render_prompt`, `diff_prompt_versions`,
77
77
  `create_prompt`, `commit_prompt_version`, `promote_prompt_alias`
78
78
 
79
- **Gateway** — `run_prompt`, `chat_completion`, `list_models`, `get_gateway_usage`
79
+ **Gateway** — `run_prompt`, `chat_completion`, `list_models`, `get_gateway_usage`, `get_spending_limits`
80
80
 
81
81
  **Tool catalog** — `list_catalog_tools`, `get_catalog_tool`, `sync_catalog_tool`,
82
82
  `execute_catalog_tool`
@@ -85,7 +85,8 @@ into running — which is the point of handing an agent a key it should not writ
85
85
  `get_session`, `submit_trace_feedback`
86
86
 
87
87
  **Evaluations** — `list_datasets`, `get_dataset`, `create_dataset`,
88
- `add_dataset_example`, `run_experiment`, `list_experiment_runs`, `get_experiment_run`
88
+ `add_dataset_example`, `run_experiment`, `list_experiment_runs`, `get_experiment_run`,
89
+ `optimize_prompt`, `get_optimize_report`, `promote_optimize_candidate`
89
90
 
90
91
  Anywhere a tool takes a prompt or a catalog tool, a **name works as well as a UUID**.
91
92
 
package/dist/bin.js CHANGED
@@ -276,10 +276,15 @@ function toApiError(label, status, body) {
276
276
  }
277
277
 
278
278
  // src/format.ts
279
+ var TRUNCATION_MARKER = "\n\n[truncated: response exceeded the size limit \u2014 narrow the query, use a smaller `limit`, or fetch one item at a time]";
280
+ function isTruncated(result) {
281
+ const first = result.content[0];
282
+ return first?.type === "text" && first.text.endsWith(TRUNCATION_MARKER);
283
+ }
279
284
  function truncateText(text, maxBytes) {
280
285
  const encoder = new TextEncoder();
281
286
  if (encoder.encode(text).length <= maxBytes) return text;
282
- const marker = "\n\n[truncated: response exceeded the size limit \u2014 narrow the query, use a smaller `limit`, or fetch one item at a time]";
287
+ const marker = TRUNCATION_MARKER;
283
288
  const budget = Math.max(0, maxBytes - encoder.encode(marker).length);
284
289
  let end = Math.min(text.length, budget);
285
290
  while (end > 0 && encoder.encode(text.slice(0, end)).length > budget) {
@@ -662,10 +667,87 @@ var catalogTools = [
662
667
 
663
668
  // src/toolsets/evaluations.ts
664
669
  var import_zod2 = require("zod");
670
+
671
+ // src/optimize-report.ts
672
+ function summarizeCandidates(grid, cells) {
673
+ const cellByKey = new Map(cells.map((cell) => [cell.cellKey, cell]));
674
+ const baselineByModel = new Map(
675
+ cells.filter((cell) => cell.isProductionBaseline).map((cell) => [cell.model, cell])
676
+ );
677
+ const byCandidate = /* @__PURE__ */ new Map();
678
+ for (const gridCell of grid) {
679
+ if (gridCell.variantKind !== "candidate" || !gridCell.promptCandidateId) continue;
680
+ const reportCell = cellByKey.get(gridCell.cellKey);
681
+ const baseline = baselineByModel.get(gridCell.model);
682
+ let summary = byCandidate.get(gridCell.promptCandidateId);
683
+ if (!summary) {
684
+ summary = {
685
+ candidateId: gridCell.promptCandidateId,
686
+ label: gridCell.variantLabel,
687
+ scores: [],
688
+ beatsBaseline: false
689
+ };
690
+ byCandidate.set(gridCell.promptCandidateId, summary);
691
+ }
692
+ summary.scores.push({
693
+ model: gridCell.model,
694
+ avgScore: reportCell?.avgScore ?? null,
695
+ passRate: reportCell?.passRate ?? null,
696
+ baselineAvgScore: baseline?.avgScore ?? null,
697
+ scoreDelta: reportCell?.deltaVsBaseline?.score ?? null,
698
+ verdict: reportCell?.deltaVsBaseline?.label ?? "unknown"
699
+ });
700
+ }
701
+ const summaries = [...byCandidate.values()];
702
+ for (const summary of summaries) {
703
+ summary.beatsBaseline = summary.scores.some((score) => score.verdict === "improved") && summary.scores.every((score) => score.verdict === "improved" || score.verdict === "flat");
704
+ }
705
+ return summaries.sort((a, b) => a.label.localeCompare(b.label));
706
+ }
707
+ function bestCandidate(candidates) {
708
+ let best = null;
709
+ let bestDelta = -Infinity;
710
+ for (const candidate of candidates) {
711
+ if (!candidate.beatsBaseline) continue;
712
+ const deltas = candidate.scores.map((score) => score.scoreDelta).filter((delta) => delta !== null);
713
+ const mean = deltas.reduce((sum, delta) => sum + delta, 0) / Math.max(1, deltas.length);
714
+ if (mean > bestDelta) {
715
+ best = candidate;
716
+ bestDelta = mean;
717
+ }
718
+ }
719
+ return best;
720
+ }
721
+
722
+ // src/toolsets/evaluations.ts
665
723
  var historyMessageSchema = import_zod2.z.object({
666
724
  role: import_zod2.z.enum(["system", "user", "assistant", "tool"]),
667
725
  content: import_zod2.z.string()
668
726
  });
727
+ async function loadOptimizeRun(client, runId) {
728
+ const run = await client.get(`/runs/${runId}`);
729
+ const notDraftedYet = run.grid.length === 0;
730
+ if (run.kind !== "optimize" && !notDraftedYet) {
731
+ throw new Error(
732
+ `Run ${runId} is an evaluation run, not an optimize run. Read it with get_experiment_run.`
733
+ );
734
+ }
735
+ if (run.status !== "succeeded") return { run, report: null, candidates: [] };
736
+ const report = await client.get(`/runs/${runId}/report`);
737
+ return { run, report, candidates: summarizeCandidates(run.grid, report.cells) };
738
+ }
739
+ function baselineOf(run) {
740
+ const cell = run.grid.find((row) => row.isProductionBaseline && row.promptVersionId);
741
+ if (!cell?.promptVersionId) {
742
+ throw new Error(`Run ${run.id} has no baseline version, so a promote cannot be checked.`);
743
+ }
744
+ const alias = /^v\d+$/.test(cell.variantLabel) ? null : cell.variantLabel;
745
+ return { versionId: cell.promptVersionId, alias };
746
+ }
747
+ function formatDelta(delta) {
748
+ if (delta === null) return "n/a";
749
+ return `${delta >= 0 ? "+" : ""}${delta.toFixed(1)}`;
750
+ }
669
751
  var evaluationTools = [
670
752
  defineTool({
671
753
  name: "list_datasets",
@@ -744,21 +826,29 @@ var evaluationTools = [
744
826
  title: "Create and start an experiment run",
745
827
  toolset: "evaluations",
746
828
  readOnlyHint: false,
747
- description: "Create an experiment over a dataset and immediately start a run of it. The grid is every (version x model) pair you list. When `prompt` is given, a baseline cell for its production alias is added automatically unless that version is already in the grid. Returns as soon as the run is queued \u2014 the worker executes it asynchronously, so poll get_experiment_run for results. Note the judge model (gpt-4o-mini by default) must itself be registered in the gateway or every score fails.",
829
+ description: "Create an experiment over a dataset and immediately start a run of it. The grid is every (version x model) pair you list. When `prompt` is given, a baseline cell for its production alias is added automatically unless that version is already in the grid. Returns as soon as the run is queued \u2014 the worker executes it asynchronously, so poll get_experiment_run for results. `judge_model` scores every answer and must be registered in the gateway (see list_models). `judge_prompt` grades with one of your prompts instead of the built-in judge.",
748
830
  inputSchema: {
749
831
  dataset_id: import_zod2.z.string().uuid().describe("Dataset to evaluate against."),
750
832
  version_ids: import_zod2.z.array(import_zod2.z.string().uuid()).min(1).describe("Prompt-version UUIDs to sweep. At least one."),
751
833
  models: import_zod2.z.array(import_zod2.z.string().min(1)).min(1).describe("Gateway model public names to sweep."),
752
834
  prompt: import_zod2.z.string().optional().describe("Prompt name or UUID under test \u2014 enables the automatic baseline cell."),
753
835
  name: import_zod2.z.string().optional().describe("Experiment name, for the run history."),
754
- alias: import_zod2.z.string().optional().describe('Which alias the automatic baseline points at. Default "production".')
836
+ alias: import_zod2.z.string().optional().describe('Which alias the automatic baseline points at. Default "production".'),
837
+ judge_model: import_zod2.z.string().min(1).describe("Gateway model that scores every answer. Required."),
838
+ judge_prompt: import_zod2.z.string().min(1).optional().describe(
839
+ "Name or UUID of a team prompt that grades instead of the built-in judge. The run uses its production version (else its latest). Default: the built-in judge."
840
+ )
755
841
  },
756
842
  async handler(args, { client, config }) {
757
843
  const body = {
758
844
  dataset_id: args.dataset_id,
759
845
  version_ids: args.version_ids,
760
- models: args.models
846
+ models: args.models,
847
+ judge_model: args.judge_model
761
848
  };
849
+ if (args.judge_prompt !== void 0) {
850
+ body.judge_prompt_id = await resolvePromptRef(client, args.judge_prompt);
851
+ }
762
852
  if (args.name !== void 0) body.name = args.name;
763
853
  if (args.alias !== void 0) body.alias = args.alias;
764
854
  if (args.prompt !== void 0) body.prompt_id = await resolvePromptRef(client, args.prompt);
@@ -800,23 +890,214 @@ var evaluationTools = [
800
890
  title: "Get a run, optionally with its comparison report",
801
891
  toolset: "evaluations",
802
892
  readOnlyHint: true,
803
- description: "Read one run's status and results. With include_report=true it also fetches the comparison report: the full (variant x model) matrix with per-cell averages, each cell's delta against the same-model production baseline, a leaderboard and an advisory winner. Ask for the report only once the run has succeeded \u2014 an in-flight run has nothing to compare.",
893
+ description: "Read one run's status and results. With include_report=true it also fetches the comparison report: the full (variant x model) matrix with per-cell averages, each cell's delta against the same-model production baseline, a leaderboard and an advisory winner. Ask for the report only once the run has succeeded \u2014 an in-flight run has nothing to compare. For an optimize run, get_optimize_report gives the same scores per candidate, with the ids promote_optimize_candidate needs.",
804
894
  inputSchema: {
805
895
  run_id: import_zod2.z.string().uuid().describe("Run UUID."),
806
896
  include_report: import_zod2.z.boolean().optional().describe("Also fetch the comparison report.")
807
897
  },
808
898
  async handler({ run_id, include_report }, { client, config }) {
809
- const run = await client.get(`/runs/${run_id}`);
899
+ const run = await client.get(
900
+ `/runs/${run_id}`
901
+ );
810
902
  let report = void 0;
811
903
  if (include_report) {
812
904
  report = await client.get(`/runs/${run_id}/report`);
813
905
  }
814
906
  return okResult(
815
- `Run ${run_id} is ${run.status} with ${run.results?.length ?? 0} result(s).`,
907
+ `Run ${run_id} is ${run.status} with ${run.results?.total ?? 0} result(s).`,
816
908
  { run, report },
817
909
  config.maxTextBytes
818
910
  );
819
911
  }
912
+ }),
913
+ defineTool({
914
+ name: "optimize_prompt",
915
+ title: "Start an optimizer run for a prompt",
916
+ toolset: "evaluations",
917
+ readOnlyHint: false,
918
+ description: 'Ask the optimizer to improve a prompt against a dataset. An optimizer model drafts `draft_count` rewrites of the version behind `alias` (default "production"), then every rewrite and that baseline run over the dataset on each of `models`, and `judge_model` scores every answer. Returns the run id as soon as the run is queued \u2014 drafting and scoring take minutes, so poll get_optimize_report. Nothing is promoted automatically: a rewrite only ships when promote_optimize_candidate is called. The dataset\'s examples need `criteria`, or the built-in judge has nothing to grade against. `judge_prompt` grades with one of your prompts instead, and the optimizer reads it too, so the rewrites aim at that rubric. Every model named here must be registered in the gateway (see list_models).',
919
+ inputSchema: {
920
+ prompt: import_zod2.z.string().min(1).describe("Prompt name or UUID to improve."),
921
+ dataset_id: import_zod2.z.string().uuid().describe("Dataset whose examples the rewrites are scored on."),
922
+ models: import_zod2.z.array(import_zod2.z.string().min(1)).min(1).describe("Gateway model public names to test each rewrite and the baseline on."),
923
+ judge_model: import_zod2.z.string().min(1).describe("Gateway model that scores every answer. Required."),
924
+ optimizer_model: import_zod2.z.string().min(1).optional().describe("Gateway model that writes the rewrites. Default: the first entry of `models`."),
925
+ draft_count: import_zod2.z.number().int().min(1).max(6).optional().describe("How many rewrites to draft, 1 to 6. Default 3."),
926
+ alias: import_zod2.z.string().min(1).optional().describe('Alias whose version is the baseline to beat. Default "production".'),
927
+ optimizer_prompt: import_zod2.z.string().optional().describe("Name or UUID of a team prompt to use as the optimizer's instructions."),
928
+ judge_prompt: import_zod2.z.string().min(1).optional().describe(
929
+ "Name or UUID of a team prompt that grades instead of the built-in judge. The run uses its production version (else its latest). Default: the built-in judge."
930
+ )
931
+ },
932
+ async handler(args, { client, config }) {
933
+ const promptId = await resolvePromptRef(client, args.prompt);
934
+ const body = {
935
+ dataset_id: args.dataset_id,
936
+ models: args.models,
937
+ judge_model: args.judge_model
938
+ };
939
+ if (args.optimizer_model !== void 0) body.optimizer_model = args.optimizer_model;
940
+ if (args.draft_count !== void 0) body.draft_count = args.draft_count;
941
+ if (args.alias !== void 0) body.alias = args.alias;
942
+ if (args.optimizer_prompt !== void 0) {
943
+ body.optimizer_prompt_id = await resolvePromptRef(client, args.optimizer_prompt);
944
+ }
945
+ if (args.judge_prompt !== void 0) {
946
+ body.judge_prompt_id = await resolvePromptRef(client, args.judge_prompt);
947
+ }
948
+ const result = await client.post(`/prompts/${promptId}/optimize`, body);
949
+ const warning = result.prompt_mismatch_warning ? " Warning: some dataset examples were built from a different prompt \u2014 see prompt_mismatch_warning." : "";
950
+ return okResult(
951
+ `Optimize run ${result.run_id} is ${result.status}; ${result.optimizer_model} drafts the rewrites, then they are scored against the ${args.alias ?? "production"} baseline. Poll get_optimize_report with this run id.${warning}`,
952
+ result,
953
+ config.maxTextBytes
954
+ );
955
+ }
956
+ }),
957
+ defineTool({
958
+ name: "get_optimize_report",
959
+ title: "Read an optimizer run's candidates and scores",
960
+ toolset: "evaluations",
961
+ readOnlyHint: true,
962
+ description: "Read an optimize run started by optimize_prompt. While it is queued or running this returns only its status \u2014 call again later. Once it has succeeded it lists every drafted rewrite with its candidate id, the optimizer's rationale, the rewritten messages, and its average score next to the baseline's on each model. `beatsBaseline` is true only for a rewrite that improved on at least one model and was improved or flat on every other; `recommended` is the one among those with the largest gain, or null when no rewrite beat the baseline. When the rewrites are too long to fit in one result, their messages are left out (`messagesOmitted`); pass `candidate_id` to read one rewrite in full.",
963
+ inputSchema: {
964
+ run_id: import_zod2.z.string().uuid().describe("Optimize run UUID, from optimize_prompt."),
965
+ candidate_id: import_zod2.z.string().uuid().optional().describe("Return only this candidate, with its full messages.")
966
+ },
967
+ async handler({ run_id, candidate_id }, { client, config }) {
968
+ const { run, report, candidates } = await loadOptimizeRun(client, run_id);
969
+ if (run.status === "queued" || run.status === "running") {
970
+ return okResult(
971
+ `Optimize run ${run_id} is ${run.status}. No scores yet \u2014 call get_optimize_report again shortly.`,
972
+ { runId: run_id, status: run.status },
973
+ config.maxTextBytes
974
+ );
975
+ }
976
+ if (run.status === "failed") {
977
+ return okResult(
978
+ `Optimize run ${run_id} failed: ${run.error ?? "no reason recorded"}. Nothing to promote.`,
979
+ { runId: run_id, status: run.status, error: run.error },
980
+ config.maxTextBytes
981
+ );
982
+ }
983
+ if (candidate_id !== void 0) {
984
+ const candidate = candidates.find((row) => row.candidateId === candidate_id);
985
+ if (!candidate) {
986
+ throw new Error(`Candidate ${candidate_id} is not a rewrite from run ${run_id}.`);
987
+ }
988
+ const detail = await client.get(
989
+ `/runs/${run_id}/candidates/${candidate_id}`
990
+ );
991
+ return okResult(
992
+ `${candidate.label} (candidate_id ${candidate_id}) from optimize run ${run_id}; beatsBaseline: ${candidate.beatsBaseline}.`,
993
+ {
994
+ runId: run_id,
995
+ candidate: { ...candidate, rationale: detail.rationale, messages: detail.messages }
996
+ },
997
+ config.maxTextBytes
998
+ );
999
+ }
1000
+ const reads = await Promise.allSettled(
1001
+ candidates.map(
1002
+ (candidate) => client.get(`/runs/${run_id}/candidates/${candidate.candidateId}`)
1003
+ )
1004
+ );
1005
+ const withContent = candidates.map((candidate, index) => {
1006
+ const read = reads[index];
1007
+ return read.status === "fulfilled" ? { ...candidate, rationale: read.value.rationale, messages: read.value.messages } : {
1008
+ ...candidate,
1009
+ rationale: null,
1010
+ messages: null,
1011
+ readError: read.reason instanceof Error ? read.reason.message : String(read.reason)
1012
+ };
1013
+ });
1014
+ const best = bestCandidate(candidates);
1015
+ const winners = candidates.filter((candidate) => candidate.beatsBaseline).length;
1016
+ const rejected = report?.optimizerRejections ?? [];
1017
+ const rejectedNote = rejected.length > 0 ? ` ${rejected.length} more rewrite(s) were dropped before scoring: ${rejected.join(" | ")}.` : "";
1018
+ const summary = (best ? `Optimize run ${run_id} succeeded. ${winners} of ${candidates.length} rewrite(s) beat the baseline. Recommended: ${best.label} (candidate_id ${best.candidateId}), ` + best.scores.map((score) => `${formatDelta(score.scoreDelta)} on ${score.model}`).join(", ") + ". Promote it with promote_optimize_candidate only if the user agrees." : `Optimize run ${run_id} succeeded, but none of its ${candidates.length} rewrite(s) beat the baseline. Nothing is worth promoting; keep the current version.`) + rejectedNote;
1019
+ const payload = {
1020
+ runId: run_id,
1021
+ status: run.status,
1022
+ judgeModel: report?.judge?.model ?? null,
1023
+ // Null means the built-in judge graded the run.
1024
+ judgePromptVersionId: report?.judge?.promptVersionId ?? null,
1025
+ recommended: best?.candidateId ?? null,
1026
+ rejectedRewrites: rejected,
1027
+ baseline: (report?.cells ?? []).filter((cell) => cell.isProductionBaseline).map((cell) => ({
1028
+ label: cell.variantLabel,
1029
+ model: cell.model,
1030
+ avgScore: cell.avgScore,
1031
+ passRate: cell.passRate
1032
+ })),
1033
+ candidates: withContent
1034
+ };
1035
+ const full = okResult(summary, payload, config.maxTextBytes);
1036
+ if (!isTruncated(full)) return full;
1037
+ return okResult(
1038
+ `${summary} The rewrites are too long to show together, so their messages are left out; call get_optimize_report again with candidate_id to read one.`,
1039
+ {
1040
+ ...payload,
1041
+ messagesOmitted: true,
1042
+ candidates: withContent.map(({ messages: _messages, ...rest }) => rest)
1043
+ },
1044
+ config.maxTextBytes
1045
+ );
1046
+ }
1047
+ }),
1048
+ defineTool({
1049
+ name: "promote_optimize_candidate",
1050
+ title: "Ship an optimizer rewrite as a new prompt version",
1051
+ toolset: "evaluations",
1052
+ readOnlyHint: false,
1053
+ destructiveHint: true,
1054
+ description: "Commit one rewrite from a succeeded optimize run as a new prompt version and move `alias` to it, so live traffic on that alias starts using it. `alias` defaults to the alias the run was scored against (production when the run used the latest version). Take `candidate_id` from get_optimize_report. Refuses a rewrite that did not beat the baseline, and refuses when the alias now points at a version other than the one the run scored against; set `promote_without_improvement` only when the user has explicitly asked to ship that rewrite anyway. Undo by moving the alias back with promote_prompt_alias.",
1055
+ inputSchema: {
1056
+ run_id: import_zod2.z.string().uuid().describe("Optimize run UUID."),
1057
+ candidate_id: import_zod2.z.string().uuid().describe("Candidate id from get_optimize_report."),
1058
+ alias: import_zod2.z.string().min(1).optional().describe("Alias to move. Default: the alias the run was scored against."),
1059
+ promote_without_improvement: import_zod2.z.boolean().optional().describe(
1060
+ "Ship a rewrite that did not beat the baseline, or replace a version the run never scored. Only on the user's explicit say-so."
1061
+ )
1062
+ },
1063
+ async handler(args, { client, config }) {
1064
+ const { run, candidates } = await loadOptimizeRun(client, args.run_id);
1065
+ if (run.status !== "succeeded") {
1066
+ throw new Error(
1067
+ `Optimize run ${args.run_id} is ${run.status}, so its rewrites are not fully scored. Promote only from a succeeded run.`
1068
+ );
1069
+ }
1070
+ const candidate = candidates.find((row) => row.candidateId === args.candidate_id);
1071
+ if (!candidate) {
1072
+ throw new Error(
1073
+ `Candidate ${args.candidate_id} is not a rewrite from run ${args.run_id}. Take the id from get_optimize_report.`
1074
+ );
1075
+ }
1076
+ if (!candidate.beatsBaseline && !args.promote_without_improvement) {
1077
+ const scores = candidate.scores.map((score) => `${formatDelta(score.scoreDelta)} (${score.verdict}) on ${score.model}`).join(", ");
1078
+ throw new Error(
1079
+ `Refused: ${candidate.label} did not beat the baseline (${scores}). Promoting it would ship a prompt that scored no better than the current one. Set promote_without_improvement to true only if the user explicitly asked for this rewrite.`
1080
+ );
1081
+ }
1082
+ const baseline = baselineOf(run);
1083
+ const alias = args.alias ?? baseline.alias ?? "production";
1084
+ const detail = await client.get(
1085
+ `/runs/${args.run_id}/candidates/${args.candidate_id}`
1086
+ );
1087
+ const aliases = await client.get(`/prompts/${detail.promptId}/aliases`);
1088
+ const live = aliases.find((row) => row.alias === alias);
1089
+ if (live && live.versionId !== baseline.versionId && !args.promote_without_improvement) {
1090
+ throw new Error(
1091
+ `Refused: alias "${alias}" now points at version ${live.versionNumber}, but run ${args.run_id} scored its rewrites against a different version. Nothing compared ${candidate.label} with version ${live.versionNumber}. Start a new optimize run against this alias, or set promote_without_improvement to true only if the user explicitly asked to replace it anyway.`
1092
+ );
1093
+ }
1094
+ const result = await client.post(`/runs/${args.run_id}/promote`, { prompt_candidate_id: args.candidate_id, alias });
1095
+ return okResult(
1096
+ `Promoted ${candidate.label} as version ${result.version.versionNumber}; alias "${result.alias.alias}" now points at it.`,
1097
+ result,
1098
+ config.maxTextBytes
1099
+ );
1100
+ }
820
1101
  })
821
1102
  ];
822
1103
 
@@ -843,6 +1124,9 @@ function gatewayMeta(headers) {
843
1124
  }
844
1125
  return meta;
845
1126
  }
1127
+ function traceSuffix(meta) {
1128
+ return meta["trace-id"] ? ` Open it with get_trace, trace_id ${meta["trace-id"]}.` : "";
1129
+ }
846
1130
  function firstChoiceText(body) {
847
1131
  const choice = body?.choices?.[0];
848
1132
  const content = choice?.message?.content;
@@ -887,9 +1171,7 @@ var gatewayTools = [
887
1171
  });
888
1172
  const meta = gatewayMeta(response.headers);
889
1173
  return okResult(
890
- `Ran "${name}"@${args.alias ?? "production"} on ${meta.model ?? "the resolved model"}${meta["cost-usd"] ? ` for $${meta["cost-usd"]}` : ""}. Reply: ${firstChoiceText(
891
- response.data
892
- ).slice(0, 400)}`,
1174
+ `Ran "${name}"@${args.alias ?? "production"} on ${meta.model ?? "the resolved model"}${meta["cost-usd"] ? ` for $${meta["cost-usd"]}` : ""}.${traceSuffix(meta)} Reply: ` + firstChoiceText(response.data).slice(0, 400),
893
1175
  { completion: response.data, gateway: meta },
894
1176
  config.maxTextBytes
895
1177
  );
@@ -930,9 +1212,7 @@ var gatewayTools = [
930
1212
  });
931
1213
  const meta = gatewayMeta(response.headers);
932
1214
  return okResult(
933
- `Completion from ${meta.model ?? args.model}${meta["cost-usd"] ? ` ($${meta["cost-usd"]})` : ""}: ${firstChoiceText(
934
- response.data
935
- ).slice(0, 400)}`,
1215
+ `Completion from ${meta.model ?? args.model}${meta["cost-usd"] ? ` ($${meta["cost-usd"]})` : ""}.${traceSuffix(meta)} Reply: ` + firstChoiceText(response.data).slice(0, 400),
936
1216
  { completion: response.data, gateway: meta },
937
1217
  config.maxTextBytes
938
1218
  );
@@ -972,6 +1252,37 @@ var gatewayTools = [
972
1252
  config.maxTextBytes
973
1253
  );
974
1254
  }
1255
+ }),
1256
+ defineTool({
1257
+ name: "get_spending_limits",
1258
+ title: "Get budgets and virtual-key limits",
1259
+ toolset: "gateway",
1260
+ readOnlyHint: true,
1261
+ description: "Every spend budget on the team \u2014 team-wide or on one virtual key \u2014 with its period, limit, live spend, what is left and when it resets, plus each virtual key's rate limits and model/provider allow-lists. Use this after a gateway call is refused with HTTP 402 (budget spent) or 429 (rate limit) to see which limit caused it. Read-only: budgets and keys are changed in the dashboard.",
1262
+ inputSchema: {},
1263
+ async handler(_args, { client, config }) {
1264
+ const [budgets, keys] = await Promise.all([
1265
+ client.get("/gateway/budgets"),
1266
+ client.get("/gateway/keys")
1267
+ ]);
1268
+ const keyNames = new Map(keys.map((k) => [k.id, k.name]));
1269
+ const limits = budgets.map((b) => ({
1270
+ id: b.id,
1271
+ scope: b.virtualKeyId === null ? "team" : "virtual_key",
1272
+ virtualKeyId: b.virtualKeyId,
1273
+ virtualKeyName: b.virtualKeyId === null ? null : keyNames.get(b.virtualKeyId) ?? null,
1274
+ period: b.period,
1275
+ limitUsd: b.limitUsd,
1276
+ spendUsd: b.spendUsd,
1277
+ remainingUsd: Math.max(0, b.limitUsd - b.spendUsd),
1278
+ exhausted: b.spendUsd >= b.limitUsd,
1279
+ resetsAt: b.resetsAt
1280
+ }));
1281
+ const summary = limits.length === 0 ? `No budgets on this team, so the gateway never refuses a call for spend. ${keys.length} virtual key(s).` : `${limits.length} budget(s), ${keys.length} virtual key(s). ` + limits.map(
1282
+ (l) => `${l.scope === "team" ? "Team" : `Key "${l.virtualKeyName ?? l.virtualKeyId}"`} ${l.period}: $${l.spendUsd} of $${l.limitUsd} spent` + (l.exhausted ? " (EXHAUSTED)" : "") + (l.resetsAt ? `, resets ${l.resetsAt}` : "")
1283
+ ).join("; ") + ".";
1284
+ return okResult(summary, { budgets: limits, virtualKeys: keys }, config.maxTextBytes);
1285
+ }
975
1286
  })
976
1287
  ];
977
1288
 
@@ -1337,7 +1648,7 @@ function selectTools(config) {
1337
1648
  }
1338
1649
 
1339
1650
  // src/server.ts
1340
- var SERVER_VERSION = "0.1.0";
1651
+ var SERVER_VERSION = "0.3.0";
1341
1652
  function buildInstructions(toolNames, readOnly) {
1342
1653
  const parts = [
1343
1654
  `AcruxCore is an LLM-ops platform: a prompt registry, a versioned tool catalog, an AI gateway, tracing, and evaluation.