swarmdo 1.58.24 → 1.58.31

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.
@@ -642,7 +642,11 @@ function renderRateLimitSlot() {
642
642
  // misses, the displayed version is meaningful (matches what the user
643
643
  // installed), not a stale hard-coded string.
644
644
  function getPkgVersion() {
645
- let ver = '1.58.9';
645
+ // #1951: an explicit SWARMDO_VERSION override (set by the CLI wrapper) wins over
646
+ // every probe below, so the header can't silently revert to a stale baked default
647
+ // when a global/npx install layout hides package.json.
648
+ if (process.env.SWARMDO_VERSION) return process.env.SWARMDO_VERSION;
649
+ let ver = '1.58.24';
646
650
  try {
647
651
  const home = os.homedir();
648
652
  const pkgPaths = [
@@ -862,6 +866,10 @@ function generateJSON() {
862
866
  return Object.assign({}, d, {
863
867
  user: Object.assign({ name: git.name, gitBranch: git.gitBranch }, d.user || {}),
864
868
  git: { modified: git.modified, untracked: git.untracked, staged: git.staged, ahead: git.ahead, behind: git.behind },
869
+ // #2195: surface the baked maxAgents denominator in the swarm object so the
870
+ // delegation contract (and its smoke) always sees swarm.maxAgents, even though
871
+ // 'hooks statusline --json' reports only the live activeSwarms/activeAgents.
872
+ swarm: Object.assign({ maxAgents: CONFIG.maxAgents }, d.swarm || {}),
865
873
  lastUpdated: new Date().toISOString(),
866
874
  });
867
875
  }
package/README.md CHANGED
@@ -298,6 +298,7 @@ The recent release train added a full day-to-day operations layer around the swa
298
298
  | `swarmdo testreport` | **JUnit/TAP → failure digest** — turn raw test-result files into the exact failing test names + `file:line` + assertion messages, instead of scanning hundreds of log lines. The front-half of the test→fix loop: feed the failures straight into `repair`. Reads a file, a directory, or stdin; `--ci` exits 1 on any failure, `--format json`. Also an MCP tool (`testreport`). Deterministic |
299
299
  | `swarmdo integrations` (alias `integrate`) | **Use swarmdo from Codex CLI, GitHub Copilot CLI, and pi** — one command wires AGENTS.md + each CLI's MCP config (idempotent, dry-run first, never touches your Claude Code setup) |
300
300
  | OpenRouter model pool | **Let swarms pick from any models you configure** — declare tier-mapped OpenRouter models in `swarmdo.config.json`; the router Thompson-samples among them per task and the execution layer dispatches the winner |
301
+ | `route serve` (run Claude Code on any model) | **A local Anthropic-compatible proxy** — `swarmdo route serve` + `ANTHROPIC_BASE_URL=http://127.0.0.1:3456` points Claude Code *itself* at your OpenRouter pool: full SSE streaming, tier routing, and per-model learned priors that favor what works for you. Run Claude Code on Llama/DeepSeek/Gemini/Qwen/etc. — no vendored proxy, no extra process |
301
302
  | `swarmdo changelog` (alias `notes`) | **Release notes from conventional commits** — `--out NOTES.md` feeds `gh release create --notes-file`; `--contributors` appends a credited contributor roll |
302
303
  | `swarmdo mcp doctor` | **MCP config diagnosis** — missing binaries, bad URLs, malformed entries across `.mcp.json` + `~/.claude.json` |
303
304
  | `swarmdo config lint` | **Static config validation** — the pure shape layer for `swarmdo.config.json`, the `.claude/settings*.json` hooks block, `.mcp.json`, **`.claude/agents/*.md` subagents** (missing `name`/`description`, a `name` duplicated across files — CC silently loads only one — a bad `model`, malformed frontmatter), and **custom slash commands + skills** (malformed YAML that makes CC load empty metadata, a bad `effort`, an inline `` !`cmd` `` bash-injection that's inert or not covered by `allowed-tools`). `--strict` gates CI |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarmdo",
3
- "version": "1.58.24",
3
+ "version": "1.58.31",
4
4
  "description": "Swarmdo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -642,7 +642,11 @@ function renderRateLimitSlot() {
642
642
  // misses, the displayed version is meaningful (matches what the user
643
643
  // installed), not a stale hard-coded string.
644
644
  function getPkgVersion() {
645
- let ver = '1.58.9';
645
+ // #1951: an explicit SWARMDO_VERSION override (set by the CLI wrapper) wins over
646
+ // every probe below, so the header can't silently revert to a stale baked default
647
+ // when a global/npx install layout hides package.json.
648
+ if (process.env.SWARMDO_VERSION) return process.env.SWARMDO_VERSION;
649
+ let ver = '1.58.24';
646
650
  try {
647
651
  const home = os.homedir();
648
652
  const pkgPaths = [
@@ -862,6 +866,10 @@ function generateJSON() {
862
866
  return Object.assign({}, d, {
863
867
  user: Object.assign({ name: git.name, gitBranch: git.gitBranch }, d.user || {}),
864
868
  git: { modified: git.modified, untracked: git.untracked, staged: git.staged, ahead: git.ahead, behind: git.behind },
869
+ // #2195: surface the baked maxAgents denominator in the swarm object so the
870
+ // delegation contract (and its smoke) always sees swarm.maxAgents, even though
871
+ // 'hooks statusline --json' reports only the live activeSwarms/activeAgents.
872
+ swarm: Object.assign({ maxAgents: CONFIG.maxAgents }, d.swarm || {}),
865
873
  lastUpdated: new Date().toISOString(),
866
874
  });
867
875
  }
@@ -736,6 +736,67 @@ const coverageRouteCommand = {
736
736
  // ============================================================================
737
737
  // Main Route Command
738
738
  // ============================================================================
739
+ const serveCommand = {
740
+ name: 'serve',
741
+ description: 'Start a local Anthropic-compatible model-router proxy (point Claude Code at it via ANTHROPIC_BASE_URL)',
742
+ options: [
743
+ { name: 'port', short: 'p', description: 'Port to listen on', type: 'number', default: 3456 },
744
+ { name: 'host', description: 'Host to bind', type: 'string', default: '127.0.0.1' },
745
+ ],
746
+ examples: [
747
+ { command: 'swarmdo route serve', description: 'Start the router proxy on 127.0.0.1:3456' },
748
+ { command: 'ANTHROPIC_BASE_URL=http://127.0.0.1:3456 claude', description: 'Point Claude Code at the proxy' },
749
+ ],
750
+ action: async (ctx) => {
751
+ const { loadOpenRouterConfig } = await import('../providers/openrouter-config.js');
752
+ const { startProxy, loadPriors, savePriors, recordOutcome } = await import('../route-serve/proxy.js');
753
+ const { config, warnings } = loadOpenRouterConfig(process.cwd());
754
+ for (const w of warnings)
755
+ output.printWarning(w);
756
+ if (!config.enabled || config.models.length === 0) {
757
+ output.printError('OpenRouter pool is not configured.');
758
+ output.writeln(output.dim(' Add an `openrouter` block to swarmdo.config.json — e.g.'));
759
+ output.writeln(output.dim(' {"openrouter":{"enabled":true,"models":[{"id":"meta-llama/llama-3.3-70b-instruct","tier":"sonnet"}]}}'));
760
+ return { success: false, exitCode: 1, message: 'openrouter pool not configured' };
761
+ }
762
+ const apiKey = process.env[config.apiKeyEnv];
763
+ if (!apiKey) {
764
+ output.printError(`Missing API key — set ${config.apiKeyEnv} in the environment.`);
765
+ return { success: false, exitCode: 1, message: `missing ${config.apiKeyEnv}` };
766
+ }
767
+ const port = Number(ctx.flags.port ?? 3456);
768
+ const host = String(ctx.flags.host ?? '127.0.0.1');
769
+ const cwd = process.cwd();
770
+ const priors = loadPriors(cwd); // learned per-slug Beta priors from prior runs
771
+ let saveTimer = null;
772
+ const scheduleSave = () => {
773
+ if (saveTimer)
774
+ return;
775
+ saveTimer = setTimeout(() => { saveTimer = null; savePriors(priors, cwd); }, 2000);
776
+ };
777
+ const { server, url } = await startProxy({
778
+ cfg: config,
779
+ apiKey,
780
+ port,
781
+ host,
782
+ priors,
783
+ onOutcome: (model, success) => { Object.assign(priors, recordOutcome(priors, model, success)); scheduleSave(); },
784
+ log: (m) => output.writeln(output.dim(` ${m}`)),
785
+ });
786
+ const tiers = [...new Set(config.models.map((m) => m.tier).filter(Boolean))].join('/');
787
+ output.printSuccess(`swarmdo route serve — listening on ${url}`);
788
+ output.writeln(` pool: ${config.models.length} model(s)${tiers ? ` · tiers ${tiers}` : ''}`);
789
+ output.writeln(output.bold(` ANTHROPIC_BASE_URL=${url} claude`));
790
+ output.writeln(output.dim(' Ctrl-C to stop.'));
791
+ // Run until interrupted (SIGINT/SIGTERM), then close the listener cleanly.
792
+ await new Promise((resolve) => {
793
+ const stop = () => { savePriors(priors, cwd); server.close(() => resolve()); };
794
+ process.once('SIGINT', stop);
795
+ process.once('SIGTERM', stop);
796
+ });
797
+ return { success: true };
798
+ },
799
+ };
739
800
  export const routeCommand = {
740
801
  name: 'route',
741
802
  description: 'Intelligent task-to-agent routing using Q-Learning',
@@ -748,6 +809,7 @@ export const routeCommand = {
748
809
  exportCommand,
749
810
  importCommand,
750
811
  coverageRouteCommand,
812
+ serveCommand,
751
813
  ],
752
814
  options: [
753
815
  {
@@ -703,6 +703,10 @@ function renderRateLimitSlot() {
703
703
  // misses, the displayed version is meaningful (matches what the user
704
704
  // installed), not a stale hard-coded string.
705
705
  function getPkgVersion() {
706
+ // #1951: an explicit SWARMDO_VERSION override (set by the CLI wrapper) wins over
707
+ // every probe below, so the header can't silently revert to a stale baked default
708
+ // when a global/npx install layout hides package.json.
709
+ if (process.env.SWARMDO_VERSION) return process.env.SWARMDO_VERSION;
706
710
  let ver = '${BAKED_CLI_VERSION}';
707
711
  try {
708
712
  const home = os.homedir();
@@ -923,6 +927,10 @@ function generateJSON() {
923
927
  return Object.assign({}, d, {
924
928
  user: Object.assign({ name: git.name, gitBranch: git.gitBranch }, d.user || {}),
925
929
  git: { modified: git.modified, untracked: git.untracked, staged: git.staged, ahead: git.ahead, behind: git.behind },
930
+ // #2195: surface the baked maxAgents denominator in the swarm object so the
931
+ // delegation contract (and its smoke) always sees swarm.maxAgents, even though
932
+ // 'hooks statusline --json' reports only the live activeSwarms/activeAgents.
933
+ swarm: Object.assign({ maxAgents: CONFIG.maxAgents }, d.swarm || {}),
926
934
  lastUpdated: new Date().toISOString(),
927
935
  });
928
936
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * route-serve/proxy.ts — a lightweight, Anthropic-compatible model-router proxy.
3
+ *
4
+ * `swarmdo route serve` starts this on 127.0.0.1:PORT. Point Claude Code (or any
5
+ * Anthropic client) at it with `ANTHROPIC_BASE_URL=http://127.0.0.1:PORT` and it
6
+ * routes each `/v1/messages` request to an OpenRouter model chosen from the
7
+ * user's configured pool (swarmdo.config.json `openrouter`), translating the
8
+ * Anthropic <-> OpenAI-compatible request/response shapes.
9
+ *
10
+ * Deliberately built on swarmdo's existing pieces — no vendored proxy, no new
11
+ * deps beyond Node's own `http`:
12
+ * - model selection: resolveOpenRouterModel() with Thompson-sampled `priors`
13
+ * (== learned routing) from providers/openrouter-config.
14
+ * - resilience: computeBackoffMs / isRetryableError / sleep from resilience/backoff.
15
+ *
16
+ * The only new surface is the HTTP serving layer + request/response translation,
17
+ * both kept PURE and fixture-tested (see __tests__/route-serve-proxy.test.ts).
18
+ *
19
+ * INCREMENT 1: non-streaming (forwards `stream:false` upstream and returns a
20
+ * single Anthropic message). Incremental SSE streaming is increment 2; until
21
+ * then a client that asked for stream still gets a correct, complete response
22
+ * (buffered), just not token-by-token.
23
+ */
24
+ import { type Server, type ServerResponse } from 'node:http';
25
+ import type { OpenRouterConfig, RoutingTier } from '../providers/openrouter-config.js';
26
+ export interface AnthropicContentBlock {
27
+ type: string;
28
+ text?: string;
29
+ [k: string]: unknown;
30
+ }
31
+ export interface AnthropicMessage {
32
+ role: 'user' | 'assistant';
33
+ content: string | AnthropicContentBlock[];
34
+ }
35
+ export interface AnthropicRequest {
36
+ model?: string;
37
+ messages: AnthropicMessage[];
38
+ system?: string | AnthropicContentBlock[];
39
+ max_tokens?: number;
40
+ temperature?: number;
41
+ top_p?: number;
42
+ stream?: boolean;
43
+ }
44
+ export interface OpenAIChatRequest {
45
+ model: string;
46
+ messages: Array<{
47
+ role: 'system' | 'user' | 'assistant';
48
+ content: string;
49
+ }>;
50
+ max_tokens?: number;
51
+ temperature?: number;
52
+ top_p?: number;
53
+ stream?: boolean;
54
+ }
55
+ export interface OpenAIChatResponse {
56
+ id?: string;
57
+ choices?: Array<{
58
+ message?: {
59
+ content?: string;
60
+ };
61
+ finish_reason?: string;
62
+ }>;
63
+ usage?: {
64
+ prompt_tokens?: number;
65
+ completion_tokens?: number;
66
+ };
67
+ }
68
+ /** Claude Code sends model ids like 'claude-3-5-haiku…', 'claude-sonnet-4…',
69
+ * 'claude-opus-4…'. Map by family; unknown → 'sonnet' (the safe middle tier). */
70
+ export declare function tierForRequest(body: Pick<AnthropicRequest, 'model'>): RoutingTier;
71
+ export declare function flattenContent(content: AnthropicMessage['content']): string;
72
+ export declare function anthropicToOpenAI(body: AnthropicRequest, model: string): OpenAIChatRequest;
73
+ export declare function openAIToAnthropic(resp: OpenAIChatResponse, model: string): Record<string, unknown>;
74
+ export interface Priors {
75
+ [modelId: string]: {
76
+ alpha: number;
77
+ beta: number;
78
+ };
79
+ }
80
+ export declare function selectModelForRequest(body: AnthropicRequest, cfg: OpenRouterConfig, priors?: Priors): {
81
+ model: string;
82
+ source: string;
83
+ tier: RoutingTier;
84
+ } | null;
85
+ /** Pure: fold one outcome into a Beta prior (success → α+1, failure → β+1). */
86
+ export declare function betaUpdate(prior: {
87
+ alpha: number;
88
+ beta: number;
89
+ } | undefined, success: boolean): {
90
+ alpha: number;
91
+ beta: number;
92
+ };
93
+ /** Pure: fold one outcome for `model` into a priors map, returning a new map. */
94
+ export declare function recordOutcome(priors: Priors, model: string, success: boolean): Priors;
95
+ export declare function loadPriors(cwd?: string): Priors;
96
+ export declare function savePriors(priors: Priors, cwd?: string): void;
97
+ export interface ForwardDeps {
98
+ cfg: OpenRouterConfig;
99
+ apiKey: string;
100
+ fetchImpl?: typeof fetch;
101
+ maxRetries?: number;
102
+ }
103
+ export declare function forwardChat(openaiReq: OpenAIChatRequest, deps: ForwardDeps): Promise<OpenAIChatResponse>;
104
+ export interface ProxyOptions {
105
+ cfg: OpenRouterConfig;
106
+ apiKey: string;
107
+ port?: number;
108
+ host?: string;
109
+ priors?: Priors;
110
+ /** injectable for tests; defaults to global fetch */
111
+ fetchImpl?: typeof fetch;
112
+ /** retry budget per upstream call (default 2) */
113
+ maxRetries?: number;
114
+ /** called after each request with the chosen model + whether it succeeded — drives learned priors */
115
+ onOutcome?: (model: string, success: boolean) => void;
116
+ log?: (msg: string) => void;
117
+ }
118
+ /** Orchestrate one /v1/messages request. Returns an HTTP status + Anthropic-shaped body. */
119
+ export declare function handleMessages(body: AnthropicRequest, opts: ProxyOptions): Promise<{
120
+ status: number;
121
+ json: unknown;
122
+ }>;
123
+ export interface SSEEvent {
124
+ event: string;
125
+ data: unknown;
126
+ }
127
+ /** Serialize one Anthropic SSE event to the wire format (`event:`/`data:` + blank line). */
128
+ export declare function serializeSSE(e: SSEEvent): string;
129
+ export interface OpenAIStreamChunk {
130
+ id?: string;
131
+ choices?: Array<{
132
+ delta?: {
133
+ content?: string;
134
+ role?: string;
135
+ };
136
+ finish_reason?: string | null;
137
+ }>;
138
+ usage?: {
139
+ completion_tokens?: number;
140
+ prompt_tokens?: number;
141
+ };
142
+ }
143
+ /** Pure: the opening events for an Anthropic streamed message. */
144
+ export declare function messageStartEvents(model: string, inputTokens?: number, id?: string): SSEEvent[];
145
+ /** Pure: one text delta event. */
146
+ export declare function textDeltaEvent(text: string): SSEEvent;
147
+ /** Pure: the closing events, mapping the OpenAI finish_reason to an Anthropic stop_reason. */
148
+ export declare function messageStopEvents(finishReason: string | null | undefined, outputTokens: number): SSEEvent[];
149
+ /** Pure: a full OpenAI streaming-chunk list -> the ordered Anthropic SSE events. */
150
+ export declare function translateOpenAIStream(chunks: OpenAIStreamChunk[], model: string): SSEEvent[];
151
+ /** Pure: pull complete `data:` JSON chunks out of an accumulating SSE buffer, keeping the incomplete tail. */
152
+ export declare function parseSSEBuffer(buffer: string): {
153
+ chunks: OpenAIStreamChunk[];
154
+ done: boolean;
155
+ rest: string;
156
+ };
157
+ /** Forward with `stream:true` and yield decoded OpenAI stream chunks. */
158
+ export declare function forwardChatStream(openaiReq: OpenAIChatRequest, deps: ForwardDeps): AsyncGenerator<OpenAIStreamChunk>;
159
+ /** Handle a streaming /v1/messages request by writing Anthropic SSE to `res`. */
160
+ export declare function handleStreamingMessages(body: AnthropicRequest, opts: ProxyOptions, res: ServerResponse): Promise<void>;
161
+ export declare function createProxyServer(opts: ProxyOptions): Server;
162
+ /** Start the proxy; resolves with the bound port + base URL to set ANTHROPIC_BASE_URL to. */
163
+ export declare function startProxy(opts: ProxyOptions): Promise<{
164
+ server: Server;
165
+ port: number;
166
+ url: string;
167
+ }>;
168
+ //# sourceMappingURL=proxy.d.ts.map
@@ -0,0 +1,411 @@
1
+ /**
2
+ * route-serve/proxy.ts — a lightweight, Anthropic-compatible model-router proxy.
3
+ *
4
+ * `swarmdo route serve` starts this on 127.0.0.1:PORT. Point Claude Code (or any
5
+ * Anthropic client) at it with `ANTHROPIC_BASE_URL=http://127.0.0.1:PORT` and it
6
+ * routes each `/v1/messages` request to an OpenRouter model chosen from the
7
+ * user's configured pool (swarmdo.config.json `openrouter`), translating the
8
+ * Anthropic <-> OpenAI-compatible request/response shapes.
9
+ *
10
+ * Deliberately built on swarmdo's existing pieces — no vendored proxy, no new
11
+ * deps beyond Node's own `http`:
12
+ * - model selection: resolveOpenRouterModel() with Thompson-sampled `priors`
13
+ * (== learned routing) from providers/openrouter-config.
14
+ * - resilience: computeBackoffMs / isRetryableError / sleep from resilience/backoff.
15
+ *
16
+ * The only new surface is the HTTP serving layer + request/response translation,
17
+ * both kept PURE and fixture-tested (see __tests__/route-serve-proxy.test.ts).
18
+ *
19
+ * INCREMENT 1: non-streaming (forwards `stream:false` upstream and returns a
20
+ * single Anthropic message). Incremental SSE streaming is increment 2; until
21
+ * then a client that asked for stream still gets a correct, complete response
22
+ * (buffered), just not token-by-token.
23
+ */
24
+ import { createServer } from 'node:http';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { resolveOpenRouterModel } from '../providers/openrouter-config.js';
28
+ import { computeBackoffMs, isRetryableError, sleep } from '../resilience/backoff.js';
29
+ // ── Pure: map an Anthropic model hint to one of our pool tiers ────────────────
30
+ /** Claude Code sends model ids like 'claude-3-5-haiku…', 'claude-sonnet-4…',
31
+ * 'claude-opus-4…'. Map by family; unknown → 'sonnet' (the safe middle tier). */
32
+ export function tierForRequest(body) {
33
+ const m = (body.model || '').toLowerCase();
34
+ if (m.includes('haiku'))
35
+ return 'haiku';
36
+ if (m.includes('opus'))
37
+ return 'opus';
38
+ return 'sonnet';
39
+ }
40
+ // ── Pure: flatten Anthropic content (string | text blocks) to a plain string ──
41
+ export function flattenContent(content) {
42
+ if (typeof content === 'string')
43
+ return content;
44
+ if (!Array.isArray(content))
45
+ return '';
46
+ return content
47
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
48
+ .map((b) => b.text)
49
+ .join('\n');
50
+ }
51
+ // ── Pure: Anthropic /v1/messages request -> OpenAI /v1/chat/completions ────────
52
+ export function anthropicToOpenAI(body, model) {
53
+ const messages = [];
54
+ const system = typeof body.system === 'string'
55
+ ? body.system
56
+ : Array.isArray(body.system)
57
+ ? body.system.filter((b) => b?.text).map((b) => b.text).join('\n')
58
+ : '';
59
+ if (system)
60
+ messages.push({ role: 'system', content: system });
61
+ for (const m of body.messages || []) {
62
+ messages.push({ role: m.role, content: flattenContent(m.content) });
63
+ }
64
+ return {
65
+ model,
66
+ messages,
67
+ max_tokens: body.max_tokens,
68
+ temperature: body.temperature,
69
+ top_p: body.top_p,
70
+ stream: false,
71
+ };
72
+ }
73
+ // ── Pure: OpenAI chat response -> Anthropic /v1/messages response ─────────────
74
+ const STOP_MAP = {
75
+ stop: 'end_turn',
76
+ length: 'max_tokens',
77
+ tool_calls: 'tool_use',
78
+ content_filter: 'end_turn',
79
+ };
80
+ export function openAIToAnthropic(resp, model) {
81
+ const choice = resp?.choices?.[0] ?? {};
82
+ const text = choice?.message?.content ?? '';
83
+ const usage = resp?.usage ?? {};
84
+ return {
85
+ id: resp?.id ? `msg_${resp.id}` : `msg_${model}`,
86
+ type: 'message',
87
+ role: 'assistant',
88
+ model,
89
+ content: [{ type: 'text', text: typeof text === 'string' ? text : '' }],
90
+ stop_reason: STOP_MAP[choice?.finish_reason ?? 'stop'] ?? 'end_turn',
91
+ stop_sequence: null,
92
+ usage: {
93
+ input_tokens: usage?.prompt_tokens ?? 0,
94
+ output_tokens: usage?.completion_tokens ?? 0,
95
+ },
96
+ };
97
+ }
98
+ export function selectModelForRequest(body, cfg, priors) {
99
+ const tier = tierForRequest(body);
100
+ const picked = resolveOpenRouterModel({ requested: body.model, tier, cfg, priors });
101
+ return picked ? { ...picked, tier } : null;
102
+ }
103
+ // ── Learned routing (increment 3): per-slug Beta priors ───────────────────────
104
+ // resolveOpenRouterModel Thompson-samples over these, so models that succeed
105
+ // more get picked more. Mirrors the α=successes / β=failures scheme in
106
+ // swarmvector/model-router.ts, but keyed by OpenRouter slug (the pool's unit)
107
+ // rather than by tier. Persisted to .swarm/route-serve-priors.json so learning
108
+ // survives restarts.
109
+ /** Pure: fold one outcome into a Beta prior (success → α+1, failure → β+1). */
110
+ export function betaUpdate(prior, success) {
111
+ const p = prior ?? { alpha: 1, beta: 1 };
112
+ return success ? { alpha: p.alpha + 1, beta: p.beta } : { alpha: p.alpha, beta: p.beta + 1 };
113
+ }
114
+ /** Pure: fold one outcome for `model` into a priors map, returning a new map. */
115
+ export function recordOutcome(priors, model, success) {
116
+ return { ...priors, [model]: betaUpdate(priors[model], success) };
117
+ }
118
+ const PRIORS_REL = path.join('.swarm', 'route-serve-priors.json');
119
+ export function loadPriors(cwd = process.cwd()) {
120
+ try {
121
+ const parsed = JSON.parse(fs.readFileSync(path.join(cwd, PRIORS_REL), 'utf8'));
122
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
123
+ }
124
+ catch {
125
+ return {};
126
+ }
127
+ }
128
+ export function savePriors(priors, cwd = process.cwd()) {
129
+ try {
130
+ fs.mkdirSync(path.join(cwd, '.swarm'), { recursive: true });
131
+ fs.writeFileSync(path.join(cwd, PRIORS_REL), JSON.stringify(priors, null, 2));
132
+ }
133
+ catch {
134
+ /* best-effort — learning is an optimization, never a hard dependency */
135
+ }
136
+ }
137
+ export async function forwardChat(openaiReq, deps) {
138
+ const doFetch = deps.fetchImpl ?? fetch;
139
+ const url = `${deps.cfg.baseUrl.replace(/\/$/, '')}/v1/chat/completions`;
140
+ const maxRetries = deps.maxRetries ?? 2;
141
+ let lastErr = '';
142
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
143
+ if (attempt > 0)
144
+ await sleep(computeBackoffMs(attempt - 1));
145
+ try {
146
+ const res = await doFetch(url, {
147
+ method: 'POST',
148
+ headers: {
149
+ 'content-type': 'application/json',
150
+ authorization: `Bearer ${deps.apiKey}`,
151
+ 'HTTP-Referer': 'https://swarmdo.com',
152
+ 'X-Title': 'swarmdo route serve',
153
+ },
154
+ body: JSON.stringify(openaiReq),
155
+ });
156
+ if (res.ok)
157
+ return (await res.json());
158
+ lastErr = `${res.status} ${await res.text().catch(() => '')}`.trim();
159
+ // 4xx (except 429) is not worth retrying — the request itself is bad.
160
+ if (res.status < 500 && res.status !== 429)
161
+ break;
162
+ }
163
+ catch (e) {
164
+ lastErr = e.message;
165
+ if (!isRetryableError(lastErr))
166
+ break;
167
+ }
168
+ }
169
+ throw new Error(`OpenRouter forward failed after ${maxRetries + 1} attempt(s): ${lastErr}`);
170
+ }
171
+ /** Orchestrate one /v1/messages request. Returns an HTTP status + Anthropic-shaped body. */
172
+ export async function handleMessages(body, opts) {
173
+ const sel = selectModelForRequest(body, opts.cfg, opts.priors);
174
+ if (!sel) {
175
+ return {
176
+ status: 503,
177
+ json: {
178
+ type: 'error',
179
+ error: {
180
+ type: 'api_error',
181
+ message: 'no OpenRouter model configured for this request — set `openrouter.enabled` + a model pool in swarmdo.config.json',
182
+ },
183
+ },
184
+ };
185
+ }
186
+ try {
187
+ const openaiReq = anthropicToOpenAI(body, sel.model);
188
+ const resp = await forwardChat(openaiReq, {
189
+ cfg: opts.cfg,
190
+ apiKey: opts.apiKey,
191
+ fetchImpl: opts.fetchImpl,
192
+ maxRetries: opts.maxRetries,
193
+ });
194
+ opts.onOutcome?.(sel.model, true);
195
+ opts.log?.(`routed ${body.model ?? '(default)'} → ${sel.model} [${sel.tier}] (${sel.source})`);
196
+ return { status: 200, json: openAIToAnthropic(resp, sel.model) };
197
+ }
198
+ catch (e) {
199
+ opts.onOutcome?.(sel.model, false);
200
+ return { status: 502, json: { type: 'error', error: { type: 'api_error', message: e.message } } };
201
+ }
202
+ }
203
+ /** Serialize one Anthropic SSE event to the wire format (`event:`/`data:` + blank line). */
204
+ export function serializeSSE(e) {
205
+ return `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`;
206
+ }
207
+ /** Pure: the opening events for an Anthropic streamed message. */
208
+ export function messageStartEvents(model, inputTokens = 0, id = `msg_${model}`) {
209
+ return [
210
+ {
211
+ event: 'message_start',
212
+ data: {
213
+ type: 'message_start',
214
+ message: {
215
+ id, type: 'message', role: 'assistant', model, content: [],
216
+ stop_reason: null, stop_sequence: null,
217
+ usage: { input_tokens: inputTokens, output_tokens: 0 },
218
+ },
219
+ },
220
+ },
221
+ { event: 'content_block_start', data: { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } } },
222
+ ];
223
+ }
224
+ /** Pure: one text delta event. */
225
+ export function textDeltaEvent(text) {
226
+ return { event: 'content_block_delta', data: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } } };
227
+ }
228
+ /** Pure: the closing events, mapping the OpenAI finish_reason to an Anthropic stop_reason. */
229
+ export function messageStopEvents(finishReason, outputTokens) {
230
+ const stop_reason = STOP_MAP[finishReason ?? 'stop'] ?? 'end_turn';
231
+ return [
232
+ { event: 'content_block_stop', data: { type: 'content_block_stop', index: 0 } },
233
+ { event: 'message_delta', data: { type: 'message_delta', delta: { stop_reason, stop_sequence: null }, usage: { output_tokens: outputTokens } } },
234
+ { event: 'message_stop', data: { type: 'message_stop' } },
235
+ ];
236
+ }
237
+ /** Pure: a full OpenAI streaming-chunk list -> the ordered Anthropic SSE events. */
238
+ export function translateOpenAIStream(chunks, model) {
239
+ const events = [...messageStartEvents(model, chunks[0]?.usage?.prompt_tokens ?? 0)];
240
+ let finish = 'stop';
241
+ let outputTokens = 0;
242
+ for (const c of chunks) {
243
+ const choice = c.choices?.[0];
244
+ const text = choice?.delta?.content;
245
+ if (typeof text === 'string' && text.length)
246
+ events.push(textDeltaEvent(text));
247
+ if (choice?.finish_reason)
248
+ finish = choice.finish_reason;
249
+ if (c.usage?.completion_tokens != null)
250
+ outputTokens = c.usage.completion_tokens;
251
+ }
252
+ events.push(...messageStopEvents(finish, outputTokens));
253
+ return events;
254
+ }
255
+ /** Pure: pull complete `data:` JSON chunks out of an accumulating SSE buffer, keeping the incomplete tail. */
256
+ export function parseSSEBuffer(buffer) {
257
+ const chunks = [];
258
+ let done = false;
259
+ const lines = buffer.split('\n');
260
+ const rest = lines.pop() ?? ''; // possibly-incomplete final line
261
+ for (const line of lines) {
262
+ const t = line.trim();
263
+ if (!t.startsWith('data:'))
264
+ continue;
265
+ const payload = t.slice(5).trim();
266
+ if (payload === '[DONE]') {
267
+ done = true;
268
+ continue;
269
+ }
270
+ if (!payload)
271
+ continue;
272
+ try {
273
+ chunks.push(JSON.parse(payload));
274
+ }
275
+ catch { /* incomplete/keepalive — skip */ }
276
+ }
277
+ return { chunks, done, rest };
278
+ }
279
+ /** Forward with `stream:true` and yield decoded OpenAI stream chunks. */
280
+ export async function* forwardChatStream(openaiReq, deps) {
281
+ const doFetch = deps.fetchImpl ?? fetch;
282
+ const url = `${deps.cfg.baseUrl.replace(/\/$/, '')}/v1/chat/completions`;
283
+ const res = await doFetch(url, {
284
+ method: 'POST',
285
+ headers: {
286
+ 'content-type': 'application/json',
287
+ authorization: `Bearer ${deps.apiKey}`,
288
+ 'HTTP-Referer': 'https://swarmdo.com',
289
+ 'X-Title': 'swarmdo route serve',
290
+ },
291
+ body: JSON.stringify({ ...openaiReq, stream: true }),
292
+ });
293
+ if (!res.ok || !res.body) {
294
+ const detail = res.text ? await res.text().catch(() => '') : '';
295
+ throw new Error(`OpenRouter stream failed: ${res.status} ${detail}`.trim());
296
+ }
297
+ const reader = res.body.getReader();
298
+ const decoder = new TextDecoder();
299
+ let buffer = '';
300
+ for (;;) {
301
+ const { value, done } = await reader.read();
302
+ if (done)
303
+ break;
304
+ buffer += decoder.decode(value, { stream: true });
305
+ const parsed = parseSSEBuffer(buffer);
306
+ buffer = parsed.rest;
307
+ for (const c of parsed.chunks)
308
+ yield c;
309
+ if (parsed.done)
310
+ return;
311
+ }
312
+ }
313
+ /** Handle a streaming /v1/messages request by writing Anthropic SSE to `res`. */
314
+ export async function handleStreamingMessages(body, opts, res) {
315
+ const sel = selectModelForRequest(body, opts.cfg, opts.priors);
316
+ if (!sel) {
317
+ const json = { type: 'error', error: { type: 'api_error', message: 'no OpenRouter model configured for this request' } };
318
+ const buf = Buffer.from(JSON.stringify(json));
319
+ res.writeHead(503, { 'content-type': 'application/json', 'content-length': String(buf.length) });
320
+ res.end(buf);
321
+ return;
322
+ }
323
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
324
+ const write = (e) => { res.write(serializeSSE(e)); };
325
+ try {
326
+ const openaiReq = anthropicToOpenAI(body, sel.model);
327
+ let started = false;
328
+ let finish = 'stop';
329
+ let outputTokens = 0;
330
+ for await (const chunk of forwardChatStream(openaiReq, { cfg: opts.cfg, apiKey: opts.apiKey, fetchImpl: opts.fetchImpl })) {
331
+ if (!started) {
332
+ for (const e of messageStartEvents(sel.model, chunk.usage?.prompt_tokens ?? 0))
333
+ write(e);
334
+ started = true;
335
+ }
336
+ const text = chunk.choices?.[0]?.delta?.content;
337
+ if (typeof text === 'string' && text.length)
338
+ write(textDeltaEvent(text));
339
+ if (chunk.choices?.[0]?.finish_reason)
340
+ finish = chunk.choices[0].finish_reason;
341
+ if (chunk.usage?.completion_tokens != null)
342
+ outputTokens = chunk.usage.completion_tokens;
343
+ }
344
+ if (!started)
345
+ for (const e of messageStartEvents(sel.model))
346
+ write(e);
347
+ for (const e of messageStopEvents(finish, outputTokens))
348
+ write(e);
349
+ opts.onOutcome?.(sel.model, true);
350
+ opts.log?.(`streamed ${body.model ?? '(default)'} → ${sel.model} [${sel.tier}] (${sel.source})`);
351
+ }
352
+ catch (e) {
353
+ opts.onOutcome?.(sel.model, false);
354
+ write({ event: 'error', data: { type: 'error', error: { type: 'api_error', message: e.message } } });
355
+ }
356
+ finally {
357
+ res.end();
358
+ }
359
+ }
360
+ function readBody(req, limitBytes = 20 * 1024 * 1024) {
361
+ return new Promise((resolve, reject) => {
362
+ let data = '';
363
+ req.on('data', (c) => {
364
+ data += c;
365
+ if (data.length > limitBytes)
366
+ reject(new Error('request body too large'));
367
+ });
368
+ req.on('end', () => resolve(data));
369
+ req.on('error', reject);
370
+ });
371
+ }
372
+ export function createProxyServer(opts) {
373
+ return createServer(async (req, res) => {
374
+ const send = (status, json) => {
375
+ const buf = Buffer.from(JSON.stringify(json));
376
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': String(buf.length) });
377
+ res.end(buf);
378
+ };
379
+ try {
380
+ if (req.method === 'GET' && (req.url === '/health' || req.url === '/')) {
381
+ return send(200, { ok: true, service: 'swarmdo route serve', pool: opts.cfg.models.length });
382
+ }
383
+ if (req.method === 'POST' && req.url?.startsWith('/v1/messages')) {
384
+ const body = JSON.parse(await readBody(req));
385
+ if (body.stream)
386
+ return void (await handleStreamingMessages(body, opts, res));
387
+ const { status, json } = await handleMessages(body, opts);
388
+ return send(status, json);
389
+ }
390
+ send(404, { type: 'error', error: { type: 'not_found_error', message: `no route for ${req.method} ${req.url}` } });
391
+ }
392
+ catch (e) {
393
+ send(400, { type: 'error', error: { type: 'invalid_request_error', message: e.message } });
394
+ }
395
+ });
396
+ }
397
+ /** Start the proxy; resolves with the bound port + base URL to set ANTHROPIC_BASE_URL to. */
398
+ export function startProxy(opts) {
399
+ const host = opts.host ?? '127.0.0.1';
400
+ const wantPort = opts.port ?? 3456;
401
+ const server = createProxyServer(opts);
402
+ return new Promise((resolve, reject) => {
403
+ server.once('error', reject);
404
+ server.listen(wantPort, host, () => {
405
+ const addr = server.address();
406
+ const port = typeof addr === 'object' && addr ? addr.port : wantPort;
407
+ resolve({ server, port, url: `http://${host}:${port}` });
408
+ });
409
+ });
410
+ }
411
+ //# sourceMappingURL=proxy.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmdo/cli",
3
- "version": "1.58.24",
3
+ "version": "1.58.31",
4
4
  "type": "module",
5
5
  "description": "Swarmdo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
6
6
  "main": "dist/src/index.js",