@desplega.ai/agent-swarm 1.121.0 → 1.122.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.
Files changed (84) hide show
  1. package/README.md +3 -3
  2. package/dist/{actions-mwshtkf7.js → actions-k3bwtmgy.js} +5 -5
  3. package/dist/{app-skbty91x.js → app-knevny0f.js} +3 -3
  4. package/dist/{assistant-tg7dhfj8.js → assistant-as8087ft.js} +8 -8
  5. package/dist/{boot-reembed-ntjmeyh2.js → boot-reembed-1dcc1yyk.js} +3 -3
  6. package/dist/{boot-reembed-q5x0xpcp.js → boot-reembed-j305cpdr.js} +4 -4
  7. package/dist/{boot-scrub-logs-5gznsfbe.js → boot-scrub-logs-w9qkjzkm.js} +2 -2
  8. package/dist/{cli-s8bdcke2.js → cli-00yr0kxv.js} +1 -1
  9. package/dist/{cli-6s3ytc5x.js → cli-16dv125e.js} +27 -2
  10. package/dist/cli-3dpj7atn.js +11 -0
  11. package/dist/{cli-srjqn7fd.js → cli-3ptjc655.js} +1 -1
  12. package/dist/{cli-vg3zrw6h.js → cli-586x669g.js} +4 -4
  13. package/dist/{cli-5hpvd15x.js → cli-60q15ncp.js} +2 -2
  14. package/dist/{cli-ghq3v4c9.js → cli-7jb8mk4n.js} +1 -1
  15. package/dist/{cli-ky8esb8t.js → cli-8sv69q06.js} +3 -3
  16. package/dist/{cli-072t49hf.js → cli-8x745f40.js} +3 -3
  17. package/dist/{cli-3b1ayz83.js → cli-9fhxk91h.js} +5 -5
  18. package/dist/{cli-nf7sadev.js → cli-bxdb36d2.js} +2 -2
  19. package/dist/{cli-g9vva2qh.js → cli-fc1gvt9c.js} +1 -1
  20. package/dist/{cli-agdrxyex.js → cli-haeq79yf.js} +1 -1
  21. package/dist/{cli-7pzae66m.js → cli-hjq6c6vz.js} +1 -1
  22. package/dist/{cli-b2mqkg1h.js → cli-j89ph4c4.js} +2 -2
  23. package/dist/{cli-4pfweq4x.js → cli-kpfsabp2.js} +5 -5
  24. package/dist/{cli-95rbe11h.js → cli-m7dmk462.js} +12 -9
  25. package/dist/{cli-0df2g86x.js → cli-mjxnr12j.js} +1 -1
  26. package/dist/{cli-3fxqwfh3.js → cli-qf0qxzw5.js} +5 -5
  27. package/dist/{cli-vp9r4ap4.js → cli-qnmj37m5.js} +2 -2
  28. package/dist/{cli-tv7vekye.js → cli-tc8xk5zh.js} +1 -1
  29. package/dist/{cli-dmzv0986.js → cli-vh3h1grg.js} +10 -1
  30. package/dist/{cli-46etyprn.js → cli-w48hgc2y.js} +1 -1
  31. package/dist/{cli-avw9ev3x.js → cli-wmx631p8.js} +14 -11
  32. package/dist/{cli-gdpx70nc.js → cli-y3an9mk7.js} +19 -19
  33. package/dist/{cli-1728qh70.js → cli-z3hcrghs.js} +1 -1
  34. package/dist/{cli-p9w7kb3h.js → cli-zpa6gjwq.js} +1 -1
  35. package/dist/{cli-7pt9sc83.js → cli-zweca3q3.js} +3 -2
  36. package/dist/cli.js +10 -10
  37. package/dist/{codex-adapter-mmy741zd.js → codex-adapter-0kv2meed.js} +3 -2
  38. package/dist/{codex-session-runner-sazmx9zt.js → codex-session-runner-m1e1v4r0.js} +3 -2
  39. package/dist/{commands-cxxye5e4.js → commands-6j54dz62.js} +2 -2
  40. package/dist/{db-ab1954b7.js → db-gcnz2tqx.js} +2 -2
  41. package/dist/{handlers-fmfe2343.js → handlers-c43sm2h1.js} +8 -8
  42. package/dist/{hook-ygnw3cvt.js → hook-y1p25vm5.js} +3 -2
  43. package/dist/{http-kactcc0z.js → http-bsz1rze1.js} +35 -34
  44. package/dist/{index-gmzs6a2q.js → index-03zn2rz7.js} +10 -9
  45. package/dist/{index-g86j2yt5.js → index-7swcv6rv.js} +7 -7
  46. package/dist/{index-hmp82wn0.js → index-8m5gd1vs.js} +12 -11
  47. package/dist/{index-6erq0wvk.js → index-qyzfh62q.js} +11 -10
  48. package/dist/{keepalive-3cvt26dw.js → keepalive-69v93p45.js} +5 -5
  49. package/dist/{lead-ysf26cjr.js → lead-xn8kfchy.js} +23 -22
  50. package/dist/{maintenance-9npwptbb.js → maintenance-g2x7q13q.js} +4 -4
  51. package/dist/{oauth-refresh-sweep-0r2v76vw.js → oauth-refresh-sweep-j80szsyr.js} +4 -4
  52. package/dist/{onboard-ddb1674a.js → onboard-e3xcjt8b.js} +2 -2
  53. package/dist/{opencode-adapter-h6rkxwrk.js → opencode-adapter-s3nhtp9m.js} +4 -1
  54. package/dist/{otel-impl-b43bkq8e.js → otel-impl-24egvjaa.js} +1 -1
  55. package/dist/{pi-mono-adapter-ke789738.js → pi-mono-adapter-k3z6e5fn.js} +102 -4
  56. package/dist/{pricing-refresh-2rqf114z.js → pricing-refresh-01kg9gky.js} +4 -4
  57. package/dist/{rbac-roles-6vtafvzb.js → rbac-roles-f9bdj93y.js} +2 -2
  58. package/dist/{rbac-roles-5wkhc8xv.js → rbac-roles-jqfp0zza.js} +3 -3
  59. package/dist/{seed-pricing-23wrb873.js → seed-pricing-z2t7rank.js} +3 -3
  60. package/dist/{setup-6rny4fk6.js → setup-kw2ps002.js} +2 -2
  61. package/dist/{worker-yapewe14.js → worker-336w8930.js} +23 -22
  62. package/openapi.json +1 -1
  63. package/package.json +1 -1
  64. package/src/be/memory/constants.ts +7 -6
  65. package/src/be/memory/graph-expansion.ts +3 -3
  66. package/src/be/memory/raters/llm-summarizer.ts +10 -2
  67. package/src/commands/provider-credentials.ts +2 -1
  68. package/src/http/memory.ts +2 -1
  69. package/src/providers/codex-adapter.ts +3 -0
  70. package/src/providers/opencode-adapter.ts +40 -0
  71. package/src/providers/pi-mono-adapter.ts +179 -1
  72. package/src/providers/pi-mono-extension.ts +9 -0
  73. package/src/tests/agent-avatar-resolution.test.ts +170 -0
  74. package/src/tests/memory-feature-flags.test.ts +45 -0
  75. package/src/tests/memory-rater-llm-summarizer.test.ts +24 -0
  76. package/src/tests/opencode-plugin.test.ts +43 -0
  77. package/src/tests/openrouter-base-url.test.ts +256 -0
  78. package/src/tests/pi-mono-adapter.test.ts +90 -0
  79. package/src/tools/memory-search.ts +2 -1
  80. package/src/utils/internal-ai/complete-structured.ts +19 -1
  81. package/src/utils/internal-ai/summarize-session.ts +7 -0
  82. package/src/utils/openrouter-base-url.ts +28 -0
  83. package/src/workflows/executors/raw-llm.ts +2 -1
  84. package/src/workflows/executors/validate.ts +2 -1
@@ -0,0 +1,256 @@
1
+ /**
2
+ * OPENROUTER_BASE_URL gateway routing — unit tests.
3
+ *
4
+ * Covers the shared resolver (`src/utils/openrouter-base-url.ts`), the pi
5
+ * models.json override writer (`ensureOpenRouterModelsOverride`) including
6
+ * its composition through pi-coding-agent's ModelRuntime, and the opencode
7
+ * per-task config injection (`applyOpenRouterBaseUrlOverride`).
8
+ */
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
15
+ import type { Config } from "@opencode-ai/sdk";
16
+ import { applyOpenRouterBaseUrlOverride } from "../providers/opencode-adapter";
17
+ import { ensureOpenRouterModelsOverride } from "../providers/pi-mono-adapter";
18
+ import { DEFAULT_OPENROUTER_BASE_URL, getOpenRouterBaseUrl } from "../utils/openrouter-base-url";
19
+
20
+ const GATEWAY = "https://control-plane.example/proxy/v1";
21
+
22
+ describe("getOpenRouterBaseUrl", () => {
23
+ test("defaults to openrouter.ai when unset", () => {
24
+ expect(getOpenRouterBaseUrl({} as NodeJS.ProcessEnv)).toBe(DEFAULT_OPENROUTER_BASE_URL);
25
+ });
26
+
27
+ test("blank / whitespace-only values fall back to the default", () => {
28
+ expect(getOpenRouterBaseUrl({ OPENROUTER_BASE_URL: "" } as NodeJS.ProcessEnv)).toBe(
29
+ DEFAULT_OPENROUTER_BASE_URL,
30
+ );
31
+ expect(getOpenRouterBaseUrl({ OPENROUTER_BASE_URL: " " } as NodeJS.ProcessEnv)).toBe(
32
+ DEFAULT_OPENROUTER_BASE_URL,
33
+ );
34
+ });
35
+
36
+ test("returns the override verbatim (trimmed)", () => {
37
+ expect(getOpenRouterBaseUrl({ OPENROUTER_BASE_URL: ` ${GATEWAY} ` } as NodeJS.ProcessEnv)).toBe(
38
+ GATEWAY,
39
+ );
40
+ });
41
+
42
+ test("strips trailing slashes so call sites can append paths", () => {
43
+ expect(getOpenRouterBaseUrl({ OPENROUTER_BASE_URL: `${GATEWAY}//` } as NodeJS.ProcessEnv)).toBe(
44
+ GATEWAY,
45
+ );
46
+ });
47
+ });
48
+
49
+ describe("ensureOpenRouterModelsOverride (pi)", () => {
50
+ let agentDir: string;
51
+
52
+ beforeAll(() => {
53
+ agentDir = mkdtempSync(join(tmpdir(), "pi-or-override-"));
54
+ });
55
+
56
+ afterAll(() => {
57
+ rmSync(agentDir, { recursive: true, force: true });
58
+ });
59
+
60
+ test("no-op when OPENROUTER_BASE_URL is unset", async () => {
61
+ const dir = mkdtempSync(join(tmpdir(), "pi-or-noop-"));
62
+ try {
63
+ await ensureOpenRouterModelsOverride(dir, {});
64
+ expect(await Bun.file(join(dir, "models.json")).exists()).toBe(false);
65
+ } finally {
66
+ rmSync(dir, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ test("writes providers.openrouter.baseUrl when the env is set", async () => {
71
+ await ensureOpenRouterModelsOverride(agentDir, { OPENROUTER_BASE_URL: GATEWAY });
72
+ const written = JSON.parse(await Bun.file(join(agentDir, "models.json")).text());
73
+ expect(written).toEqual({ providers: { openrouter: { baseUrl: GATEWAY } } });
74
+ });
75
+
76
+ test("reverts a written override when the env returns to default (file we created is removed)", async () => {
77
+ const dir = mkdtempSync(join(tmpdir(), "pi-or-revert-"));
78
+ try {
79
+ await ensureOpenRouterModelsOverride(dir, { OPENROUTER_BASE_URL: GATEWAY });
80
+ expect(await Bun.file(join(dir, "models.json")).exists()).toBe(true);
81
+ await ensureOpenRouterModelsOverride(dir, {});
82
+ // The override was ALL the file contained — both it and the marker go.
83
+ expect(await Bun.file(join(dir, "models.json")).exists()).toBe(false);
84
+ expect(await Bun.file(join(dir, ".agent-swarm-openrouter-override.json")).exists()).toBe(
85
+ false,
86
+ );
87
+ } finally {
88
+ rmSync(dir, { recursive: true, force: true });
89
+ }
90
+ });
91
+
92
+ test("revert restores a displaced user baseUrl and keeps sibling keys", async () => {
93
+ const dir = mkdtempSync(join(tmpdir(), "pi-or-restore-"));
94
+ try {
95
+ await Bun.write(
96
+ join(dir, "models.json"),
97
+ JSON.stringify({
98
+ providers: {
99
+ openrouter: { baseUrl: "https://user.example/v1", compat: { supportsTools: true } },
100
+ custom: { baseUrl: "https://custom.example/v1", api: "openai-completions" },
101
+ },
102
+ }),
103
+ );
104
+ await ensureOpenRouterModelsOverride(dir, { OPENROUTER_BASE_URL: GATEWAY });
105
+ // Re-run with the gateway still set — displaced value must survive.
106
+ await ensureOpenRouterModelsOverride(dir, { OPENROUTER_BASE_URL: GATEWAY });
107
+ await ensureOpenRouterModelsOverride(dir, {});
108
+ const restored = JSON.parse(await Bun.file(join(dir, "models.json")).text());
109
+ expect(restored.providers.openrouter).toEqual({
110
+ baseUrl: "https://user.example/v1",
111
+ compat: { supportsTools: true },
112
+ });
113
+ expect(restored.providers.custom).toEqual({
114
+ baseUrl: "https://custom.example/v1",
115
+ api: "openai-completions",
116
+ });
117
+ } finally {
118
+ rmSync(dir, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ test("revert leaves a user-edited baseUrl alone (marker mismatch) and drops the marker", async () => {
123
+ const dir = mkdtempSync(join(tmpdir(), "pi-or-user-edit-"));
124
+ try {
125
+ await ensureOpenRouterModelsOverride(dir, { OPENROUTER_BASE_URL: GATEWAY });
126
+ // User hand-edits the file after we wrote it.
127
+ await Bun.write(
128
+ join(dir, "models.json"),
129
+ JSON.stringify({ providers: { openrouter: { baseUrl: "https://mine.example/v1" } } }),
130
+ );
131
+ await ensureOpenRouterModelsOverride(dir, {});
132
+ const kept = JSON.parse(await Bun.file(join(dir, "models.json")).text());
133
+ expect(kept.providers.openrouter.baseUrl).toBe("https://mine.example/v1");
134
+ expect(await Bun.file(join(dir, ".agent-swarm-openrouter-override.json")).exists()).toBe(
135
+ false,
136
+ );
137
+ } finally {
138
+ rmSync(dir, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ test("default env never touches a hand-authored models.json (no marker)", async () => {
143
+ const dir = mkdtempSync(join(tmpdir(), "pi-or-hands-off-"));
144
+ try {
145
+ const userConfig = {
146
+ providers: { openrouter: { baseUrl: "https://mine.example/v1" } },
147
+ };
148
+ await Bun.write(join(dir, "models.json"), JSON.stringify(userConfig));
149
+ await ensureOpenRouterModelsOverride(dir, {});
150
+ expect(JSON.parse(await Bun.file(join(dir, "models.json")).text())).toEqual(userConfig);
151
+ } finally {
152
+ rmSync(dir, { recursive: true, force: true });
153
+ }
154
+ });
155
+
156
+ test("merge-preserves existing providers and openrouter keys", async () => {
157
+ const dir = mkdtempSync(join(tmpdir(), "pi-or-merge-"));
158
+ try {
159
+ await Bun.write(
160
+ join(dir, "models.json"),
161
+ JSON.stringify({
162
+ providers: {
163
+ openrouter: { baseUrl: "https://old.example/v1", compat: { supportsTools: true } },
164
+ custom: { baseUrl: "https://custom.example/v1", api: "openai-completions" },
165
+ },
166
+ }),
167
+ );
168
+ await ensureOpenRouterModelsOverride(dir, { OPENROUTER_BASE_URL: GATEWAY });
169
+ const written = JSON.parse(await Bun.file(join(dir, "models.json")).text());
170
+ expect(written.providers.openrouter.baseUrl).toBe(GATEWAY);
171
+ expect(written.providers.openrouter.compat).toEqual({ supportsTools: true });
172
+ expect(written.providers.custom).toEqual({
173
+ baseUrl: "https://custom.example/v1",
174
+ api: "openai-completions",
175
+ });
176
+ } finally {
177
+ rmSync(dir, { recursive: true, force: true });
178
+ }
179
+ });
180
+
181
+ test("overwrites a corrupt models.json with just the override", async () => {
182
+ const dir = mkdtempSync(join(tmpdir(), "pi-or-corrupt-"));
183
+ try {
184
+ await Bun.write(join(dir, "models.json"), "{not json");
185
+ await ensureOpenRouterModelsOverride(dir, { OPENROUTER_BASE_URL: GATEWAY });
186
+ const written = JSON.parse(await Bun.file(join(dir, "models.json")).text());
187
+ expect(written).toEqual({ providers: { openrouter: { baseUrl: GATEWAY } } });
188
+ } finally {
189
+ rmSync(dir, { recursive: true, force: true });
190
+ }
191
+ });
192
+
193
+ test("ModelRuntime composes the override onto built-in OpenRouter models", async () => {
194
+ // The whole point of the lever: built-in model IDs resolved through the
195
+ // runtime's composed store carry the gateway baseUrl.
196
+ const runtime = await ModelRuntime.create({
197
+ authPath: join(agentDir, "auth.json"),
198
+ modelsPath: join(agentDir, "models.json"),
199
+ });
200
+ expect(runtime.getError()).toBeUndefined();
201
+ const model = runtime.getModel("openrouter", "anthropic/claude-sonnet-5");
202
+ expect(model?.baseUrl).toBe(GATEWAY);
203
+ });
204
+
205
+ test("ModelRuntime keeps the default baseUrl when no models.json exists", async () => {
206
+ const dir = mkdtempSync(join(tmpdir(), "pi-or-default-"));
207
+ try {
208
+ const runtime = await ModelRuntime.create({
209
+ authPath: join(dir, "auth.json"),
210
+ modelsPath: join(dir, "models.json"),
211
+ });
212
+ const model = runtime.getModel("openrouter", "anthropic/claude-sonnet-5");
213
+ expect(model?.baseUrl).toBe(DEFAULT_OPENROUTER_BASE_URL);
214
+ } finally {
215
+ rmSync(dir, { recursive: true, force: true });
216
+ }
217
+ });
218
+ });
219
+
220
+ describe("applyOpenRouterBaseUrlOverride (opencode)", () => {
221
+ test("no-op when OPENROUTER_BASE_URL is unset", () => {
222
+ const config: Config = { model: "openrouter/google/gemini-3-flash-preview" };
223
+ applyOpenRouterBaseUrlOverride(config, {});
224
+ expect(config.provider).toBeUndefined();
225
+ });
226
+
227
+ test("sets provider.openrouter.options.baseURL when the env is set", () => {
228
+ const config: Config = { model: "openrouter/google/gemini-3-flash-preview" };
229
+ applyOpenRouterBaseUrlOverride(config, { OPENROUTER_BASE_URL: GATEWAY });
230
+ expect(config.provider?.openrouter?.options?.baseURL).toBe(GATEWAY);
231
+ });
232
+
233
+ test("preserves existing provider config (e.g. reasoning model options)", () => {
234
+ const config: Config = {
235
+ model: "openrouter/google/gemini-3-flash-preview",
236
+ provider: {
237
+ openrouter: {
238
+ models: {
239
+ "google/gemini-3-flash-preview": { options: { reasoning: { effort: "low" } } },
240
+ },
241
+ options: { apiKey: "sk-keep" },
242
+ },
243
+ anthropic: { options: { apiKey: "sk-ant" } },
244
+ },
245
+ };
246
+ applyOpenRouterBaseUrlOverride(config, { OPENROUTER_BASE_URL: GATEWAY });
247
+ expect(config.provider?.openrouter?.options).toEqual({
248
+ apiKey: "sk-keep",
249
+ baseURL: GATEWAY,
250
+ });
251
+ expect(config.provider?.openrouter?.models).toEqual({
252
+ "google/gemini-3-flash-preview": { options: { reasoning: { effort: "low" } } },
253
+ });
254
+ expect(config.provider?.anthropic).toEqual({ options: { apiKey: "sk-ant" } });
255
+ });
256
+ });
@@ -81,6 +81,96 @@ describe("PiMonoAdapter.createSession — reasoning_effort", () => {
81
81
  });
82
82
  });
83
83
 
84
+ // ─── OPENROUTER_BASE_URL gateway routing (session model composition) ─────────
85
+
86
+ describe("PiMonoAdapter.createSession — OPENROUTER_BASE_URL gateway", () => {
87
+ const GATEWAY = "https://control-plane.example/proxy/v1";
88
+ const agentDir = `/tmp/pi-or-gateway-test-${Date.now()}`;
89
+ const origAgentDirEnv = process.env.PI_CODING_AGENT_DIR;
90
+
91
+ let createAgentSessionSpy: ReturnType<typeof spyOn>;
92
+ let capturedOptions: Record<string, unknown> | undefined;
93
+
94
+ beforeAll(() => {
95
+ mkdirSync(agentDir, { recursive: true });
96
+ // getAgentDir() reads process.env directly (config.env doesn't reach it).
97
+ process.env.PI_CODING_AGENT_DIR = agentDir;
98
+ });
99
+
100
+ afterAll(() => {
101
+ if (origAgentDirEnv === undefined) delete process.env.PI_CODING_AGENT_DIR;
102
+ else process.env.PI_CODING_AGENT_DIR = origAgentDirEnv;
103
+ rmSync(agentDir, { recursive: true, force: true });
104
+ });
105
+
106
+ afterEach(() => {
107
+ createAgentSessionSpy?.mockRestore();
108
+ capturedOptions = undefined;
109
+ });
110
+
111
+ function makeConfig(env: Record<string, string>): ProviderSessionConfig {
112
+ return {
113
+ prompt: "hello",
114
+ systemPrompt: "",
115
+ model: "openrouter/google/gemini-3-flash-preview",
116
+ role: "worker",
117
+ agentId: "test-agent",
118
+ taskId: "test-task",
119
+ apiUrl: "",
120
+ apiKey: "",
121
+ cwd: "/tmp",
122
+ logFile: `/tmp/pi-or-gateway-test-${Date.now()}-${Math.random().toString(36).slice(2)}.log`,
123
+ env: { OPENROUTER_API_KEY: "sk-or-test", ...env },
124
+ };
125
+ }
126
+
127
+ function spyOnCreateAgentSession() {
128
+ createAgentSessionSpy = spyOn(piCodingAgent, "createAgentSession").mockImplementation((async (
129
+ opts: Record<string, unknown>,
130
+ ) => {
131
+ capturedOptions = opts;
132
+ return {
133
+ session: {
134
+ sessionId: "fake-session",
135
+ isStreaming: false,
136
+ model: undefined,
137
+ subscribe: () => () => {},
138
+ dispose: () => {},
139
+ },
140
+ };
141
+ }) as typeof piCodingAgent.createAgentSession);
142
+ }
143
+
144
+ test("session model carries the gateway baseUrl when OPENROUTER_BASE_URL is set", async () => {
145
+ spyOnCreateAgentSession();
146
+ const adapter = new PiMonoAdapter();
147
+ await adapter.createSession(makeConfig({ OPENROUTER_BASE_URL: GATEWAY }));
148
+ const model = capturedOptions?.model as { baseUrl?: string; id?: string } | undefined;
149
+ expect(model?.id).toBe("google/gemini-3-flash-preview");
150
+ expect(model?.baseUrl).toBe(GATEWAY);
151
+ // The override was materialized for the runtime to load.
152
+ expect(existsSync(join(agentDir, "models.json"))).toBe(true);
153
+ });
154
+
155
+ test("session model keeps the default openrouter.ai baseUrl when env is unset", async () => {
156
+ // Fresh agent dir so the previous test's models.json doesn't leak in.
157
+ const cleanDir = `${agentDir}-clean`;
158
+ mkdirSync(cleanDir, { recursive: true });
159
+ process.env.PI_CODING_AGENT_DIR = cleanDir;
160
+ try {
161
+ spyOnCreateAgentSession();
162
+ const adapter = new PiMonoAdapter();
163
+ await adapter.createSession(makeConfig({}));
164
+ const model = capturedOptions?.model as { baseUrl?: string } | undefined;
165
+ expect(model?.baseUrl).toBe("https://openrouter.ai/api/v1");
166
+ expect(existsSync(join(cleanDir, "models.json"))).toBe(false);
167
+ } finally {
168
+ process.env.PI_CODING_AGENT_DIR = agentDir;
169
+ rmSync(cleanDir, { recursive: true, force: true });
170
+ }
171
+ });
172
+ });
173
+
84
174
  describe("AGENTS.md symlink management", () => {
85
175
  const tmpDir = `/tmp/pi-mono-test-${Date.now()}`;
86
176
 
@@ -93,7 +93,8 @@ export const registerMemorySearchTool = (server: McpServer) => {
93
93
  isLead,
94
94
  queryText: query,
95
95
  });
96
- // 1-hop memory_link neighbor expansion (no-op unless MEMORY_GRAPH_EXPANSION=1).
96
+ // Default-on 1-hop memory_link neighbor expansion (disable with
97
+ // MEMORY_GRAPH_EXPANSION=0|false).
97
98
  const expanded = expandCandidatesWithGraph(candidates, requestInfo.agentId, {
98
99
  scope: scope as "agent" | "swarm" | "all",
99
100
  source,
@@ -20,6 +20,7 @@ import { complete } from "@earendil-works/pi-ai/compat";
20
20
  import { getBuiltinModel as getModel } from "@earendil-works/pi-ai/providers/all";
21
21
  import type { TSchema } from "typebox";
22
22
  import { z } from "zod";
23
+ import { DEFAULT_OPENROUTER_BASE_URL, getOpenRouterBaseUrl } from "../openrouter-base-url.js";
23
24
  import { type ResolvedCredential, resolveCredential } from "./credentials.js";
24
25
  import { parseModelStr } from "./models.js";
25
26
 
@@ -40,6 +41,14 @@ export interface CompleteStructuredOptions<TZod extends z.ZodTypeAny> {
40
41
  */
41
42
  apiUrl?: string;
42
43
  apiKey?: string;
44
+ /**
45
+ * Per-task resolved environment (`ProviderSessionConfig.env` /
46
+ * `fetchResolvedEnv` output). Used for credential lookup AND the
47
+ * `OPENROUTER_BASE_URL` gateway override — swarm_config-sourced values
48
+ * never reach `process.env`, so callers with a session env must pass it.
49
+ * Defaults to `process.env`.
50
+ */
51
+ env?: Record<string, string | undefined>;
43
52
  /** Default: 3. */
44
53
  retries?: number;
45
54
  signal?: AbortSignal;
@@ -186,7 +195,7 @@ export async function completeStructured<TZod extends z.ZodTypeAny>(
186
195
  } else {
187
196
  const resolver = opts._resolveCredential ?? resolveCredential;
188
197
  cred = await resolver({
189
- env: process.env,
198
+ env: (opts.env ?? process.env) as NodeJS.ProcessEnv,
190
199
  apiUrl: opts.apiUrl,
191
200
  apiKey: opts.apiKey,
192
201
  callerTag: opts.callerTag,
@@ -259,6 +268,15 @@ export async function completeStructured<TZod extends z.ZodTypeAny>(
259
268
  return null;
260
269
  }
261
270
 
271
+ // The builtin catalog hardcodes openrouter.ai; OPENROUTER_BASE_URL reroutes
272
+ // through a gateway (pi-ai's stream path uses `model.baseUrl` verbatim).
273
+ // Resolved from the caller's session env when provided — swarm_config
274
+ // overlays don't reach `process.env`.
275
+ const openRouterBaseUrl = getOpenRouterBaseUrl((opts.env ?? process.env) as NodeJS.ProcessEnv);
276
+ if (provider === "openrouter" && openRouterBaseUrl !== DEFAULT_OPENROUTER_BASE_URL) {
277
+ model = { ...model, baseUrl: openRouterBaseUrl };
278
+ }
279
+
262
280
  const completeFn = opts._complete ?? complete;
263
281
  let userPrompt = opts.userPrompt;
264
282
  let lastErr: unknown = null;
@@ -35,6 +35,12 @@ export interface SummarizeSessionOptions {
35
35
  /** Passed through to `completeStructured` for codex-OAuth probing. */
36
36
  apiUrl: string;
37
37
  apiKey: string;
38
+ /**
39
+ * Per-task resolved env — forwarded to `completeStructured` so credential
40
+ * lookup and the `OPENROUTER_BASE_URL` gateway override see swarm_config
41
+ * overlays that never reach `process.env`.
42
+ */
43
+ env?: Record<string, string | undefined>;
38
44
  signal?: AbortSignal;
39
45
  /**
40
46
  * Bypass `resolveCredential` entirely — opencode auth path (and tests) pass
@@ -94,6 +100,7 @@ export async function summarizeSession(
94
100
  callerTag: `session-summary:${opts.harness}`,
95
101
  apiUrl: opts.apiUrl,
96
102
  apiKey: opts.apiKey,
103
+ env: opts.env,
97
104
  signal: opts.signal,
98
105
  retries: 3,
99
106
  ...(opts._credentialOverride ? { _credentialOverride: opts._credentialOverride } : {}),
@@ -0,0 +1,28 @@
1
+ /**
2
+ * OpenRouter base-URL resolution.
3
+ *
4
+ * `OPENROUTER_BASE_URL` lets a deployment route all OpenRouter traffic
5
+ * through an OpenAI-compatible gateway (e.g. the cloud control plane's
6
+ * `/proxy/v1` endpoint) instead of `openrouter.ai` directly, so the raw
7
+ * OpenRouter key never has to live on the box. When the env var is unset
8
+ * the default OpenRouter URL is used and behavior is unchanged.
9
+ *
10
+ * Worker-safe: pure env read, no `src/be/db` / `bun:sqlite` imports.
11
+ * The opencode summarize plugin (`plugin/opencode-plugins/lib/summarize.ts`)
12
+ * vendors this logic — keep the two in sync.
13
+ */
14
+
15
+ export const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
16
+
17
+ /**
18
+ * Resolve the OpenRouter base URL (no trailing slash). Reads
19
+ * `OPENROUTER_BASE_URL` from `env`; blank values fall back to the default.
20
+ * Call sites append paths like `/chat/completions` or `/models`.
21
+ */
22
+ export function getOpenRouterBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
23
+ const raw = env.OPENROUTER_BASE_URL;
24
+ if (typeof raw === "string" && raw.trim().length > 0) {
25
+ return raw.trim().replace(/\/+$/, "");
26
+ }
27
+ return DEFAULT_OPENROUTER_BASE_URL;
28
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { ExecutorMeta } from "../../types";
3
+ import { getOpenRouterBaseUrl } from "../../utils/openrouter-base-url";
3
4
  import { BaseExecutor, type ExecutorResult } from "./base";
4
5
 
5
6
  // ─── Schemas ────────────────────────────────────────────────
@@ -27,7 +28,7 @@ export async function executeRawLlm(
27
28
  try {
28
29
  const { createOpenAI } = await import("@ai-sdk/openai");
29
30
  const openrouter = createOpenAI({
30
- baseURL: "https://openrouter.ai/api/v1",
31
+ baseURL: getOpenRouterBaseUrl(),
31
32
  apiKey: process.env.OPENROUTER_API_KEY,
32
33
  });
33
34
  const model = openrouter(modelName);
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { ExecutorMeta } from "../../types";
3
+ import { getOpenRouterBaseUrl } from "../../utils/openrouter-base-url";
3
4
  import { BaseExecutor, type ExecutorResult } from "./base";
4
5
 
5
6
  // ─── Schemas ────────────────────────────────────────────────
@@ -115,7 +116,7 @@ export class ValidateExecutor extends BaseExecutor<
115
116
  const { generateObject, jsonSchema } = await import("ai");
116
117
 
117
118
  const openrouter = createOpenAI({
118
- baseURL: "https://openrouter.ai/api/v1",
119
+ baseURL: getOpenRouterBaseUrl(),
119
120
  apiKey: process.env.OPENROUTER_API_KEY,
120
121
  });
121
122