@ilya-lesikov/pi-pi 0.6.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 +1 -1
- package/extensions/orchestrator/config.ts +3 -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.ts +47 -6
- package/extensions/orchestrator/flant-infra.test.ts +287 -0
- package/extensions/orchestrator/flant-infra.ts +316 -44
- package/extensions/orchestrator/integration.test.ts +205 -6
- package/extensions/orchestrator/model-registry.test.ts +76 -12
- package/extensions/orchestrator/model-registry.ts +48 -32
- package/extensions/orchestrator/orchestrator.ts +13 -2
- package/extensions/orchestrator/phases/review.test.ts +54 -0
- package/extensions/orchestrator/phases/review.ts +54 -12
- package/extensions/orchestrator/pp-menu.test.ts +74 -1
- package/extensions/orchestrator/pp-menu.ts +199 -41
- package/extensions/orchestrator/state.ts +41 -20
- package/extensions/orchestrator/test-helpers.ts +14 -2
- package/extensions/orchestrator/usage-tracker.test.ts +131 -3
- package/extensions/orchestrator/usage-tracker.ts +74 -11
- package/package.json +1 -1
package/3p/pi-ask-user/index.ts
CHANGED
|
@@ -113,7 +113,7 @@ type AskResponse =
|
|
|
113
113
|
|
|
114
114
|
// Reason a question was cancelled. Only "user" (a deliberate top-level ESC)
|
|
115
115
|
// should abort the LLM turn; "timeout" and "signal" are programmatic and must not.
|
|
116
|
-
type CancelReason = "user" | "timeout" | "signal";
|
|
116
|
+
export type CancelReason = "user" | "timeout" | "signal";
|
|
117
117
|
|
|
118
118
|
// Sentinel returned through the UI/askUser boundary to carry a cancel reason.
|
|
119
119
|
// Distinct from a plain AskResponse so callers can disambiguate cancel vs answer.
|
|
@@ -6,7 +6,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
6
6
|
import { isValidLogLevel, getLogger, type LogLevel } from "./log.js";
|
|
7
7
|
|
|
8
8
|
export type DurationValue = string | number;
|
|
9
|
-
export type OrchestratorRole = "implement" | "plan" | "debug" | "brainstorm" | "review";
|
|
9
|
+
export type OrchestratorRole = "implement" | "plan" | "debug" | "brainstorm" | "review" | "quick";
|
|
10
10
|
export type SimpleSubagentRole = "explore" | "librarian" | "task";
|
|
11
11
|
export type PresetGroupKey = "planners" | "codeReviewers" | "planReviewers" | "brainstormReviewers";
|
|
12
12
|
|
|
@@ -91,7 +91,7 @@ export type TimeoutConfig = NormalizedPiPiConfig["performance"]["internals"];
|
|
|
91
91
|
|
|
92
92
|
export const PRESET_GROUPS = ["planners", "codeReviewers", "planReviewers", "brainstormReviewers"] as const;
|
|
93
93
|
|
|
94
|
-
const ORCHESTRATOR_ROLES: OrchestratorRole[] = ["implement", "plan", "debug", "brainstorm", "review"];
|
|
94
|
+
const ORCHESTRATOR_ROLES: OrchestratorRole[] = ["implement", "plan", "debug", "brainstorm", "review", "quick"];
|
|
95
95
|
const SIMPLE_SUBAGENT_ROLES: SimpleSubagentRole[] = ["explore", "librarian", "task"];
|
|
96
96
|
|
|
97
97
|
const DEFAULT_CONFIG: PiPiConfig = {
|
|
@@ -108,6 +108,7 @@ const DEFAULT_CONFIG: PiPiConfig = {
|
|
|
108
108
|
debug: { model: "openai/gpt-latest", thinking: "high" },
|
|
109
109
|
brainstorm: { model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
110
110
|
review: { model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
111
|
+
quick: { model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
111
112
|
},
|
|
112
113
|
subagents: {
|
|
113
114
|
simple: {
|
|
@@ -58,7 +58,9 @@ function renderStatsLine(width: number, theme: Theme): string {
|
|
|
58
58
|
const ctx = footerCtx;
|
|
59
59
|
const tracker = footerTracker;
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
// ↑ is the total input the model actually processed (uncached + cache read +
|
|
62
|
+
// cache write) across main + subagents — not just the tiny uncached sliver.
|
|
63
|
+
const inputTokens = tracker?.getTotalProcessedInputTokens() ?? 0;
|
|
62
64
|
const outputTokens = tracker?.getTotalOutputTokens() ?? 0;
|
|
63
65
|
const cacheRate = tracker?.getCacheHitRate() ?? 0;
|
|
64
66
|
const totalCost = tracker?.getTotalCost() ?? 0;
|
|
@@ -66,7 +68,8 @@ function renderStatsLine(width: number, theme: Theme): string {
|
|
|
66
68
|
const cacheSupported = tracker?.isCacheSupported() ?? false;
|
|
67
69
|
const leftParts: string[] = [`↑${formatTokens(inputTokens)}`, `↓${formatTokens(outputTokens)}`];
|
|
68
70
|
if (cacheSupported) leftParts.push(`⚡${Math.round(cacheRate * 100)}%`);
|
|
69
|
-
|
|
71
|
+
// Always show cost, even $0.00 (subscription/flat-rate sessions).
|
|
72
|
+
leftParts.push(`$${totalCost.toFixed(2)}`);
|
|
70
73
|
leftParts.push(toContextUsagePart(ctx, theme));
|
|
71
74
|
let left = leftParts.join(" ");
|
|
72
75
|
|
|
@@ -58,6 +58,7 @@ function createConfig() {
|
|
|
58
58
|
debug: { model: "openai/gpt-latest", thinking: "high" },
|
|
59
59
|
brainstorm: { model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
60
60
|
review: { model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
61
|
+
quick: { model: "anthropic/claude-opus-latest", thinking: "high" },
|
|
61
62
|
},
|
|
62
63
|
subagents: {
|
|
63
64
|
simple: {
|
|
@@ -143,6 +144,7 @@ function createCtx() {
|
|
|
143
144
|
},
|
|
144
145
|
modelRegistry: {
|
|
145
146
|
getAvailable: vi.fn(() => [
|
|
147
|
+
{ provider: "anthropic", id: "claude-opus-latest" },
|
|
146
148
|
{ provider: "anthropic", id: "claude-opus-4-6" },
|
|
147
149
|
{ provider: "openai", id: "gpt-5.4" },
|
|
148
150
|
{ provider: "google", id: "gemini-3.1-flash" },
|
|
@@ -154,7 +156,7 @@ function createCtx() {
|
|
|
154
156
|
|
|
155
157
|
beforeEach(() => {
|
|
156
158
|
const aliasMap: Record<string, string> = {
|
|
157
|
-
"anthropic/claude-opus-latest": "anthropic/claude-opus-
|
|
159
|
+
"anthropic/claude-opus-latest": "anthropic/claude-opus-latest",
|
|
158
160
|
"openai/gpt-latest": "openai/gpt-5.4",
|
|
159
161
|
"google/gemini-flash-latest": "google/gemini-3.1-flash",
|
|
160
162
|
"google/gemini-pro-latest": "google/gemini-3.1-pro",
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
PRESET_GROUPS,
|
|
13
13
|
} from "./config.js";
|
|
14
14
|
import { resolveModel, getAllAliases } from "./model-registry.js";
|
|
15
|
-
import { loadFlantSettings } from "./flant-infra.js";
|
|
15
|
+
import { loadFlantSettings, readClaudeOAuthToken, readGatewayApiKey, refreshClaudeOAuthToken } from "./flant-infra.js";
|
|
16
16
|
import type { Orchestrator } from "./orchestrator.js";
|
|
17
17
|
|
|
18
18
|
type Severity = "pass" | "warning" | "failure";
|
|
@@ -470,7 +470,7 @@ export async function runDoctor(orchestrator: Orchestrator, ctx: any): Promise<v
|
|
|
470
470
|
|
|
471
471
|
await safeCheck(async () => {
|
|
472
472
|
const settings = loadFlantSettings();
|
|
473
|
-
const shouldCheck = Boolean(process.env.FLANT_API_KEY) || settings.enabled || !!settings.cachedFlantModels || !!settings.cachedOpenRouterData;
|
|
473
|
+
const shouldCheck = Boolean(process.env.FLANT_API_KEY) || settings.enabled || settings.subscription || !!settings.cachedFlantModels || !!settings.cachedOpenRouterData;
|
|
474
474
|
if (!shouldCheck) {
|
|
475
475
|
addLine({ severity: "pass", text: "Skipped: FLANT_API_KEY not set and no Flant configuration detected" });
|
|
476
476
|
return;
|
|
@@ -526,6 +526,44 @@ export async function runDoctor(orchestrator: Orchestrator, ctx: any): Promise<v
|
|
|
526
526
|
} catch (error) {
|
|
527
527
|
addLine({ severity: "failure", text: `OpenRouter probe failed: ${toErrorMessage(error)}` });
|
|
528
528
|
}
|
|
529
|
+
|
|
530
|
+
if (settings.subscription) {
|
|
531
|
+
const oauthToken = (await refreshClaudeOAuthToken()) ?? readClaudeOAuthToken();
|
|
532
|
+
const gatewayKey = readGatewayApiKey();
|
|
533
|
+
if (!oauthToken) {
|
|
534
|
+
addLine({ severity: "warning", text: "Personal subscription enabled, but no valid Claude OAuth token found (log in to your subscription in pi)" });
|
|
535
|
+
} else if (!gatewayKey) {
|
|
536
|
+
addLine({ severity: "warning", text: "Personal subscription enabled, but no gateway key (LLM_API_KEY / FLANT_API_KEY)" });
|
|
537
|
+
} else {
|
|
538
|
+
const started = Date.now();
|
|
539
|
+
try {
|
|
540
|
+
const response = await timedFetch("https://llm-api.flant.ru/v1/messages", {
|
|
541
|
+
method: "POST",
|
|
542
|
+
headers: {
|
|
543
|
+
"content-type": "application/json",
|
|
544
|
+
"anthropic-version": "2023-06-01",
|
|
545
|
+
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
|
|
546
|
+
"user-agent": "claude-cli/1.0.0",
|
|
547
|
+
"x-app": "cli",
|
|
548
|
+
Authorization: `Bearer ${oauthToken}`,
|
|
549
|
+
"x-litellm-api-key": `Bearer ${gatewayKey}`,
|
|
550
|
+
},
|
|
551
|
+
body: JSON.stringify({
|
|
552
|
+
model: "sub/claude-haiku-4-5",
|
|
553
|
+
max_tokens: 4,
|
|
554
|
+
messages: [{ role: "user", content: "ping" }],
|
|
555
|
+
}),
|
|
556
|
+
}, 15000);
|
|
557
|
+
if (!response.ok) {
|
|
558
|
+
addLine({ severity: "failure", text: `Personal subscription probe failed with HTTP ${response.status}` });
|
|
559
|
+
} else {
|
|
560
|
+
addLine({ severity: "pass", text: `Personal subscription reachable (sub/claude-*, ${Date.now() - started}ms)` });
|
|
561
|
+
}
|
|
562
|
+
} catch (error) {
|
|
563
|
+
addLine({ severity: "failure", text: `Personal subscription probe failed: ${toErrorMessage(error)}` });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
529
567
|
}, "Flant checks failed");
|
|
530
568
|
|
|
531
569
|
addCategory("LSP");
|
|
@@ -206,14 +206,21 @@ function tryCompleteReviewCycle(orchestrator: Orchestrator, spawnedReviewers?: n
|
|
|
206
206
|
},
|
|
207
207
|
"instruction",
|
|
208
208
|
);
|
|
209
|
-
orchestrator.safeSendUserMessage(reviewReadyMessage(phase));
|
|
209
|
+
orchestrator.safeSendUserMessage(reviewReadyMessage(phase, getEffectivePhaseMode(orchestrator.active.state)));
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
function reviewReadyMessage(phase: string): string {
|
|
212
|
+
function reviewReadyMessage(phase: string, mode: TaskMode): string {
|
|
213
213
|
if (phase === "brainstorm") {
|
|
214
214
|
return "[PI-PI] Review cycle is ready for apply_feedback. The reviewers assessed your artifacts (USER_REQUEST.md, RESEARCH.md, and artifacts/), not a code diff. Read their outputs and update those artifacts as needed.";
|
|
215
215
|
}
|
|
216
|
-
|
|
216
|
+
// Only autonomous plan/implement auto-advance: those must re-call
|
|
217
|
+
// pp_phase_complete to finalize the pass and transition. Guided phases
|
|
218
|
+
// (including debug and the interactive review phase) stay user-driven, so
|
|
219
|
+
// they get neutral wording with no re-call/auto-advance directive.
|
|
220
|
+
if (mode === "autonomous") {
|
|
221
|
+
return "[PI-PI] Review cycle is ready for apply_feedback. Read the reviewer outputs, apply any required changes, then call pp_phase_complete again to finalize this review pass and advance the phase. Do NOT stop or wait for the user — the phase is NOT complete until you re-call pp_phase_complete.";
|
|
222
|
+
}
|
|
223
|
+
return "[PI-PI] Review cycle is ready for apply_feedback. Read the reviewer outputs and apply any required changes.";
|
|
217
224
|
}
|
|
218
225
|
|
|
219
226
|
export async function enterReviewCycle(
|
|
@@ -713,8 +720,14 @@ function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
|
713
720
|
|
|
714
721
|
ctx.ui?.setWorkingMessage?.("Waiting for user input…");
|
|
715
722
|
try {
|
|
716
|
-
const { showActiveTaskMenu } = await import("./pp-menu.js");
|
|
723
|
+
const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
|
|
717
724
|
const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
|
|
725
|
+
// Deliberate user ESC: stop the turn cleanly (mirror ask_user), no
|
|
726
|
+
// reminder text that would start a new LLM turn.
|
|
727
|
+
if (text === USER_CANCELLED) {
|
|
728
|
+
ctx.abort?.();
|
|
729
|
+
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
730
|
+
}
|
|
718
731
|
// A transition may have started while the menu was open. The controller
|
|
719
732
|
// is the source of truth; abort the agent's pending turn so it doesn't
|
|
720
733
|
// race the transition. (Interactive-UX abort — stays local, not routed.)
|
|
@@ -840,7 +853,10 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
840
853
|
const count = orchestrator.spawnedAgentIds.size + orchestrator.pendingSubagentSpawns;
|
|
841
854
|
return { content: [{ type: "text" as const, text: `${count} subagent(s) still running. Wait for them to complete before calling pp_phase_complete.` }], isError: true as const, details: {} };
|
|
842
855
|
}
|
|
843
|
-
|
|
856
|
+
// Gate on the effective PHASE mode, not the task mode: brainstorm/debug/
|
|
857
|
+
// review are always user-driven even for an autonomous task, so they must
|
|
858
|
+
// fall through to the guided menu path below rather than auto-advancing.
|
|
859
|
+
const effectiveMode = getEffectivePhaseMode(orchestrator.active.state);
|
|
844
860
|
if (effectiveMode === "autonomous") {
|
|
845
861
|
const phase = orchestrator.active.state.phase;
|
|
846
862
|
let justFinalizedReviewCycle = false;
|
|
@@ -908,8 +924,16 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
908
924
|
}
|
|
909
925
|
ctx.ui.setWorkingMessage?.("Waiting for user approval…");
|
|
910
926
|
try {
|
|
911
|
-
const { showActiveTaskMenu } = await import("./pp-menu.js");
|
|
927
|
+
const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
|
|
912
928
|
const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool");
|
|
929
|
+
// A deliberate user ESC on the menu means "stop cleanly, let me type" —
|
|
930
|
+
// mirror ask_user: abort the turn and return nothing so no new LLM turn
|
|
931
|
+
// starts. This takes precedence over the reminder path below, in ALL
|
|
932
|
+
// interactive cases (including while a transition is mid-flight).
|
|
933
|
+
if (text === USER_CANCELLED) {
|
|
934
|
+
ctx.abort?.();
|
|
935
|
+
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
936
|
+
}
|
|
913
937
|
// A transition or await may have started while the menu was open. The
|
|
914
938
|
// controller is the source of truth; abort the pending turn so it can't
|
|
915
939
|
// race. (Interactive-UX abort — stays local, not routed.)
|
|
@@ -923,6 +947,8 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
923
947
|
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
924
948
|
}
|
|
925
949
|
if (!text) {
|
|
950
|
+
// Non-ESC dismissal (explicit "Back") while a transition is running:
|
|
951
|
+
// keep the intentional artifact-update reminder (commit 10e7021).
|
|
926
952
|
return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
|
|
927
953
|
}
|
|
928
954
|
return { content: [{ type: "text" as const, text }], details: {} };
|
|
@@ -993,6 +1019,21 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
993
1019
|
|
|
994
1020
|
registerMainTraceHooks(orchestrator);
|
|
995
1021
|
|
|
1022
|
+
// Personal-subscription Claude routing registers the sub provider with a
|
|
1023
|
+
// literal OAuth token (a static snapshot). That token expires within a few
|
|
1024
|
+
// hours, so a long-lived session would keep sending a stale token and the
|
|
1025
|
+
// gateway would return 401. Refresh (and re-register on change) before each
|
|
1026
|
+
// turn's LLM call. Cheap no-op when subscription routing is inactive or the
|
|
1027
|
+
// token is unchanged. registerEventHandlers runs only on the root session.
|
|
1028
|
+
pi.on("turn_start", async () => {
|
|
1029
|
+
try {
|
|
1030
|
+
const { refreshSubProvider } = await import("./flant-infra.js");
|
|
1031
|
+
await refreshSubProvider(pi);
|
|
1032
|
+
} catch (err: any) {
|
|
1033
|
+
getLogger().debug({ s: "flant", err: err?.message }, "sub provider refresh failed");
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
|
|
996
1037
|
// TransitionController drivers. These are the reliable idle/completion signals:
|
|
997
1038
|
// - agent_end: the main loop went idle. If a transition is pending, this is
|
|
998
1039
|
// when the controller fires compaction.
|
|
@@ -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,7 @@ describe("flant-infra", () => {
|
|
|
143
427
|
enabled: false,
|
|
144
428
|
autoUpdate: true,
|
|
145
429
|
cacheTTLDays: 7,
|
|
430
|
+
subscription: false,
|
|
146
431
|
lastUpdated: null,
|
|
147
432
|
cachedFlantModels: null,
|
|
148
433
|
cachedOpenRouterData: null,
|
|
@@ -180,6 +465,7 @@ describe("flant-infra", () => {
|
|
|
180
465
|
enabled: true,
|
|
181
466
|
autoUpdate: false,
|
|
182
467
|
cacheTTLDays: 3,
|
|
468
|
+
subscription: false,
|
|
183
469
|
lastUpdated: "2026-01-02T03:04:05.000Z",
|
|
184
470
|
cachedFlantModels: ["gpt-5-4", "claude-opus-4-6"],
|
|
185
471
|
cachedOpenRouterData: {
|
|
@@ -202,6 +488,7 @@ describe("flant-infra", () => {
|
|
|
202
488
|
enabled: true,
|
|
203
489
|
autoUpdate: true,
|
|
204
490
|
cacheTTLDays: 14,
|
|
491
|
+
subscription: true,
|
|
205
492
|
lastUpdated: "2026-02-01T00:00:00.000Z",
|
|
206
493
|
cachedFlantModels: ["claude-opus-4-6", "gpt-5-4"],
|
|
207
494
|
cachedOpenRouterData: {
|