@ilya-lesikov/pi-pi 0.5.0 → 0.7.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 +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +68 -28
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +631 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +7 -3
- package/extensions/orchestrator/doctor.test.ts +561 -0
- package/extensions/orchestrator/doctor.ts +702 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1220 -343
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +511 -0
- package/extensions/orchestrator/flant-infra.ts +390 -49
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +3065 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +302 -0
- package/extensions/orchestrator/model-registry.ts +298 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +298 -140
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -36
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -31
- package/extensions/orchestrator/phases/review-task.ts +3 -7
- package/extensions/orchestrator/phases/review.test.ts +54 -0
- package/extensions/orchestrator/phases/review.ts +89 -48
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +207 -0
- package/extensions/orchestrator/pp-menu.ts +2741 -373
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +128 -44
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +229 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +563 -0
- package/extensions/orchestrator/usage-tracker.ts +96 -12
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- package/package.json +2 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { callExa } from "./exa.js";
|
|
3
|
+
|
|
4
|
+
function mockFetchText(text: string, init: { ok?: boolean; status?: number } = {}) {
|
|
5
|
+
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
6
|
+
ok: init.ok ?? true,
|
|
7
|
+
status: init.status ?? 200,
|
|
8
|
+
text: async () => text,
|
|
9
|
+
} as Response);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("callExa error handling", () => {
|
|
17
|
+
it("throws on an SSE error payload even when the message lacks the word 'error'", async () => {
|
|
18
|
+
mockFetchText(`data: ${JSON.stringify({ error: { message: "rate limit exceeded" } })}\n`);
|
|
19
|
+
await expect(callExa("web_search_exa", {})).rejects.toThrow("rate limit exceeded");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns text from a normal SSE success response", async () => {
|
|
23
|
+
mockFetchText(`data: ${JSON.stringify({ result: { content: [{ text: "hello world" }] } })}\n`);
|
|
24
|
+
await expect(callExa("web_search_exa", {})).resolves.toBe("hello world");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns text from a non-SSE JSON success response", async () => {
|
|
28
|
+
mockFetchText(JSON.stringify({ result: { content: [{ text: "plain json" }] } }));
|
|
29
|
+
await expect(callExa("web_search_exa", {})).resolves.toBe("plain json");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("throws on a non-SSE JSON error payload lacking the word 'error'", async () => {
|
|
33
|
+
mockFetchText(JSON.stringify({ error: { message: "quota reached" } }));
|
|
34
|
+
await expect(callExa("web_search_exa", {})).rejects.toThrow("quota reached");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("falls through and returns the raw body for a non-JSON OK response", async () => {
|
|
38
|
+
mockFetchText("not json at all", { ok: true, status: 200 });
|
|
39
|
+
await expect(callExa("web_search_exa", {})).resolves.toBe("not json at all");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("throws on a non-JSON body when the HTTP response is not ok", async () => {
|
|
43
|
+
mockFetchText("rate limited", { ok: false, status: 429 });
|
|
44
|
+
await expect(callExa("web_search_exa", {})).rejects.toThrow("Exa HTTP 429");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -3,7 +3,7 @@ import { Type } from "@sinclair/typebox";
|
|
|
3
3
|
|
|
4
4
|
const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
|
|
5
5
|
|
|
6
|
-
async function callExa(toolName: string, args: Record<string, unknown>): Promise<string> {
|
|
6
|
+
export async function callExa(toolName: string, args: Record<string, unknown>): Promise<string> {
|
|
7
7
|
const body = JSON.stringify({
|
|
8
8
|
jsonrpc: "2.0",
|
|
9
9
|
id: 1,
|
|
@@ -25,23 +25,29 @@ async function callExa(toolName: string, args: Record<string, unknown>): Promise
|
|
|
25
25
|
|
|
26
26
|
for (const line of raw.split("\n")) {
|
|
27
27
|
if (!line.startsWith("data:")) continue;
|
|
28
|
+
let json: any;
|
|
28
29
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (text) return text;
|
|
33
|
-
} catch (e: any) {
|
|
34
|
-
if (e.message?.includes("error")) throw e;
|
|
30
|
+
json = JSON.parse(line.slice(5).trim());
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
35
33
|
}
|
|
34
|
+
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error));
|
|
35
|
+
const text = json.result?.content?.[0]?.text;
|
|
36
|
+
if (text) return text;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
let json: any;
|
|
38
40
|
try {
|
|
39
|
-
|
|
40
|
-
if (json.error) throw new Error(json.error.message);
|
|
41
|
-
return json.result?.content?.[0]?.text ?? raw;
|
|
41
|
+
json = JSON.parse(raw);
|
|
42
42
|
} catch {
|
|
43
|
+
if (!res.ok) throw new Error(`Exa HTTP ${res.status}: ${raw.slice(0, 200)}`);
|
|
43
44
|
return raw;
|
|
44
45
|
}
|
|
46
|
+
if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error));
|
|
47
|
+
const text = json.result?.content?.[0]?.text;
|
|
48
|
+
if (text != null) return text;
|
|
49
|
+
if (!res.ok) throw new Error(`Exa HTTP ${res.status}: ${raw.slice(0, 200)}`);
|
|
50
|
+
return raw;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
function ok(text: string) {
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
const refreshAnthropicTokenMock = vi.fn();
|
|
8
|
+
vi.mock("@earendil-works/pi-ai/oauth", () => ({
|
|
9
|
+
refreshAnthropicToken: (...args: unknown[]) => refreshAnthropicTokenMock(...args),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const tempDirs: string[] = [];
|
|
13
|
+
|
|
14
|
+
function makeTempDir(): string {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-pi-flant-infra-"));
|
|
16
|
+
tempDirs.push(dir);
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function loadFlantInfraModule(agentDir: string) {
|
|
21
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
22
|
+
vi.resetModules();
|
|
23
|
+
return import("./flant-infra.js");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function collectModelSpecs(value: unknown): string[] {
|
|
27
|
+
if (!value || typeof value !== "object") return [];
|
|
28
|
+
const out: string[] = [];
|
|
29
|
+
const walk = (node: any) => {
|
|
30
|
+
if (!node || typeof node !== "object") return;
|
|
31
|
+
if (typeof node.model === "string") out.push(node.model);
|
|
32
|
+
for (const nested of Object.values(node)) {
|
|
33
|
+
walk(nested);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
walk(value);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
refreshAnthropicTokenMock.mockReset();
|
|
42
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
43
|
+
for (const dir of tempDirs.splice(0)) {
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("flant-infra", () => {
|
|
49
|
+
it("registerFlantProviders is idempotent across repeated calls", async () => {
|
|
50
|
+
const dir = makeTempDir();
|
|
51
|
+
const mod = await loadFlantInfraModule(dir);
|
|
52
|
+
|
|
53
|
+
const registered = new Map<string, unknown>();
|
|
54
|
+
const pi = {
|
|
55
|
+
registerProvider: vi.fn((name: string, config: unknown) => registered.set(name, config)),
|
|
56
|
+
unregisterProvider: vi.fn((name: string) => registered.delete(name)),
|
|
57
|
+
} as any;
|
|
58
|
+
|
|
59
|
+
const models = ["claude-opus-4-6", "gpt-5"];
|
|
60
|
+
mod.registerFlantProviders(pi, models, {});
|
|
61
|
+
mod.registerFlantProviders(pi, models, {});
|
|
62
|
+
|
|
63
|
+
expect(registered.size).toBe(2);
|
|
64
|
+
expect([...registered.keys()].sort()).toEqual(["pp-flant-anthropic", "pp-flant-openai"]);
|
|
65
|
+
});
|
|
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
|
+
|
|
271
|
+
it("generateDisplayName formats model ids", async () => {
|
|
272
|
+
const dir = makeTempDir();
|
|
273
|
+
const mod = await loadFlantInfraModule(dir);
|
|
274
|
+
|
|
275
|
+
expect(mod.generateDisplayName("claude-opus-4-6")).toBe("Claude Opus 4.6");
|
|
276
|
+
expect(mod.generateDisplayName("gpt-5-4-mini")).toBe("GPT 5.4 Mini");
|
|
277
|
+
expect(mod.generateDisplayName("o3-mini")).toBe("O3 Mini");
|
|
278
|
+
expect(mod.generateDisplayName("qwen-3-coder")).toBe("Qwen 3 Coder");
|
|
279
|
+
expect(mod.generateDisplayName("api-ai-2")).toBe("API AI 2");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("generateFlantConfig creates config with mapped model specs", async () => {
|
|
283
|
+
const dir = makeTempDir();
|
|
284
|
+
const mod = await loadFlantInfraModule(dir);
|
|
285
|
+
|
|
286
|
+
const config = mod.generateFlantConfig([
|
|
287
|
+
"claude-opus-4-6",
|
|
288
|
+
"gpt-5-4",
|
|
289
|
+
"gpt-5-4-mini",
|
|
290
|
+
"gemini-3-1-pro",
|
|
291
|
+
"gemini-3-1-flash",
|
|
292
|
+
"deepseek-v3",
|
|
293
|
+
"grok-4",
|
|
294
|
+
]) as any;
|
|
295
|
+
|
|
296
|
+
expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic/claude-opus-4-6");
|
|
297
|
+
expect(config.agents.orchestrators.plan.model).toBe("pp-flant-anthropic/claude-opus-4-6");
|
|
298
|
+
expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
|
|
299
|
+
expect(config.agents.orchestrators.brainstorm.model).toBe("pp-flant-anthropic/claude-opus-4-6");
|
|
300
|
+
expect(config.agents.orchestrators.review.model).toBe("pp-flant-anthropic/claude-opus-4-6");
|
|
301
|
+
expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash");
|
|
302
|
+
expect(config.agents.subagents.simple.librarian.model).toBe("pp-flant-openai/gemini-3-1-flash");
|
|
303
|
+
expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.opus.model).toBe("pp-flant-anthropic/claude-opus-4-6");
|
|
304
|
+
expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gpt.model).toBe("pp-flant-openai/gpt-5-4");
|
|
305
|
+
expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gemini.model).toBe("pp-flant-openai/gemini-3-1-pro");
|
|
306
|
+
});
|
|
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
|
+
|
|
381
|
+
it("generateFlantConfig returns empty object for empty models", async () => {
|
|
382
|
+
const dir = makeTempDir();
|
|
383
|
+
const mod = await loadFlantInfraModule(dir);
|
|
384
|
+
|
|
385
|
+
expect(mod.generateFlantConfig([])).toEqual({});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("generateFlantConfig with anthropic-only models uses anthropic specs", async () => {
|
|
389
|
+
const dir = makeTempDir();
|
|
390
|
+
const mod = await loadFlantInfraModule(dir);
|
|
391
|
+
|
|
392
|
+
const config = mod.generateFlantConfig(["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-3-5"]);
|
|
393
|
+
const specs = collectModelSpecs(config);
|
|
394
|
+
|
|
395
|
+
expect(specs.length).toBeGreaterThan(0);
|
|
396
|
+
expect(specs.every((spec) => spec.startsWith("pp-flant-anthropic/"))).toBe(true);
|
|
397
|
+
expect(specs.some((spec) => spec.startsWith("pp-flant-openai/"))).toBe(false);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("generateFlantConfig picks latest models per family", async () => {
|
|
401
|
+
const dir = makeTempDir();
|
|
402
|
+
const mod = await loadFlantInfraModule(dir);
|
|
403
|
+
|
|
404
|
+
const config = mod.generateFlantConfig([
|
|
405
|
+
"claude-opus-4-5",
|
|
406
|
+
"claude-opus-4-7",
|
|
407
|
+
"gpt-5-3",
|
|
408
|
+
"gpt-5-4",
|
|
409
|
+
"gemini-3-0-pro",
|
|
410
|
+
"gemini-3-1-pro",
|
|
411
|
+
"gemini-3-1-flash",
|
|
412
|
+
"gemini-3-1-flash-lite",
|
|
413
|
+
]) as any;
|
|
414
|
+
|
|
415
|
+
expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic/claude-opus-4-7");
|
|
416
|
+
expect(config.agents.orchestrators.plan.model).toBe("pp-flant-anthropic/claude-opus-4-7");
|
|
417
|
+
expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
|
|
418
|
+
expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gemini.model).toBe("pp-flant-openai/gemini-3-1-pro");
|
|
419
|
+
expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash-lite");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("loadFlantSettings returns defaults when file is missing", async () => {
|
|
423
|
+
const dir = makeTempDir();
|
|
424
|
+
const mod = await loadFlantInfraModule(dir);
|
|
425
|
+
|
|
426
|
+
expect(mod.loadFlantSettings()).toEqual({
|
|
427
|
+
enabled: false,
|
|
428
|
+
autoUpdate: true,
|
|
429
|
+
cacheTTLDays: 7,
|
|
430
|
+
subscription: false,
|
|
431
|
+
lastUpdated: null,
|
|
432
|
+
cachedFlantModels: null,
|
|
433
|
+
cachedOpenRouterData: null,
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("loadFlantSettings parses valid settings file", async () => {
|
|
438
|
+
const dir = makeTempDir();
|
|
439
|
+
const mod = await loadFlantInfraModule(dir);
|
|
440
|
+
const settingsDir = join(dir, "extensions", "pp", "cache");
|
|
441
|
+
const settingsPath = join(settingsDir, "flant-models.json");
|
|
442
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
443
|
+
writeFileSync(
|
|
444
|
+
settingsPath,
|
|
445
|
+
JSON.stringify({
|
|
446
|
+
enabled: true,
|
|
447
|
+
autoUpdate: false,
|
|
448
|
+
cacheTTLDays: "3",
|
|
449
|
+
lastUpdated: "2026-01-02T03:04:05.000Z",
|
|
450
|
+
cachedFlantModels: ["gpt-5-4", 123, "claude-opus-4-6"],
|
|
451
|
+
cachedOpenRouterData: {
|
|
452
|
+
"gpt-5-4": {
|
|
453
|
+
name: "GPT 5.4",
|
|
454
|
+
context_length: 123,
|
|
455
|
+
max_completion_tokens: 45,
|
|
456
|
+
pricing: { prompt: 1, completion: 2, cacheRead: 3, cacheWrite: 4 },
|
|
457
|
+
modality: "text",
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
}),
|
|
461
|
+
"utf-8",
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
expect(mod.loadFlantSettings()).toEqual({
|
|
465
|
+
enabled: true,
|
|
466
|
+
autoUpdate: false,
|
|
467
|
+
cacheTTLDays: 3,
|
|
468
|
+
subscription: false,
|
|
469
|
+
lastUpdated: "2026-01-02T03:04:05.000Z",
|
|
470
|
+
cachedFlantModels: ["gpt-5-4", "claude-opus-4-6"],
|
|
471
|
+
cachedOpenRouterData: {
|
|
472
|
+
"gpt-5-4": {
|
|
473
|
+
name: "GPT 5.4",
|
|
474
|
+
context_length: 123,
|
|
475
|
+
max_completion_tokens: 45,
|
|
476
|
+
pricing: { prompt: 1, completion: 2, cacheRead: 3, cacheWrite: 4 },
|
|
477
|
+
modality: "text",
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("saveFlantSettings and loadFlantSettings round-trip", async () => {
|
|
484
|
+
const dir = makeTempDir();
|
|
485
|
+
const mod = await loadFlantInfraModule(dir);
|
|
486
|
+
|
|
487
|
+
const settings = {
|
|
488
|
+
enabled: true,
|
|
489
|
+
autoUpdate: true,
|
|
490
|
+
cacheTTLDays: 14,
|
|
491
|
+
subscription: true,
|
|
492
|
+
lastUpdated: "2026-02-01T00:00:00.000Z",
|
|
493
|
+
cachedFlantModels: ["claude-opus-4-6", "gpt-5-4"],
|
|
494
|
+
cachedOpenRouterData: {
|
|
495
|
+
"claude-opus-4-6": {
|
|
496
|
+
name: "Claude Opus 4.6",
|
|
497
|
+
context_length: 200000,
|
|
498
|
+
max_completion_tokens: 32000,
|
|
499
|
+
pricing: { prompt: 0.01, completion: 0.02, cacheRead: 0.003, cacheWrite: 0.004 },
|
|
500
|
+
modality: "text",
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
mod.saveFlantSettings(settings);
|
|
506
|
+
|
|
507
|
+
const loaded = mod.loadFlantSettings();
|
|
508
|
+
expect(loaded).toEqual(settings);
|
|
509
|
+
expect(existsSync(join(dir, "extensions", "pp", "cache", "flant-models.json"))).toBe(true);
|
|
510
|
+
});
|
|
511
|
+
});
|