@ilya-lesikov/pi-pi 0.6.0 → 0.8.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/3p/pi-ask-user/index.ts +1 -1
- package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
- package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
- package/3p/pi-subagents/src/agent-manager.ts +34 -1
- package/3p/pi-subagents/src/agent-runner.ts +66 -33
- package/3p/pi-subagents/src/index.ts +3 -38
- package/3p/pi-subagents/src/types.ts +4 -0
- package/extensions/orchestrator/agents/advisor.ts +35 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/constraints.test.ts +44 -0
- package/extensions/orchestrator/agents/constraints.ts +3 -0
- package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/planner.ts +9 -8
- package/extensions/orchestrator/agents/prompts.test.ts +120 -0
- package/extensions/orchestrator/agents/reviewer.ts +48 -0
- package/extensions/orchestrator/agents/task.ts +6 -20
- package/extensions/orchestrator/agents/tool-routing.ts +23 -1
- package/extensions/orchestrator/command-handlers.ts +1 -1
- package/extensions/orchestrator/config.ts +8 -4
- package/extensions/orchestrator/context.test.ts +54 -0
- package/extensions/orchestrator/context.ts +65 -2
- package/extensions/orchestrator/custom-footer.ts +5 -2
- package/extensions/orchestrator/doctor.test.ts +3 -1
- package/extensions/orchestrator/doctor.ts +40 -2
- package/extensions/orchestrator/event-handlers.test.ts +97 -1
- package/extensions/orchestrator/event-handlers.ts +222 -48
- package/extensions/orchestrator/flant-infra.test.ts +312 -0
- package/extensions/orchestrator/flant-infra.ts +407 -44
- package/extensions/orchestrator/index.ts +1 -1
- package/extensions/orchestrator/integration.test.ts +312 -18
- package/extensions/orchestrator/messages.test.ts +30 -0
- package/extensions/orchestrator/messages.ts +6 -0
- package/extensions/orchestrator/model-registry.test.ts +124 -13
- package/extensions/orchestrator/model-registry.ts +91 -33
- package/extensions/orchestrator/orchestrator.test.ts +113 -0
- package/extensions/orchestrator/orchestrator.ts +163 -3
- package/extensions/orchestrator/phases/brainstorm.ts +9 -20
- package/extensions/orchestrator/phases/implementation.test.ts +11 -0
- package/extensions/orchestrator/phases/implementation.ts +4 -6
- package/extensions/orchestrator/phases/planning.test.ts +16 -0
- package/extensions/orchestrator/phases/planning.ts +11 -4
- package/extensions/orchestrator/phases/review-task.ts +1 -4
- package/extensions/orchestrator/phases/review.test.ts +62 -0
- package/extensions/orchestrator/phases/review.ts +58 -15
- package/extensions/orchestrator/plannotator.ts +9 -6
- package/extensions/orchestrator/pp-menu.test.ts +74 -1
- package/extensions/orchestrator/pp-menu.ts +366 -94
- package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
- package/extensions/orchestrator/pp-state-tools.ts +249 -0
- package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
- package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
- package/extensions/orchestrator/state.ts +41 -20
- package/extensions/orchestrator/test-helpers.ts +18 -3
- package/extensions/orchestrator/usage-tracker.test.ts +131 -3
- package/extensions/orchestrator/usage-tracker.ts +78 -11
- package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
- package/extensions/orchestrator/validate-artifacts.ts +2 -2
- package/package.json +1 -1
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
4
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
6
|
|
|
7
|
+
const refreshAnthropicTokenMock = vi.fn();
|
|
8
|
+
vi.mock("@earendil-works/pi-ai/oauth", () => ({
|
|
9
|
+
refreshAnthropicToken: (...args: unknown[]) => refreshAnthropicTokenMock(...args),
|
|
10
|
+
}));
|
|
11
|
+
|
|
6
12
|
const tempDirs: string[] = [];
|
|
7
13
|
|
|
8
14
|
function makeTempDir(): string {
|
|
@@ -32,6 +38,7 @@ function collectModelSpecs(value: unknown): string[] {
|
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
afterEach(() => {
|
|
41
|
+
refreshAnthropicTokenMock.mockReset();
|
|
35
42
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
36
43
|
for (const dir of tempDirs.splice(0)) {
|
|
37
44
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -57,6 +64,210 @@ describe("flant-infra", () => {
|
|
|
57
64
|
expect([...registered.keys()].sort()).toEqual(["pp-flant-anthropic", "pp-flant-openai"]);
|
|
58
65
|
});
|
|
59
66
|
|
|
67
|
+
it("does not register the sub provider when subscription is disabled", async () => {
|
|
68
|
+
const dir = makeTempDir();
|
|
69
|
+
const mod = await loadFlantInfraModule(dir);
|
|
70
|
+
const registered = new Map<string, unknown>();
|
|
71
|
+
const pi = {
|
|
72
|
+
registerProvider: vi.fn((name: string, config: unknown) => registered.set(name, config)),
|
|
73
|
+
unregisterProvider: vi.fn((name: string) => registered.delete(name)),
|
|
74
|
+
} as any;
|
|
75
|
+
|
|
76
|
+
mod.registerFlantProviders(pi, ["claude-opus-4-8", "gpt-5"], {}, { subscription: false });
|
|
77
|
+
expect([...registered.keys()].sort()).toEqual(["pp-flant-anthropic", "pp-flant-openai"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("registers the sub provider with sub/ models when subscription enabled and credentials present", async () => {
|
|
81
|
+
const dir = makeTempDir();
|
|
82
|
+
mkdirSync(dir, { recursive: true });
|
|
83
|
+
writeFileSync(
|
|
84
|
+
join(dir, "auth.json"),
|
|
85
|
+
JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-test-token", expires: Date.now() + 3_600_000 } }),
|
|
86
|
+
"utf-8",
|
|
87
|
+
);
|
|
88
|
+
const prevKey = process.env.LLM_API_KEY;
|
|
89
|
+
process.env.LLM_API_KEY = "sk-gateway-test";
|
|
90
|
+
try {
|
|
91
|
+
const mod = await loadFlantInfraModule(dir);
|
|
92
|
+
const registered = new Map<string, any>();
|
|
93
|
+
const pi = {
|
|
94
|
+
registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)),
|
|
95
|
+
unregisterProvider: vi.fn((name: string) => registered.delete(name)),
|
|
96
|
+
} as any;
|
|
97
|
+
|
|
98
|
+
mod.registerFlantProviders(pi, ["claude-opus-4-8", "claude-haiku-4-5", "gpt-5"], {}, { subscription: true });
|
|
99
|
+
|
|
100
|
+
expect([...registered.keys()].sort()).toEqual(["pp-flant-anthropic", "pp-flant-anthropic-sub", "pp-flant-openai"]);
|
|
101
|
+
const sub = registered.get("pp-flant-anthropic-sub");
|
|
102
|
+
expect(sub.api).toBe("anthropic-messages");
|
|
103
|
+
expect(sub.baseUrl).toBe("https://llm-api.flant.ru");
|
|
104
|
+
expect(sub.apiKey).toBe("sk-ant-oat01-test-token");
|
|
105
|
+
expect(sub.headers["x-litellm-api-key"]).toBe("Bearer sk-gateway-test");
|
|
106
|
+
expect(sub.models.map((m: any) => m.id).sort()).toEqual(["sub/claude-haiku-4-5", "sub/claude-opus-4-8"]);
|
|
107
|
+
} finally {
|
|
108
|
+
if (prevKey === undefined) delete process.env.LLM_API_KEY;
|
|
109
|
+
else process.env.LLM_API_KEY = prevKey;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("skips the sub provider when subscription enabled but OAuth token missing", async () => {
|
|
114
|
+
const dir = makeTempDir();
|
|
115
|
+
const prevKey = process.env.LLM_API_KEY;
|
|
116
|
+
process.env.LLM_API_KEY = "sk-gateway-test";
|
|
117
|
+
try {
|
|
118
|
+
const mod = await loadFlantInfraModule(dir);
|
|
119
|
+
const registered = new Map<string, unknown>();
|
|
120
|
+
const pi = {
|
|
121
|
+
registerProvider: vi.fn((name: string, config: unknown) => registered.set(name, config)),
|
|
122
|
+
unregisterProvider: vi.fn((name: string) => registered.delete(name)),
|
|
123
|
+
} as any;
|
|
124
|
+
|
|
125
|
+
mod.registerFlantProviders(pi, ["claude-opus-4-8"], {}, { subscription: true });
|
|
126
|
+
expect(registered.has("pp-flant-anthropic-sub")).toBe(false);
|
|
127
|
+
} finally {
|
|
128
|
+
if (prevKey === undefined) delete process.env.LLM_API_KEY;
|
|
129
|
+
else process.env.LLM_API_KEY = prevKey;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("refreshSubProvider re-registers the sub provider when the token changes", async () => {
|
|
134
|
+
const dir = makeTempDir();
|
|
135
|
+
mkdirSync(dir, { recursive: true });
|
|
136
|
+
const authPath = join(dir, "auth.json");
|
|
137
|
+
// Start with an expired token that has a refresh token available.
|
|
138
|
+
writeFileSync(
|
|
139
|
+
authPath,
|
|
140
|
+
JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", refresh: "rt-old", expires: Date.now() - 1000 } }),
|
|
141
|
+
"utf-8",
|
|
142
|
+
);
|
|
143
|
+
const prevKey = process.env.LLM_API_KEY;
|
|
144
|
+
process.env.LLM_API_KEY = "sk-gateway-test";
|
|
145
|
+
try {
|
|
146
|
+
const mod = await loadFlantInfraModule(dir);
|
|
147
|
+
const registered = new Map<string, any>();
|
|
148
|
+
const pi = {
|
|
149
|
+
registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)),
|
|
150
|
+
unregisterProvider: vi.fn((name: string) => registered.delete(name)),
|
|
151
|
+
} as any;
|
|
152
|
+
|
|
153
|
+
// Refresh yields a fresh token, which is what the initial registration reads.
|
|
154
|
+
refreshAnthropicTokenMock.mockResolvedValueOnce({ access: "sk-ant-oat01-fresh", refresh: "rt-fresh", expires: Date.now() + 3_600_000 });
|
|
155
|
+
await mod.refreshClaudeOAuthToken();
|
|
156
|
+
mod.registerFlantProviders(pi, ["claude-opus-4-8"], {}, { subscription: true });
|
|
157
|
+
expect(registered.get("pp-flant-anthropic-sub").apiKey).toBe("sk-ant-oat01-fresh");
|
|
158
|
+
|
|
159
|
+
// A no-op refresh (token unchanged) must not re-register.
|
|
160
|
+
const callsBefore = pi.registerProvider.mock.calls.length;
|
|
161
|
+
await mod.refreshSubProvider(pi);
|
|
162
|
+
expect(pi.registerProvider.mock.calls.length).toBe(callsBefore);
|
|
163
|
+
|
|
164
|
+
// Simulate the token expiring and a refresh minting a new one: the sub
|
|
165
|
+
// provider is re-registered with the new token.
|
|
166
|
+
writeFileSync(
|
|
167
|
+
authPath,
|
|
168
|
+
JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-expired", refresh: "rt-fresh", expires: Date.now() - 1000 } }),
|
|
169
|
+
"utf-8",
|
|
170
|
+
);
|
|
171
|
+
refreshAnthropicTokenMock.mockResolvedValueOnce({ access: "sk-ant-oat01-rotated", refresh: "rt-rotated", expires: Date.now() + 3_600_000 });
|
|
172
|
+
await mod.refreshSubProvider(pi);
|
|
173
|
+
expect(registered.get("pp-flant-anthropic-sub").apiKey).toBe("sk-ant-oat01-rotated");
|
|
174
|
+
} finally {
|
|
175
|
+
if (prevKey === undefined) delete process.env.LLM_API_KEY;
|
|
176
|
+
else process.env.LLM_API_KEY = prevKey;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("refreshSubProvider is a no-op when subscription routing is inactive", async () => {
|
|
181
|
+
const dir = makeTempDir();
|
|
182
|
+
const mod = await loadFlantInfraModule(dir);
|
|
183
|
+
const pi = {
|
|
184
|
+
registerProvider: vi.fn(),
|
|
185
|
+
unregisterProvider: vi.fn(),
|
|
186
|
+
} as any;
|
|
187
|
+
// No registerFlantProviders({ subscription: true }) call happened, so there
|
|
188
|
+
// is no cached sub-provider context: refresh must do nothing.
|
|
189
|
+
await mod.refreshSubProvider(pi);
|
|
190
|
+
expect(pi.registerProvider).not.toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("readClaudeOAuthToken returns null for expired token", async () => {
|
|
194
|
+
const dir = makeTempDir();
|
|
195
|
+
mkdirSync(dir, { recursive: true });
|
|
196
|
+
writeFileSync(
|
|
197
|
+
join(dir, "auth.json"),
|
|
198
|
+
JSON.stringify({ anthropic: { access: "sk-ant-oat01-old", expires: Date.now() - 1000 } }),
|
|
199
|
+
"utf-8",
|
|
200
|
+
);
|
|
201
|
+
const mod = await loadFlantInfraModule(dir);
|
|
202
|
+
expect(mod.readClaudeOAuthToken()).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("refreshClaudeOAuthToken returns the current token when not expired", async () => {
|
|
206
|
+
const dir = makeTempDir();
|
|
207
|
+
mkdirSync(dir, { recursive: true });
|
|
208
|
+
writeFileSync(
|
|
209
|
+
join(dir, "auth.json"),
|
|
210
|
+
JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-fresh", refresh: "rt", expires: Date.now() + 3_600_000 } }),
|
|
211
|
+
"utf-8",
|
|
212
|
+
);
|
|
213
|
+
const mod = await loadFlantInfraModule(dir);
|
|
214
|
+
await expect(mod.refreshClaudeOAuthToken()).resolves.toBe("sk-ant-oat01-fresh");
|
|
215
|
+
expect(refreshAnthropicTokenMock).not.toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("refreshClaudeOAuthToken refreshes an expired token and persists it", async () => {
|
|
219
|
+
const dir = makeTempDir();
|
|
220
|
+
mkdirSync(dir, { recursive: true });
|
|
221
|
+
const authPath = join(dir, "auth.json");
|
|
222
|
+
writeFileSync(
|
|
223
|
+
authPath,
|
|
224
|
+
JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", refresh: "rt-old", expires: Date.now() - 1000 } }),
|
|
225
|
+
"utf-8",
|
|
226
|
+
);
|
|
227
|
+
const newExpires = Date.now() + 3_600_000;
|
|
228
|
+
refreshAnthropicTokenMock.mockResolvedValue({ access: "sk-ant-oat01-new", refresh: "rt-new", expires: newExpires });
|
|
229
|
+
|
|
230
|
+
const mod = await loadFlantInfraModule(dir);
|
|
231
|
+
await expect(mod.refreshClaudeOAuthToken()).resolves.toBe("sk-ant-oat01-new");
|
|
232
|
+
expect(refreshAnthropicTokenMock).toHaveBeenCalledWith("rt-old");
|
|
233
|
+
|
|
234
|
+
const persisted = JSON.parse(readFileSync(authPath, "utf-8"));
|
|
235
|
+
expect(persisted.anthropic).toMatchObject({
|
|
236
|
+
type: "oauth",
|
|
237
|
+
access: "sk-ant-oat01-new",
|
|
238
|
+
refresh: "rt-new",
|
|
239
|
+
expires: newExpires,
|
|
240
|
+
});
|
|
241
|
+
// A subsequent synchronous read now sees the fresh token.
|
|
242
|
+
expect(mod.readClaudeOAuthToken()).toBe("sk-ant-oat01-new");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("refreshClaudeOAuthToken returns null when expired and no refresh token present", async () => {
|
|
246
|
+
const dir = makeTempDir();
|
|
247
|
+
mkdirSync(dir, { recursive: true });
|
|
248
|
+
writeFileSync(
|
|
249
|
+
join(dir, "auth.json"),
|
|
250
|
+
JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", expires: Date.now() - 1000 } }),
|
|
251
|
+
"utf-8",
|
|
252
|
+
);
|
|
253
|
+
const mod = await loadFlantInfraModule(dir);
|
|
254
|
+
await expect(mod.refreshClaudeOAuthToken()).resolves.toBeNull();
|
|
255
|
+
expect(refreshAnthropicTokenMock).not.toHaveBeenCalled();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("refreshClaudeOAuthToken returns null when refresh fails", async () => {
|
|
259
|
+
const dir = makeTempDir();
|
|
260
|
+
mkdirSync(dir, { recursive: true });
|
|
261
|
+
writeFileSync(
|
|
262
|
+
join(dir, "auth.json"),
|
|
263
|
+
JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", refresh: "rt-old", expires: Date.now() - 1000 } }),
|
|
264
|
+
"utf-8",
|
|
265
|
+
);
|
|
266
|
+
refreshAnthropicTokenMock.mockRejectedValue(new Error("refresh boom"));
|
|
267
|
+
const mod = await loadFlantInfraModule(dir);
|
|
268
|
+
await expect(mod.refreshClaudeOAuthToken()).resolves.toBeNull();
|
|
269
|
+
});
|
|
270
|
+
|
|
60
271
|
it("generateDisplayName formats model ids", async () => {
|
|
61
272
|
const dir = makeTempDir();
|
|
62
273
|
const mod = await loadFlantInfraModule(dir);
|
|
@@ -94,6 +305,79 @@ describe("flant-infra", () => {
|
|
|
94
305
|
expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gemini.model).toBe("pp-flant-openai/gemini-3-1-pro");
|
|
95
306
|
});
|
|
96
307
|
|
|
308
|
+
it("generateFlantConfig routes Claude roles through subs when subscription active", async () => {
|
|
309
|
+
const dir = makeTempDir();
|
|
310
|
+
const mod = await loadFlantInfraModule(dir);
|
|
311
|
+
|
|
312
|
+
const config = mod.generateFlantConfig(
|
|
313
|
+
["claude-opus-4-8", "claude-haiku-4-5", "gpt-5-4", "gemini-3-1-pro", "gemini-3-1-flash"],
|
|
314
|
+
true,
|
|
315
|
+
) as any;
|
|
316
|
+
|
|
317
|
+
// Claude roles -> sub provider
|
|
318
|
+
expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
|
|
319
|
+
expect(config.agents.orchestrators.plan.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
|
|
320
|
+
expect(config.agents.orchestrators.brainstorm.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
|
|
321
|
+
expect(config.agents.subagents.simple.task.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
|
|
322
|
+
expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.opus.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
|
|
323
|
+
// Non-Claude roles stay on the openai (company-billed) provider
|
|
324
|
+
expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
|
|
325
|
+
expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash");
|
|
326
|
+
expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gpt.model).toBe("pp-flant-openai/gpt-5-4");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("generateFlantConfig keeps Claude roles on the company provider when subscription inactive", async () => {
|
|
330
|
+
const dir = makeTempDir();
|
|
331
|
+
const mod = await loadFlantInfraModule(dir);
|
|
332
|
+
|
|
333
|
+
const config = mod.generateFlantConfig(["claude-opus-4-8", "gpt-5-4"], false) as any;
|
|
334
|
+
expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic/claude-opus-4-8");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("isSubscriptionActive requires flag + oauth token + gateway key", async () => {
|
|
338
|
+
const dir = makeTempDir();
|
|
339
|
+
mkdirSync(dir, { recursive: true });
|
|
340
|
+
writeFileSync(
|
|
341
|
+
join(dir, "auth.json"),
|
|
342
|
+
JSON.stringify({ anthropic: { access: "sk-ant-oat01-test", expires: Date.now() + 3_600_000 } }),
|
|
343
|
+
"utf-8",
|
|
344
|
+
);
|
|
345
|
+
const settingsDir = join(dir, "extensions", "pp", "cache");
|
|
346
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
347
|
+
writeFileSync(join(settingsDir, "flant-models.json"), JSON.stringify({ enabled: true, subscription: true }), "utf-8");
|
|
348
|
+
const prevKey = process.env.LLM_API_KEY;
|
|
349
|
+
process.env.LLM_API_KEY = "sk-gateway-test";
|
|
350
|
+
try {
|
|
351
|
+
const mod = await loadFlantInfraModule(dir);
|
|
352
|
+
expect(mod.isSubscriptionActive()).toBe(true);
|
|
353
|
+
expect(mod.isSubscriptionActive({ subscription: false } as any)).toBe(false);
|
|
354
|
+
} finally {
|
|
355
|
+
if (prevKey === undefined) delete process.env.LLM_API_KEY;
|
|
356
|
+
else process.env.LLM_API_KEY = prevKey;
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("isSubscriptionActive is false without a gateway key", async () => {
|
|
361
|
+
const dir = makeTempDir();
|
|
362
|
+
mkdirSync(dir, { recursive: true });
|
|
363
|
+
writeFileSync(
|
|
364
|
+
join(dir, "auth.json"),
|
|
365
|
+
JSON.stringify({ anthropic: { access: "sk-ant-oat01-test", expires: Date.now() + 3_600_000 } }),
|
|
366
|
+
"utf-8",
|
|
367
|
+
);
|
|
368
|
+
const prevKey = process.env.LLM_API_KEY;
|
|
369
|
+
const prevFlant = process.env.FLANT_API_KEY;
|
|
370
|
+
delete process.env.LLM_API_KEY;
|
|
371
|
+
delete process.env.FLANT_API_KEY;
|
|
372
|
+
try {
|
|
373
|
+
const mod = await loadFlantInfraModule(dir);
|
|
374
|
+
expect(mod.isSubscriptionActive({ subscription: true } as any)).toBe(false);
|
|
375
|
+
} finally {
|
|
376
|
+
if (prevKey !== undefined) process.env.LLM_API_KEY = prevKey;
|
|
377
|
+
if (prevFlant !== undefined) process.env.FLANT_API_KEY = prevFlant;
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
97
381
|
it("generateFlantConfig returns empty object for empty models", async () => {
|
|
98
382
|
const dir = makeTempDir();
|
|
99
383
|
const mod = await loadFlantInfraModule(dir);
|
|
@@ -143,6 +427,8 @@ describe("flant-infra", () => {
|
|
|
143
427
|
enabled: false,
|
|
144
428
|
autoUpdate: true,
|
|
145
429
|
cacheTTLDays: 7,
|
|
430
|
+
switchBackIntervalMinutes: 30,
|
|
431
|
+
subscription: false,
|
|
146
432
|
lastUpdated: null,
|
|
147
433
|
cachedFlantModels: null,
|
|
148
434
|
cachedOpenRouterData: null,
|
|
@@ -180,6 +466,8 @@ describe("flant-infra", () => {
|
|
|
180
466
|
enabled: true,
|
|
181
467
|
autoUpdate: false,
|
|
182
468
|
cacheTTLDays: 3,
|
|
469
|
+
switchBackIntervalMinutes: 30,
|
|
470
|
+
subscription: false,
|
|
183
471
|
lastUpdated: "2026-01-02T03:04:05.000Z",
|
|
184
472
|
cachedFlantModels: ["gpt-5-4", "claude-opus-4-6"],
|
|
185
473
|
cachedOpenRouterData: {
|
|
@@ -202,6 +490,8 @@ describe("flant-infra", () => {
|
|
|
202
490
|
enabled: true,
|
|
203
491
|
autoUpdate: true,
|
|
204
492
|
cacheTTLDays: 14,
|
|
493
|
+
switchBackIntervalMinutes: 30,
|
|
494
|
+
subscription: true,
|
|
205
495
|
lastUpdated: "2026-02-01T00:00:00.000Z",
|
|
206
496
|
cachedFlantModels: ["claude-opus-4-6", "gpt-5-4"],
|
|
207
497
|
cachedOpenRouterData: {
|
|
@@ -221,4 +511,26 @@ describe("flant-infra", () => {
|
|
|
221
511
|
expect(loaded).toEqual(settings);
|
|
222
512
|
expect(existsSync(join(dir, "extensions", "pp", "cache", "flant-models.json"))).toBe(true);
|
|
223
513
|
});
|
|
514
|
+
|
|
515
|
+
it("normalizes switchBackIntervalMinutes: default when missing, parsed, floored to >=1", async () => {
|
|
516
|
+
const dir = makeTempDir();
|
|
517
|
+
const mod = await loadFlantInfraModule(dir);
|
|
518
|
+
const settingsDir = join(dir, "extensions", "pp", "cache");
|
|
519
|
+
const settingsPath = join(settingsDir, "flant-models.json");
|
|
520
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
521
|
+
|
|
522
|
+
// Missing -> default 30.
|
|
523
|
+
writeFileSync(settingsPath, JSON.stringify({ enabled: true }), "utf-8");
|
|
524
|
+
expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(30);
|
|
525
|
+
|
|
526
|
+
// String numeric -> parsed and rounded.
|
|
527
|
+
writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: "45" }), "utf-8");
|
|
528
|
+
expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(45);
|
|
529
|
+
|
|
530
|
+
// Invalid / <1 -> floored to 1.
|
|
531
|
+
writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: 0 }), "utf-8");
|
|
532
|
+
expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(1);
|
|
533
|
+
writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: "nonsense" }), "utf-8");
|
|
534
|
+
expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(30);
|
|
535
|
+
});
|
|
224
536
|
});
|