@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.
@@ -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
- const inputTokens = tracker?.getTotalInputTokens() ?? 0;
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
- if (totalCost > 0) leftParts.push(`$${totalCost.toFixed(2)}`);
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-4-6",
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
- return "[PI-PI] Review cycle is ready for apply_feedback. Read reviewer outputs and proceed.";
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
- const effectiveMode = getEffectiveMode(orchestrator.active.state);
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: {