@ilya-lesikov/pi-pi 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/3p/pi-ask-user/index.ts +1 -1
  2. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  5. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  7. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  8. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  9. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  10. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  11. package/3p/pi-subagents/src/index.ts +3 -38
  12. package/3p/pi-subagents/src/types.ts +4 -0
  13. package/extensions/orchestrator/agents/advisor.ts +35 -0
  14. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  15. package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
  16. package/extensions/orchestrator/agents/constraints.test.ts +44 -0
  17. package/extensions/orchestrator/agents/constraints.ts +3 -0
  18. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  19. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  20. package/extensions/orchestrator/agents/planner.ts +9 -8
  21. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  22. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  23. package/extensions/orchestrator/agents/task.ts +6 -20
  24. package/extensions/orchestrator/agents/tool-routing.ts +23 -1
  25. package/extensions/orchestrator/command-handlers.ts +1 -1
  26. package/extensions/orchestrator/config.ts +8 -4
  27. package/extensions/orchestrator/context.test.ts +54 -0
  28. package/extensions/orchestrator/context.ts +65 -2
  29. package/extensions/orchestrator/custom-footer.ts +5 -2
  30. package/extensions/orchestrator/doctor.test.ts +3 -1
  31. package/extensions/orchestrator/doctor.ts +40 -2
  32. package/extensions/orchestrator/event-handlers.test.ts +97 -1
  33. package/extensions/orchestrator/event-handlers.ts +222 -48
  34. package/extensions/orchestrator/flant-infra.test.ts +312 -0
  35. package/extensions/orchestrator/flant-infra.ts +407 -44
  36. package/extensions/orchestrator/index.ts +1 -1
  37. package/extensions/orchestrator/integration.test.ts +312 -18
  38. package/extensions/orchestrator/messages.test.ts +30 -0
  39. package/extensions/orchestrator/messages.ts +6 -0
  40. package/extensions/orchestrator/model-registry.test.ts +124 -13
  41. package/extensions/orchestrator/model-registry.ts +91 -33
  42. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  43. package/extensions/orchestrator/orchestrator.ts +163 -3
  44. package/extensions/orchestrator/phases/brainstorm.ts +9 -20
  45. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  46. package/extensions/orchestrator/phases/implementation.ts +4 -6
  47. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  48. package/extensions/orchestrator/phases/planning.ts +11 -4
  49. package/extensions/orchestrator/phases/review-task.ts +1 -4
  50. package/extensions/orchestrator/phases/review.test.ts +62 -0
  51. package/extensions/orchestrator/phases/review.ts +58 -15
  52. package/extensions/orchestrator/plannotator.ts +9 -6
  53. package/extensions/orchestrator/pp-menu.test.ts +74 -1
  54. package/extensions/orchestrator/pp-menu.ts +366 -94
  55. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  56. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  57. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  58. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  59. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  60. package/extensions/orchestrator/state.ts +41 -20
  61. package/extensions/orchestrator/test-helpers.ts +18 -3
  62. package/extensions/orchestrator/usage-tracker.test.ts +131 -3
  63. package/extensions/orchestrator/usage-tracker.ts +78 -11
  64. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  65. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  66. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import lockfile from "proper-lockfile";
5
+ import { refreshAnthropicToken } from "@earendil-works/pi-ai/oauth";
5
6
  import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
6
7
  import type { PiPiConfig } from "./config.js";
7
8
  import { updateRegistryFromAvailableModels } from "./model-registry.js";
@@ -28,6 +29,19 @@ export interface FlantSettings {
28
29
  lastUpdated: string | null;
29
30
  cachedFlantModels: string[] | null;
30
31
  cachedOpenRouterData: Record<string, OpenRouterModelData> | null;
32
+ /**
33
+ * When true, additionally register the `pp-flant-anthropic-sub` provider,
34
+ * which routes Claude requests through the gateway using the user's personal
35
+ * Claude OAuth token (billed against their personal Claude subscription).
36
+ */
37
+ subscription: boolean;
38
+ /**
39
+ * Minutes between out-of-band "is the subscription limit cleared yet?" probes
40
+ * while the sub→non-sub rate-limit fallback is active. On each interval a
41
+ * cheap probe hits the sub model; on success the user is asked to switch back.
42
+ * Default 30.
43
+ */
44
+ switchBackIntervalMinutes: number;
31
45
  }
32
46
 
33
47
  const GEMINI_MAP: Record<string, string> = {
@@ -56,11 +70,154 @@ const DEFAULT_SETTINGS: FlantSettings = {
56
70
  lastUpdated: null,
57
71
  cachedFlantModels: null,
58
72
  cachedOpenRouterData: null,
73
+ subscription: false,
74
+ switchBackIntervalMinutes: 30,
59
75
  };
60
76
 
77
+ /** Provider name for the personal-subscription Claude routing. */
78
+ export const SUB_PROVIDER = "pp-flant-anthropic-sub";
79
+
80
+ /** Prefix the gateway expects for personal-subscription Claude models. */
81
+ export const SUB_MODEL_PREFIX = "sub/";
82
+
83
+ /**
84
+ * Read the Claude OAuth access token persisted by pi's built-in `anthropic`
85
+ * OAuth provider. Returns null when absent or expired. The gateway uses this
86
+ * token (forwarded as `Authorization: Bearer ...`) to bill the user's personal
87
+ * Claude subscription; pi-ai automatically adds the Claude Code identity
88
+ * headers because the token has the `sk-ant-oat` prefix.
89
+ */
90
+ export function readClaudeOAuthToken(): string | null {
91
+ const authPath = join(resolveAgentDir(), "auth.json");
92
+ if (!existsSync(authPath)) return null;
93
+ try {
94
+ const raw = JSON.parse(readFileSync(authPath, "utf-8")) as {
95
+ anthropic?: { access?: unknown; expires?: unknown };
96
+ };
97
+ const anthropic = raw.anthropic;
98
+ if (!anthropic || typeof anthropic.access !== "string" || !anthropic.access) return null;
99
+ if (typeof anthropic.expires === "number" && anthropic.expires <= Date.now()) return null;
100
+ return anthropic.access;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ interface AnthropicOAuthCreds {
107
+ type?: unknown;
108
+ access?: unknown;
109
+ refresh?: unknown;
110
+ expires?: unknown;
111
+ }
112
+
113
+ /**
114
+ * Ensure the persisted Claude OAuth access token is fresh, refreshing it via
115
+ * the stored refresh token when it has expired (or is within its safety
116
+ * margin). The refreshed credentials are written back to `auth.json` under the
117
+ * `anthropic` provider id in pi's own `{ type: "oauth", ... }` format, so both
118
+ * this extension and pi's built-in `anthropic` provider observe the new token.
119
+ *
120
+ * Unlike readClaudeOAuthToken, this is async (a refresh performs a network
121
+ * call) and returns the valid access token, or null when no usable
122
+ * credentials exist / a refresh fails. Async entry points call this before the
123
+ * synchronous readClaudeOAuthToken so downstream reads see a fresh token.
124
+ */
125
+ export async function refreshClaudeOAuthToken(): Promise<string | null> {
126
+ const log = getLogger();
127
+ const authPath = join(resolveAgentDir(), "auth.json");
128
+ if (!existsSync(authPath)) return null;
129
+
130
+ let anthropic: AnthropicOAuthCreds | undefined;
131
+ try {
132
+ const raw = JSON.parse(readFileSync(authPath, "utf-8")) as { anthropic?: AnthropicOAuthCreds };
133
+ anthropic = raw.anthropic;
134
+ } catch {
135
+ return null;
136
+ }
137
+ if (!anthropic || typeof anthropic.access !== "string" || !anthropic.access) return null;
138
+
139
+ const expires = typeof anthropic.expires === "number" ? anthropic.expires : 0;
140
+ if (expires > Date.now()) return anthropic.access;
141
+
142
+ // Expired (or no expiry recorded): try to refresh.
143
+ if (typeof anthropic.refresh !== "string" || !anthropic.refresh) {
144
+ log.debug({ s: "flant" }, "claude oauth token expired and no refresh token available");
145
+ return null;
146
+ }
147
+
148
+ let refreshed: { refresh: string; access: string; expires: number };
149
+ try {
150
+ refreshed = await refreshAnthropicToken(anthropic.refresh);
151
+ } catch (err: any) {
152
+ log.debug({ s: "flant", err: err?.message }, "claude oauth token refresh failed");
153
+ return null;
154
+ }
155
+
156
+ // Persist refreshed credentials back under the `anthropic` provider id, using
157
+ // pi's { type: "oauth", ... } shape and the same file lock pi uses.
158
+ try {
159
+ const authDir = resolveAgentDir();
160
+ if (!existsSync(authDir)) mkdirSync(authDir, { recursive: true });
161
+ if (!existsSync(authPath)) writeFileSync(authPath, "{}\n", "utf-8");
162
+ const release = lockfile.lockSync(authPath, { stale: 10000 });
163
+ try {
164
+ let current: Record<string, unknown> = {};
165
+ try {
166
+ current = JSON.parse(readFileSync(authPath, "utf-8")) as Record<string, unknown>;
167
+ } catch {
168
+ current = {};
169
+ }
170
+ const existing = (current.anthropic && typeof current.anthropic === "object")
171
+ ? current.anthropic as Record<string, unknown>
172
+ : {};
173
+ // Another instance may have refreshed while we were waiting for the lock.
174
+ const existingExpires = typeof existing.expires === "number" ? existing.expires : 0;
175
+ if (existingExpires > Date.now() && typeof existing.access === "string" && existing.access) {
176
+ return existing.access;
177
+ }
178
+ current.anthropic = {
179
+ ...existing,
180
+ type: "oauth",
181
+ access: refreshed.access,
182
+ refresh: refreshed.refresh,
183
+ expires: refreshed.expires,
184
+ };
185
+ writeFileSync(authPath, JSON.stringify(current, null, 2) + "\n", "utf-8");
186
+ } finally {
187
+ release();
188
+ }
189
+ } catch (err: any) {
190
+ // If persistence fails we still return the freshly minted token so the
191
+ // current run can proceed; the next run will refresh again.
192
+ log.debug({ s: "flant", err: err?.message }, "failed to persist refreshed claude oauth token");
193
+ }
194
+
195
+ log.debug({ s: "flant" }, "refreshed claude oauth token");
196
+ return refreshed.access;
197
+ }
198
+
199
+ /** Resolve the gateway API key (LLM_API_KEY preferred, FLANT_API_KEY fallback). */
200
+ export function readGatewayApiKey(): string | null {
201
+ return process.env.LLM_API_KEY ?? process.env.FLANT_API_KEY ?? null;
202
+ }
203
+
61
204
  let piRef: ExtensionAPI | null = null;
62
205
  let generatedFlantConfig: Partial<PiPiConfig> | null = null;
63
206
 
207
+ /**
208
+ * Inputs needed to (re)register the personal-subscription Claude provider.
209
+ * Cached at the last registerFlantProviders call so refreshSubProvider can
210
+ * rebuild the provider with a freshly-read OAuth token without re-discovering
211
+ * models. Null when subscription routing is not active.
212
+ */
213
+ interface SubProviderContext {
214
+ anthropicModels: string[];
215
+ metadata: Record<string, OpenRouterModelData>;
216
+ }
217
+ let subProviderContext: SubProviderContext | null = null;
218
+ /** The OAuth access token the sub provider was last registered with. */
219
+ let lastSubToken: string | null = null;
220
+
64
221
  export function setPI(pi: ExtensionAPI): void {
65
222
  piRef = pi;
66
223
  }
@@ -74,6 +231,7 @@ export function unregisterFlantProviders(pi?: ExtensionAPI): void {
74
231
  if (!api) return;
75
232
  api.unregisterProvider("pp-flant-anthropic");
76
233
  api.unregisterProvider("pp-flant-openai");
234
+ api.unregisterProvider(SUB_PROVIDER);
77
235
  }
78
236
 
79
237
  function ensureSettingsDir(): void {
@@ -95,10 +253,16 @@ function normalizeSettings(raw: unknown): FlantSettings {
95
253
  if (!raw || typeof raw !== "object") return { ...DEFAULT_SETTINGS };
96
254
  const value = raw as Record<string, unknown>;
97
255
  const cacheTTLDays = Math.max(1, Math.round(toNumber(value.cacheTTLDays, DEFAULT_SETTINGS.cacheTTLDays)));
256
+ const switchBackIntervalMinutes = Math.max(
257
+ 1,
258
+ Math.round(toNumber(value.switchBackIntervalMinutes, DEFAULT_SETTINGS.switchBackIntervalMinutes)),
259
+ );
98
260
  return {
99
261
  enabled: !!value.enabled,
100
262
  autoUpdate: value.autoUpdate === undefined ? true : !!value.autoUpdate,
101
263
  cacheTTLDays,
264
+ switchBackIntervalMinutes,
265
+ subscription: !!value.subscription,
102
266
  lastUpdated: typeof value.lastUpdated === "string" ? value.lastUpdated : null,
103
267
  cachedFlantModels: Array.isArray(value.cachedFlantModels)
104
268
  ? value.cachedFlantModels.filter((m): m is string => typeof m === "string")
@@ -196,6 +360,81 @@ function mapFlantToOpenRouterId(modelId: string): string | null {
196
360
  return null;
197
361
  }
198
362
 
363
+ /**
364
+ * Out-of-band probe: is the personal Claude subscription limit cleared yet?
365
+ * Sends a tiny, fully throwaway request (NOT part of the session, so it cannot
366
+ * pollute conversation/context/cache) to the gateway's Anthropic endpoint using
367
+ * the personal Claude OAuth token. Returns:
368
+ * "ok" — 200: capacity is back (offer switch-back)
369
+ * "rate_limited" — 429: still limited (stay on non-sub, retry later)
370
+ * "error" — credentials missing / network / other status (treat as not-back)
371
+ *
372
+ * The OAuth token is refreshed first so a failure is a genuine 429 and not an
373
+ * expired-token false negative. `max_tokens: 1` + an explicit "just respond hi"
374
+ * instruction keep output at ~1 token.
375
+ */
376
+ // Derive the gateway probe model id (`sub/<bare-claude-id>`) from any stored
377
+ // form: `pp-flant-anthropic-sub/sub/<m>`, `sub/<m>`, or a bare `<m>`. Exported
378
+ // for testing the derivation without a network call.
379
+ export function subProbeModelId(modelId: string): string {
380
+ let bare = modelId;
381
+ if (bare.startsWith(`${SUB_PROVIDER}/`)) bare = bare.slice(`${SUB_PROVIDER}/`.length);
382
+ if (bare.startsWith(SUB_MODEL_PREFIX)) bare = bare.slice(SUB_MODEL_PREFIX.length);
383
+ return `${SUB_MODEL_PREFIX}${bare}`;
384
+ }
385
+
386
+ export async function probeSubscriptionCleared(
387
+ modelId: string,
388
+ ): Promise<"ok" | "rate_limited" | "error"> {
389
+ const log = getLogger();
390
+ await refreshClaudeOAuthToken();
391
+ const oauthToken = readClaudeOAuthToken();
392
+ const gatewayKey = readGatewayApiKey();
393
+ if (!oauthToken || !gatewayKey) {
394
+ log.debug({ s: "flant", hasOAuth: !!oauthToken, hasGatewayKey: !!gatewayKey }, "probe skipped: missing credentials");
395
+ return "error";
396
+ }
397
+ // The gateway expects the bare claude-* id under the `sub/` prefix.
398
+ const probeModel = subProbeModelId(modelId);
399
+ try {
400
+ const res = await fetch("https://llm-api.flant.ru/v1/messages", {
401
+ method: "POST",
402
+ headers: {
403
+ "content-type": "application/json",
404
+ "anthropic-version": "2023-06-01",
405
+ // Claude Code OAuth identity headers — the gateway requires these for
406
+ // subscription (sub/) routing; without them it can reject the probe and
407
+ // we would never detect that the limit cleared. Mirrors doctor.ts.
408
+ "anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
409
+ "user-agent": "claude-cli/1.0.0",
410
+ "x-app": "cli",
411
+ Authorization: `Bearer ${oauthToken}`,
412
+ "x-litellm-api-key": `Bearer ${gatewayKey}`,
413
+ },
414
+ body: JSON.stringify({
415
+ model: probeModel,
416
+ max_tokens: 1,
417
+ temperature: 0,
418
+ messages: [{ role: "user", content: "just respond hi" }],
419
+ }),
420
+ signal: AbortSignal.timeout(30000),
421
+ });
422
+ if (res.status === 429) {
423
+ log.debug({ s: "flant", model: probeModel }, "probe: still rate limited");
424
+ return "rate_limited";
425
+ }
426
+ if (res.ok) {
427
+ log.debug({ s: "flant", model: probeModel }, "probe: capacity back");
428
+ return "ok";
429
+ }
430
+ log.debug({ s: "flant", model: probeModel, status: res.status }, "probe: unexpected status");
431
+ return "error";
432
+ } catch (err: any) {
433
+ log.debug({ s: "flant", err: err?.message }, "probe failed");
434
+ return "error";
435
+ }
436
+ }
437
+
199
438
  export async function discoverFlantModels(apiKey: string): Promise<string[]> {
200
439
  const res = await fetch("https://llm-api.flant.ru/v1/models", {
201
440
  headers: { Authorization: `Bearer ${apiKey}` },
@@ -262,9 +501,24 @@ export async function fetchOpenRouterMetadata(modelIds: string[]): Promise<Recor
262
501
  return out;
263
502
  }
264
503
 
265
- function modelSpec(modelId: string): string {
266
- const provider = modelId.startsWith("claude-") ? "pp-flant-anthropic" : "pp-flant-openai";
267
- return `${provider}/${modelId}`;
504
+ /**
505
+ * Returns true when the personal-subscription Claude path is fully usable:
506
+ * the setting is enabled AND both credentials (Claude OAuth token + gateway
507
+ * key) are present. Mirrors the gate in registerFlantProviders so we never
508
+ * generate `sub/` role assignments that cannot resolve to a real provider.
509
+ */
510
+ export function isSubscriptionActive(settings?: FlantSettings): boolean {
511
+ const s = settings ?? loadFlantSettings();
512
+ return s.subscription && !!readClaudeOAuthToken() && !!readGatewayApiKey();
513
+ }
514
+
515
+ function modelSpec(modelId: string, subscriptionActive = false): string {
516
+ if (modelId.startsWith("claude-")) {
517
+ return subscriptionActive
518
+ ? `${SUB_PROVIDER}/${SUB_MODEL_PREFIX}${modelId}`
519
+ : `pp-flant-anthropic/${modelId}`;
520
+ }
521
+ return `pp-flant-openai/${modelId}`;
268
522
  }
269
523
 
270
524
  function buildProviderModelConfig(
@@ -289,16 +543,21 @@ function buildProviderModelConfig(
289
543
  };
290
544
  }
291
545
 
546
+ export interface RegisterFlantOptions {
547
+ /** Whether to also register the personal-subscription provider. Defaults to loadFlantSettings().subscription. */
548
+ subscription?: boolean;
549
+ }
550
+
292
551
  export function registerFlantProviders(
293
552
  pi: ExtensionAPI,
294
553
  models: string[],
295
554
  metadata: Record<string, OpenRouterModelData>,
555
+ options: RegisterFlantOptions = {},
296
556
  ): void {
297
557
  const log = getLogger();
298
558
  const uniqueModels = [...new Set(models)];
299
559
  const anthropicModels = uniqueModels.filter((m) => m.startsWith("claude-"));
300
560
  const openaiModels = uniqueModels.filter((m) => !m.startsWith("claude-"));
301
- log.debug({ s: "flant", total: uniqueModels.length, anthropic: anthropicModels.length, openai: openaiModels.length }, "registering flant providers");
302
561
 
303
562
  unregisterFlantProviders(pi);
304
563
 
@@ -316,10 +575,97 @@ export function registerFlantProviders(
316
575
  models: openaiModels.map((m) => buildProviderModelConfig(m, metadata)),
317
576
  });
318
577
 
319
- updateRegistryFromAvailableModels([
578
+ const availableSpecs = [
320
579
  ...anthropicModels.map((id) => `pp-flant-anthropic/${id}`),
321
580
  ...openaiModels.map((id) => `pp-flant-openai/${id}`),
322
- ]);
581
+ ];
582
+
583
+ const subscription = options.subscription ?? loadFlantSettings().subscription;
584
+ let subModels: string[] = [];
585
+ if (subscription) {
586
+ // Remember the models/metadata so refreshSubProvider can rebuild the
587
+ // provider with a fresh OAuth token on each turn (see below).
588
+ subProviderContext = { anthropicModels, metadata };
589
+ subModels = registerSubProvider(pi, anthropicModels, metadata);
590
+ availableSpecs.push(...subModels.map((id) => `${SUB_PROVIDER}/${id}`));
591
+ } else {
592
+ subProviderContext = null;
593
+ lastSubToken = null;
594
+ }
595
+
596
+ log.debug({ s: "flant", total: uniqueModels.length, anthropic: anthropicModels.length, openai: openaiModels.length, sub: subModels.length }, "registering flant providers");
597
+
598
+ updateRegistryFromAvailableModels(availableSpecs);
599
+ }
600
+
601
+ /**
602
+ * Register (or re-register) the personal-subscription Claude provider using the
603
+ * OAuth access token currently persisted in auth.json. Returns the list of
604
+ * `sub/`-prefixed model ids registered (empty when credentials are missing).
605
+ *
606
+ * The provider is registered with a literal `apiKey` (the resolved token), so
607
+ * the value is a static snapshot for the lifetime of the registration. Because
608
+ * the OAuth token expires within a few hours, refreshSubProvider must be called
609
+ * periodically (on each turn) to rebuild the provider with a fresh token;
610
+ * otherwise long-lived sessions send a stale token and the gateway returns 401.
611
+ */
612
+ function registerSubProvider(
613
+ pi: ExtensionAPI,
614
+ anthropicModels: string[],
615
+ metadata: Record<string, OpenRouterModelData>,
616
+ ): string[] {
617
+ const log = getLogger();
618
+ const oauthToken = readClaudeOAuthToken();
619
+ const gatewayKey = readGatewayApiKey();
620
+ if (!oauthToken || !gatewayKey) {
621
+ log.debug({ s: "flant", hasOAuth: !!oauthToken, hasGatewayKey: !!gatewayKey }, "subscription enabled but credentials missing; skipping sub provider");
622
+ lastSubToken = null;
623
+ return [];
624
+ }
625
+ pi.registerProvider(SUB_PROVIDER, {
626
+ name: "Flant (personal Claude subscription)",
627
+ api: "anthropic-messages",
628
+ baseUrl: "https://llm-api.flant.ru",
629
+ // The OAuth token (sk-ant-oat...) triggers pi-ai's Claude Code identity
630
+ // headers and is forwarded as `Authorization: Bearer ...`.
631
+ apiKey: oauthToken,
632
+ // Gateway key travels in a side header so it does not clobber the OAuth auth.
633
+ headers: { "x-litellm-api-key": `Bearer ${gatewayKey}` },
634
+ // Model id carries the `sub/` prefix the gateway expects, while pricing/
635
+ // metadata is looked up by the bare claude-* id.
636
+ models: anthropicModels.map((m) => {
637
+ const cfg = buildProviderModelConfig(m, metadata);
638
+ return { ...cfg, id: `${SUB_MODEL_PREFIX}${m}` };
639
+ }),
640
+ });
641
+ lastSubToken = oauthToken;
642
+ return anthropicModels.map((m) => `${SUB_MODEL_PREFIX}${m}`);
643
+ }
644
+
645
+ /**
646
+ * Ensure the personal-subscription Claude provider is registered with a
647
+ * non-expired OAuth token. Refreshes the token (persisting it to auth.json when
648
+ * needed) and, when it changed since the last registration, re-registers the
649
+ * provider so subsequent LLM calls pick up the fresh token.
650
+ *
651
+ * Called on each turn for the root session. No-op when subscription routing is
652
+ * not active. Cheap when the token is unchanged (only a token read + compare).
653
+ */
654
+ export async function refreshSubProvider(pi?: ExtensionAPI): Promise<void> {
655
+ const ctx = subProviderContext;
656
+ if (!ctx) return;
657
+ const api = pi ?? piRef;
658
+ if (!api) return;
659
+
660
+ await refreshClaudeOAuthToken();
661
+ const token = readClaudeOAuthToken();
662
+ // Only re-register when the token actually changed; registerProvider takes
663
+ // effect immediately, so re-registering every turn would be wasteful churn.
664
+ if (token && token === lastSubToken) return;
665
+
666
+ const log = getLogger();
667
+ registerSubProvider(api, ctx.anthropicModels, ctx.metadata);
668
+ log.debug({ s: "flant", changed: token !== lastSubToken }, "refreshed sub provider oauth token");
323
669
  }
324
670
 
325
671
  function pickLatest(models: string[]): string | null {
@@ -339,18 +685,19 @@ function pickCheapestFastModel(models: string[]): string | null {
339
685
  return pickLatest(models.filter((m) => /^claude-haiku-/.test(m)));
340
686
  }
341
687
 
342
- function makeVariant(modelId: string | null, fallbackModelId: string): { enabled: boolean; model: string; thinking: string } {
343
- if (!modelId) return { enabled: false, model: modelSpec(fallbackModelId), thinking: "high" };
344
- return { enabled: true, model: modelSpec(modelId), thinking: "high" };
688
+ function makeVariant(modelId: string | null, fallbackModelId: string, sub = false): { enabled: boolean; model: string; thinking: string } {
689
+ if (!modelId) return { enabled: false, model: modelSpec(fallbackModelId, sub), thinking: "high" };
690
+ return { enabled: true, model: modelSpec(modelId, sub), thinking: "high" };
345
691
  }
346
692
 
347
693
  function makeVariantWithThinking(
348
694
  modelId: string | null,
349
695
  fallbackModelId: string,
350
696
  thinking: string,
697
+ sub = false,
351
698
  ): { enabled: boolean; model: string; thinking: string } {
352
- if (!modelId) return { enabled: false, model: modelSpec(fallbackModelId), thinking };
353
- return { enabled: true, model: modelSpec(modelId), thinking };
699
+ if (!modelId) return { enabled: false, model: modelSpec(fallbackModelId, sub), thinking };
700
+ return { enabled: true, model: modelSpec(modelId, sub), thinking };
354
701
  }
355
702
 
356
703
  function buildPresetGroup(
@@ -360,9 +707,10 @@ function buildPresetGroup(
360
707
  return { default: defaultPreset, presets };
361
708
  }
362
709
 
363
- export function generateFlantConfig(models: string[]): Partial<PiPiConfig> {
710
+ export function generateFlantConfig(models: string[], subscriptionActive = false): Partial<PiPiConfig> {
364
711
  const uniqueModels = [...new Set(models)];
365
712
  if (uniqueModels.length === 0) return {};
713
+ const sub = subscriptionActive;
366
714
 
367
715
  const latestOpus = pickLatest(uniqueModels.filter((m) => /^claude-opus-/.test(m)));
368
716
  const latestClaude = pickLatest(uniqueModels.filter((m) => /^claude-/.test(m)));
@@ -383,73 +731,77 @@ export function generateFlantConfig(models: string[]): Partial<PiPiConfig> {
383
731
  return {
384
732
  agents: {
385
733
  orchestrators: {
386
- implement: { model: modelSpec(implementModel), thinking: "high" },
387
- plan: { model: modelSpec(implementModel), thinking: "high" },
388
- debug: { model: modelSpec(debugModel), thinking: "high" },
389
- brainstorm: { model: modelSpec(brainstormModel), thinking: "high" },
390
- review: { model: modelSpec(implementModel), thinking: "high" },
734
+ implement: { model: modelSpec(implementModel, sub), thinking: "high" },
735
+ plan: { model: modelSpec(implementModel, sub), thinking: "high" },
736
+ debug: { model: modelSpec(debugModel, sub), thinking: "high" },
737
+ brainstorm: { model: modelSpec(brainstormModel, sub), thinking: "high" },
738
+ review: { model: modelSpec(implementModel, sub), thinking: "high" },
739
+ quick: { model: modelSpec(implementModel, sub), thinking: "high" },
391
740
  },
392
741
  subagents: {
393
742
  simple: {
394
- explore: { model: modelSpec(fastModel), thinking: "low" },
395
- librarian: { model: modelSpec(fastModel), thinking: "medium" },
396
- task: { model: modelSpec(taskModel), thinking: "medium" },
743
+ explore: { model: modelSpec(fastModel, sub), thinking: "low" },
744
+ librarian: { model: modelSpec(fastModel, sub), thinking: "medium" },
745
+ task: { model: modelSpec(taskModel, sub), thinking: "medium" },
746
+ advisor: { model: modelSpec(latestGpt ?? fallback, sub), thinking: "high" },
747
+ "deep-debugger": { model: modelSpec(latestGpt ?? fallback, sub), thinking: "high" },
748
+ reviewer: { model: modelSpec(latestGpt ?? fallback, sub), thinking: "high" },
397
749
  },
398
750
  presetGroups: {
399
751
  planners: buildPresetGroup({
400
752
  regular: {
401
753
  agents: {
402
- opus: makeVariant(latestOpus, fallback),
403
- gpt: makeVariant(latestGpt, fallback),
404
- gemini: makeVariant(latestGeminiPro, fallback),
754
+ opus: makeVariant(latestOpus, fallback, sub),
755
+ gpt: makeVariant(latestGpt, fallback, sub),
756
+ gemini: makeVariant(latestGeminiPro, fallback, sub),
405
757
  },
406
758
  },
407
759
  }),
408
760
  planReviewers: buildPresetGroup({
409
761
  regular: {
410
762
  agents: {
411
- opus: makeVariant(latestOpus, fallback),
412
- gpt: makeVariant(latestGpt, fallback),
413
- gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
763
+ opus: makeVariant(latestOpus, fallback, sub),
764
+ gpt: makeVariant(latestGpt, fallback, sub),
765
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
414
766
  },
415
767
  },
416
768
  deep: {
417
769
  agents: {
418
- opus: makeVariantWithThinking(latestOpus, fallback, "xhigh"),
419
- gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh"),
420
- gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
770
+ opus: makeVariantWithThinking(latestOpus, fallback, "xhigh", sub),
771
+ gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh", sub),
772
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
421
773
  },
422
774
  },
423
775
  }),
424
776
  codeReviewers: buildPresetGroup({
425
777
  regular: {
426
778
  agents: {
427
- opus: makeVariant(latestOpus, fallback),
428
- gpt: makeVariant(latestGpt, fallback),
429
- gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
779
+ opus: makeVariant(latestOpus, fallback, sub),
780
+ gpt: makeVariant(latestGpt, fallback, sub),
781
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
430
782
  },
431
783
  },
432
784
  deep: {
433
785
  agents: {
434
- opus: makeVariantWithThinking(latestOpus, fallback, "xhigh"),
435
- gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh"),
436
- gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
786
+ opus: makeVariantWithThinking(latestOpus, fallback, "xhigh", sub),
787
+ gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh", sub),
788
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
437
789
  },
438
790
  },
439
791
  }),
440
792
  brainstormReviewers: buildPresetGroup({
441
793
  regular: {
442
794
  agents: {
443
- opus: makeVariant(latestOpus, fallback),
444
- gpt: makeVariant(latestGpt, fallback),
445
- gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
795
+ opus: makeVariant(latestOpus, fallback, sub),
796
+ gpt: makeVariant(latestGpt, fallback, sub),
797
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
446
798
  },
447
799
  },
448
800
  deep: {
449
801
  agents: {
450
- opus: makeVariantWithThinking(latestOpus, fallback, "xhigh"),
451
- gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh"),
452
- gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh"),
802
+ opus: makeVariantWithThinking(latestOpus, fallback, "xhigh", sub),
803
+ gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh", sub),
804
+ gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
453
805
  },
454
806
  },
455
807
  }),
@@ -479,6 +831,12 @@ export async function updateFlantInfra(
479
831
  setPI(pi);
480
832
  const settings = loadFlantSettings();
481
833
 
834
+ // Refresh the personal-subscription Claude OAuth token before (re)registering
835
+ // providers so the sub provider is built with a valid, non-expired token.
836
+ if (settings.subscription) {
837
+ await refreshClaudeOAuthToken();
838
+ }
839
+
482
840
  let models = isCacheValid(settings) ? settings.cachedFlantModels : null;
483
841
  let metadata = isCacheValid(settings) ? settings.cachedOpenRouterData : null;
484
842
  let refreshed = false;
@@ -522,7 +880,7 @@ export async function updateFlantInfra(
522
880
 
523
881
  try {
524
882
  registerFlantProviders(pi, models, metadata);
525
- generatedFlantConfig = generateFlantConfig(models);
883
+ generatedFlantConfig = generateFlantConfig(models, isSubscriptionActive(settings));
526
884
  if (!refreshed && settings.cachedFlantModels && settings.cachedOpenRouterData && !settings.lastUpdated) {
527
885
  settings.lastUpdated = new Date().toISOString();
528
886
  saveFlantSettings(settings);
@@ -544,7 +902,7 @@ export function initFlantSync(pi: ExtensionAPI): void {
544
902
  }
545
903
  if (settings.cachedFlantModels && settings.cachedOpenRouterData) {
546
904
  registerFlantProviders(pi, settings.cachedFlantModels, settings.cachedOpenRouterData);
547
- generatedFlantConfig = generateFlantConfig(settings.cachedFlantModels);
905
+ generatedFlantConfig = generateFlantConfig(settings.cachedFlantModels, isSubscriptionActive(settings));
548
906
  }
549
907
  }
550
908
 
@@ -555,6 +913,11 @@ export async function initFlantOnStartup(pi: ExtensionAPI): Promise<void> {
555
913
  generatedFlantConfig = null;
556
914
  return;
557
915
  }
558
- if (!settings.autoUpdate) return;
916
+ if (!settings.autoUpdate) {
917
+ // updateFlantInfra (which refreshes the token) is skipped when auto-update
918
+ // is off, but any registered sub provider still needs a fresh token.
919
+ if (settings.subscription) await refreshClaudeOAuthToken();
920
+ return;
921
+ }
559
922
  await updateFlantInfra(pi);
560
923
  }
@@ -65,7 +65,7 @@ function registerSubagentTools(pi: ExtensionAPI): void {
65
65
  ...event.content,
66
66
  {
67
67
  type: "text" as const,
68
- text: `\n\n<validation-error>\nPlan structure is invalid:\n${result.errors.map((e) => `- ${e}`).join("\n")}\n\nFix immediately. Required structure:\n# Plan\n## Scope\n<2-4 lines>\n## Checklist\n- [ ] <outcome> — Done when: <observable condition>\n## Blockers (optional)\n<issues>\n\nRewrite the file now.\n</validation-error>`,
68
+ text: `\n\n<validation-error>\nPlan structure is invalid:\n${result.errors.map((e) => `- ${e}`).join("\n")}\n\nFix immediately. Required structure:\n# Plan\n## Scope\n<2-4 lines>\n## Checklist\n- [ ] <outcome> — Done when: <observable condition>\n## Pattern constraints (optional; include when adding a type/function/user-facing value)\n<closest existing analog + conventions to mirror>\n## Blockers (optional)\n<issues>\n\nRewrite the file now.\n</validation-error>`,
69
69
  },
70
70
  ],
71
71
  };