@ilya-lesikov/pi-pi 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/3p/pi-ask-user/index.ts +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +68 -28
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +631 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +7 -3
- package/extensions/orchestrator/doctor.test.ts +561 -0
- package/extensions/orchestrator/doctor.ts +702 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1220 -343
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +511 -0
- package/extensions/orchestrator/flant-infra.ts +390 -49
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +3065 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +302 -0
- package/extensions/orchestrator/model-registry.ts +298 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +298 -140
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -36
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -31
- package/extensions/orchestrator/phases/review-task.ts +3 -7
- package/extensions/orchestrator/phases/review.test.ts +54 -0
- package/extensions/orchestrator/phases/review.ts +89 -48
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +207 -0
- package/extensions/orchestrator/pp-menu.ts +2741 -373
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +128 -44
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +229 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +563 -0
- package/extensions/orchestrator/usage-tracker.ts +96 -12
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- package/package.json +2 -1
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import lockfile from "proper-lockfile";
|
|
5
|
+
import { refreshAnthropicToken } from "@earendil-works/pi-ai/oauth";
|
|
4
6
|
import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
5
7
|
import type { PiPiConfig } from "./config.js";
|
|
8
|
+
import { updateRegistryFromAvailableModels } from "./model-registry.js";
|
|
9
|
+
import { compareModelVersion } from "./model-version.js";
|
|
10
|
+
import { getLogger } from "./log.js";
|
|
6
11
|
|
|
7
12
|
export interface OpenRouterModelData {
|
|
8
13
|
name: string;
|
|
@@ -24,6 +29,12 @@ export interface FlantSettings {
|
|
|
24
29
|
lastUpdated: string | null;
|
|
25
30
|
cachedFlantModels: string[] | null;
|
|
26
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;
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
const GEMINI_MAP: Record<string, string> = {
|
|
@@ -52,11 +63,153 @@ const DEFAULT_SETTINGS: FlantSettings = {
|
|
|
52
63
|
lastUpdated: null,
|
|
53
64
|
cachedFlantModels: null,
|
|
54
65
|
cachedOpenRouterData: null,
|
|
66
|
+
subscription: false,
|
|
55
67
|
};
|
|
56
68
|
|
|
69
|
+
/** Provider name for the personal-subscription Claude routing. */
|
|
70
|
+
export const SUB_PROVIDER = "pp-flant-anthropic-sub";
|
|
71
|
+
|
|
72
|
+
/** Prefix the gateway expects for personal-subscription Claude models. */
|
|
73
|
+
export const SUB_MODEL_PREFIX = "sub/";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read the Claude OAuth access token persisted by pi's built-in `anthropic`
|
|
77
|
+
* OAuth provider. Returns null when absent or expired. The gateway uses this
|
|
78
|
+
* token (forwarded as `Authorization: Bearer ...`) to bill the user's personal
|
|
79
|
+
* Claude subscription; pi-ai automatically adds the Claude Code identity
|
|
80
|
+
* headers because the token has the `sk-ant-oat` prefix.
|
|
81
|
+
*/
|
|
82
|
+
export function readClaudeOAuthToken(): string | null {
|
|
83
|
+
const authPath = join(resolveAgentDir(), "auth.json");
|
|
84
|
+
if (!existsSync(authPath)) return null;
|
|
85
|
+
try {
|
|
86
|
+
const raw = JSON.parse(readFileSync(authPath, "utf-8")) as {
|
|
87
|
+
anthropic?: { access?: unknown; expires?: unknown };
|
|
88
|
+
};
|
|
89
|
+
const anthropic = raw.anthropic;
|
|
90
|
+
if (!anthropic || typeof anthropic.access !== "string" || !anthropic.access) return null;
|
|
91
|
+
if (typeof anthropic.expires === "number" && anthropic.expires <= Date.now()) return null;
|
|
92
|
+
return anthropic.access;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface AnthropicOAuthCreds {
|
|
99
|
+
type?: unknown;
|
|
100
|
+
access?: unknown;
|
|
101
|
+
refresh?: unknown;
|
|
102
|
+
expires?: unknown;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Ensure the persisted Claude OAuth access token is fresh, refreshing it via
|
|
107
|
+
* the stored refresh token when it has expired (or is within its safety
|
|
108
|
+
* margin). The refreshed credentials are written back to `auth.json` under the
|
|
109
|
+
* `anthropic` provider id in pi's own `{ type: "oauth", ... }` format, so both
|
|
110
|
+
* this extension and pi's built-in `anthropic` provider observe the new token.
|
|
111
|
+
*
|
|
112
|
+
* Unlike readClaudeOAuthToken, this is async (a refresh performs a network
|
|
113
|
+
* call) and returns the valid access token, or null when no usable
|
|
114
|
+
* credentials exist / a refresh fails. Async entry points call this before the
|
|
115
|
+
* synchronous readClaudeOAuthToken so downstream reads see a fresh token.
|
|
116
|
+
*/
|
|
117
|
+
export async function refreshClaudeOAuthToken(): Promise<string | null> {
|
|
118
|
+
const log = getLogger();
|
|
119
|
+
const authPath = join(resolveAgentDir(), "auth.json");
|
|
120
|
+
if (!existsSync(authPath)) return null;
|
|
121
|
+
|
|
122
|
+
let anthropic: AnthropicOAuthCreds | undefined;
|
|
123
|
+
try {
|
|
124
|
+
const raw = JSON.parse(readFileSync(authPath, "utf-8")) as { anthropic?: AnthropicOAuthCreds };
|
|
125
|
+
anthropic = raw.anthropic;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
if (!anthropic || typeof anthropic.access !== "string" || !anthropic.access) return null;
|
|
130
|
+
|
|
131
|
+
const expires = typeof anthropic.expires === "number" ? anthropic.expires : 0;
|
|
132
|
+
if (expires > Date.now()) return anthropic.access;
|
|
133
|
+
|
|
134
|
+
// Expired (or no expiry recorded): try to refresh.
|
|
135
|
+
if (typeof anthropic.refresh !== "string" || !anthropic.refresh) {
|
|
136
|
+
log.debug({ s: "flant" }, "claude oauth token expired and no refresh token available");
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let refreshed: { refresh: string; access: string; expires: number };
|
|
141
|
+
try {
|
|
142
|
+
refreshed = await refreshAnthropicToken(anthropic.refresh);
|
|
143
|
+
} catch (err: any) {
|
|
144
|
+
log.debug({ s: "flant", err: err?.message }, "claude oauth token refresh failed");
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Persist refreshed credentials back under the `anthropic` provider id, using
|
|
149
|
+
// pi's { type: "oauth", ... } shape and the same file lock pi uses.
|
|
150
|
+
try {
|
|
151
|
+
const authDir = resolveAgentDir();
|
|
152
|
+
if (!existsSync(authDir)) mkdirSync(authDir, { recursive: true });
|
|
153
|
+
if (!existsSync(authPath)) writeFileSync(authPath, "{}\n", "utf-8");
|
|
154
|
+
const release = lockfile.lockSync(authPath, { stale: 10000 });
|
|
155
|
+
try {
|
|
156
|
+
let current: Record<string, unknown> = {};
|
|
157
|
+
try {
|
|
158
|
+
current = JSON.parse(readFileSync(authPath, "utf-8")) as Record<string, unknown>;
|
|
159
|
+
} catch {
|
|
160
|
+
current = {};
|
|
161
|
+
}
|
|
162
|
+
const existing = (current.anthropic && typeof current.anthropic === "object")
|
|
163
|
+
? current.anthropic as Record<string, unknown>
|
|
164
|
+
: {};
|
|
165
|
+
// Another instance may have refreshed while we were waiting for the lock.
|
|
166
|
+
const existingExpires = typeof existing.expires === "number" ? existing.expires : 0;
|
|
167
|
+
if (existingExpires > Date.now() && typeof existing.access === "string" && existing.access) {
|
|
168
|
+
return existing.access;
|
|
169
|
+
}
|
|
170
|
+
current.anthropic = {
|
|
171
|
+
...existing,
|
|
172
|
+
type: "oauth",
|
|
173
|
+
access: refreshed.access,
|
|
174
|
+
refresh: refreshed.refresh,
|
|
175
|
+
expires: refreshed.expires,
|
|
176
|
+
};
|
|
177
|
+
writeFileSync(authPath, JSON.stringify(current, null, 2) + "\n", "utf-8");
|
|
178
|
+
} finally {
|
|
179
|
+
release();
|
|
180
|
+
}
|
|
181
|
+
} catch (err: any) {
|
|
182
|
+
// If persistence fails we still return the freshly minted token so the
|
|
183
|
+
// current run can proceed; the next run will refresh again.
|
|
184
|
+
log.debug({ s: "flant", err: err?.message }, "failed to persist refreshed claude oauth token");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
log.debug({ s: "flant" }, "refreshed claude oauth token");
|
|
188
|
+
return refreshed.access;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Resolve the gateway API key (LLM_API_KEY preferred, FLANT_API_KEY fallback). */
|
|
192
|
+
export function readGatewayApiKey(): string | null {
|
|
193
|
+
return process.env.LLM_API_KEY ?? process.env.FLANT_API_KEY ?? null;
|
|
194
|
+
}
|
|
195
|
+
|
|
57
196
|
let piRef: ExtensionAPI | null = null;
|
|
58
197
|
let generatedFlantConfig: Partial<PiPiConfig> | null = null;
|
|
59
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Inputs needed to (re)register the personal-subscription Claude provider.
|
|
201
|
+
* Cached at the last registerFlantProviders call so refreshSubProvider can
|
|
202
|
+
* rebuild the provider with a freshly-read OAuth token without re-discovering
|
|
203
|
+
* models. Null when subscription routing is not active.
|
|
204
|
+
*/
|
|
205
|
+
interface SubProviderContext {
|
|
206
|
+
anthropicModels: string[];
|
|
207
|
+
metadata: Record<string, OpenRouterModelData>;
|
|
208
|
+
}
|
|
209
|
+
let subProviderContext: SubProviderContext | null = null;
|
|
210
|
+
/** The OAuth access token the sub provider was last registered with. */
|
|
211
|
+
let lastSubToken: string | null = null;
|
|
212
|
+
|
|
60
213
|
export function setPI(pi: ExtensionAPI): void {
|
|
61
214
|
piRef = pi;
|
|
62
215
|
}
|
|
@@ -70,6 +223,7 @@ export function unregisterFlantProviders(pi?: ExtensionAPI): void {
|
|
|
70
223
|
if (!api) return;
|
|
71
224
|
api.unregisterProvider("pp-flant-anthropic");
|
|
72
225
|
api.unregisterProvider("pp-flant-openai");
|
|
226
|
+
api.unregisterProvider(SUB_PROVIDER);
|
|
73
227
|
}
|
|
74
228
|
|
|
75
229
|
function ensureSettingsDir(): void {
|
|
@@ -95,6 +249,7 @@ function normalizeSettings(raw: unknown): FlantSettings {
|
|
|
95
249
|
enabled: !!value.enabled,
|
|
96
250
|
autoUpdate: value.autoUpdate === undefined ? true : !!value.autoUpdate,
|
|
97
251
|
cacheTTLDays,
|
|
252
|
+
subscription: !!value.subscription,
|
|
98
253
|
lastUpdated: typeof value.lastUpdated === "string" ? value.lastUpdated : null,
|
|
99
254
|
cachedFlantModels: Array.isArray(value.cachedFlantModels)
|
|
100
255
|
? value.cachedFlantModels.filter((m): m is string => typeof m === "string")
|
|
@@ -117,7 +272,13 @@ export function loadFlantSettings(): FlantSettings {
|
|
|
117
272
|
|
|
118
273
|
export function saveFlantSettings(settings: FlantSettings): void {
|
|
119
274
|
ensureSettingsDir();
|
|
120
|
-
|
|
275
|
+
if (!existsSync(SETTINGS_PATH)) writeFileSync(SETTINGS_PATH, "{}\n", "utf-8");
|
|
276
|
+
const release = lockfile.lockSync(SETTINGS_PATH, { stale: 10000 });
|
|
277
|
+
try {
|
|
278
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
279
|
+
} finally {
|
|
280
|
+
release();
|
|
281
|
+
}
|
|
121
282
|
}
|
|
122
283
|
|
|
123
284
|
function toTitleCase(token: string): string {
|
|
@@ -252,9 +413,24 @@ export async function fetchOpenRouterMetadata(modelIds: string[]): Promise<Recor
|
|
|
252
413
|
return out;
|
|
253
414
|
}
|
|
254
415
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Returns true when the personal-subscription Claude path is fully usable:
|
|
418
|
+
* the setting is enabled AND both credentials (Claude OAuth token + gateway
|
|
419
|
+
* key) are present. Mirrors the gate in registerFlantProviders so we never
|
|
420
|
+
* generate `sub/` role assignments that cannot resolve to a real provider.
|
|
421
|
+
*/
|
|
422
|
+
export function isSubscriptionActive(settings?: FlantSettings): boolean {
|
|
423
|
+
const s = settings ?? loadFlantSettings();
|
|
424
|
+
return s.subscription && !!readClaudeOAuthToken() && !!readGatewayApiKey();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function modelSpec(modelId: string, subscriptionActive = false): string {
|
|
428
|
+
if (modelId.startsWith("claude-")) {
|
|
429
|
+
return subscriptionActive
|
|
430
|
+
? `${SUB_PROVIDER}/${SUB_MODEL_PREFIX}${modelId}`
|
|
431
|
+
: `pp-flant-anthropic/${modelId}`;
|
|
432
|
+
}
|
|
433
|
+
return `pp-flant-openai/${modelId}`;
|
|
258
434
|
}
|
|
259
435
|
|
|
260
436
|
function buildProviderModelConfig(
|
|
@@ -279,15 +455,24 @@ function buildProviderModelConfig(
|
|
|
279
455
|
};
|
|
280
456
|
}
|
|
281
457
|
|
|
458
|
+
export interface RegisterFlantOptions {
|
|
459
|
+
/** Whether to also register the personal-subscription provider. Defaults to loadFlantSettings().subscription. */
|
|
460
|
+
subscription?: boolean;
|
|
461
|
+
}
|
|
462
|
+
|
|
282
463
|
export function registerFlantProviders(
|
|
283
464
|
pi: ExtensionAPI,
|
|
284
465
|
models: string[],
|
|
285
466
|
metadata: Record<string, OpenRouterModelData>,
|
|
467
|
+
options: RegisterFlantOptions = {},
|
|
286
468
|
): void {
|
|
469
|
+
const log = getLogger();
|
|
287
470
|
const uniqueModels = [...new Set(models)];
|
|
288
471
|
const anthropicModels = uniqueModels.filter((m) => m.startsWith("claude-"));
|
|
289
472
|
const openaiModels = uniqueModels.filter((m) => !m.startsWith("claude-"));
|
|
290
473
|
|
|
474
|
+
unregisterFlantProviders(pi);
|
|
475
|
+
|
|
291
476
|
pi.registerProvider("pp-flant-anthropic", {
|
|
292
477
|
api: "anthropic-messages",
|
|
293
478
|
baseUrl: "https://llm-api.flant.ru",
|
|
@@ -301,18 +486,98 @@ export function registerFlantProviders(
|
|
|
301
486
|
apiKey: "$FLANT_API_KEY",
|
|
302
487
|
models: openaiModels.map((m) => buildProviderModelConfig(m, metadata)),
|
|
303
488
|
});
|
|
489
|
+
|
|
490
|
+
const availableSpecs = [
|
|
491
|
+
...anthropicModels.map((id) => `pp-flant-anthropic/${id}`),
|
|
492
|
+
...openaiModels.map((id) => `pp-flant-openai/${id}`),
|
|
493
|
+
];
|
|
494
|
+
|
|
495
|
+
const subscription = options.subscription ?? loadFlantSettings().subscription;
|
|
496
|
+
let subModels: string[] = [];
|
|
497
|
+
if (subscription) {
|
|
498
|
+
// Remember the models/metadata so refreshSubProvider can rebuild the
|
|
499
|
+
// provider with a fresh OAuth token on each turn (see below).
|
|
500
|
+
subProviderContext = { anthropicModels, metadata };
|
|
501
|
+
subModels = registerSubProvider(pi, anthropicModels, metadata);
|
|
502
|
+
availableSpecs.push(...subModels.map((id) => `${SUB_PROVIDER}/${id}`));
|
|
503
|
+
} else {
|
|
504
|
+
subProviderContext = null;
|
|
505
|
+
lastSubToken = null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
log.debug({ s: "flant", total: uniqueModels.length, anthropic: anthropicModels.length, openai: openaiModels.length, sub: subModels.length }, "registering flant providers");
|
|
509
|
+
|
|
510
|
+
updateRegistryFromAvailableModels(availableSpecs);
|
|
304
511
|
}
|
|
305
512
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
513
|
+
/**
|
|
514
|
+
* Register (or re-register) the personal-subscription Claude provider using the
|
|
515
|
+
* OAuth access token currently persisted in auth.json. Returns the list of
|
|
516
|
+
* `sub/`-prefixed model ids registered (empty when credentials are missing).
|
|
517
|
+
*
|
|
518
|
+
* The provider is registered with a literal `apiKey` (the resolved token), so
|
|
519
|
+
* the value is a static snapshot for the lifetime of the registration. Because
|
|
520
|
+
* the OAuth token expires within a few hours, refreshSubProvider must be called
|
|
521
|
+
* periodically (on each turn) to rebuild the provider with a fresh token;
|
|
522
|
+
* otherwise long-lived sessions send a stale token and the gateway returns 401.
|
|
523
|
+
*/
|
|
524
|
+
function registerSubProvider(
|
|
525
|
+
pi: ExtensionAPI,
|
|
526
|
+
anthropicModels: string[],
|
|
527
|
+
metadata: Record<string, OpenRouterModelData>,
|
|
528
|
+
): string[] {
|
|
529
|
+
const log = getLogger();
|
|
530
|
+
const oauthToken = readClaudeOAuthToken();
|
|
531
|
+
const gatewayKey = readGatewayApiKey();
|
|
532
|
+
if (!oauthToken || !gatewayKey) {
|
|
533
|
+
log.debug({ s: "flant", hasOAuth: !!oauthToken, hasGatewayKey: !!gatewayKey }, "subscription enabled but credentials missing; skipping sub provider");
|
|
534
|
+
lastSubToken = null;
|
|
535
|
+
return [];
|
|
314
536
|
}
|
|
315
|
-
|
|
537
|
+
pi.registerProvider(SUB_PROVIDER, {
|
|
538
|
+
name: "Flant (personal Claude subscription)",
|
|
539
|
+
api: "anthropic-messages",
|
|
540
|
+
baseUrl: "https://llm-api.flant.ru",
|
|
541
|
+
// The OAuth token (sk-ant-oat...) triggers pi-ai's Claude Code identity
|
|
542
|
+
// headers and is forwarded as `Authorization: Bearer ...`.
|
|
543
|
+
apiKey: oauthToken,
|
|
544
|
+
// Gateway key travels in a side header so it does not clobber the OAuth auth.
|
|
545
|
+
headers: { "x-litellm-api-key": `Bearer ${gatewayKey}` },
|
|
546
|
+
// Model id carries the `sub/` prefix the gateway expects, while pricing/
|
|
547
|
+
// metadata is looked up by the bare claude-* id.
|
|
548
|
+
models: anthropicModels.map((m) => {
|
|
549
|
+
const cfg = buildProviderModelConfig(m, metadata);
|
|
550
|
+
return { ...cfg, id: `${SUB_MODEL_PREFIX}${m}` };
|
|
551
|
+
}),
|
|
552
|
+
});
|
|
553
|
+
lastSubToken = oauthToken;
|
|
554
|
+
return anthropicModels.map((m) => `${SUB_MODEL_PREFIX}${m}`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Ensure the personal-subscription Claude provider is registered with a
|
|
559
|
+
* non-expired OAuth token. Refreshes the token (persisting it to auth.json when
|
|
560
|
+
* needed) and, when it changed since the last registration, re-registers the
|
|
561
|
+
* provider so subsequent LLM calls pick up the fresh token.
|
|
562
|
+
*
|
|
563
|
+
* Called on each turn for the root session. No-op when subscription routing is
|
|
564
|
+
* not active. Cheap when the token is unchanged (only a token read + compare).
|
|
565
|
+
*/
|
|
566
|
+
export async function refreshSubProvider(pi?: ExtensionAPI): Promise<void> {
|
|
567
|
+
const ctx = subProviderContext;
|
|
568
|
+
if (!ctx) return;
|
|
569
|
+
const api = pi ?? piRef;
|
|
570
|
+
if (!api) return;
|
|
571
|
+
|
|
572
|
+
await refreshClaudeOAuthToken();
|
|
573
|
+
const token = readClaudeOAuthToken();
|
|
574
|
+
// Only re-register when the token actually changed; registerProvider takes
|
|
575
|
+
// effect immediately, so re-registering every turn would be wasteful churn.
|
|
576
|
+
if (token && token === lastSubToken) return;
|
|
577
|
+
|
|
578
|
+
const log = getLogger();
|
|
579
|
+
registerSubProvider(api, ctx.anthropicModels, ctx.metadata);
|
|
580
|
+
log.debug({ s: "flant", changed: token !== lastSubToken }, "refreshed sub provider oauth token");
|
|
316
581
|
}
|
|
317
582
|
|
|
318
583
|
function pickLatest(models: string[]): string | null {
|
|
@@ -332,14 +597,32 @@ function pickCheapestFastModel(models: string[]): string | null {
|
|
|
332
597
|
return pickLatest(models.filter((m) => /^claude-haiku-/.test(m)));
|
|
333
598
|
}
|
|
334
599
|
|
|
335
|
-
function makeVariant(modelId: string | null, fallbackModelId: string): { enabled: boolean; model: string; thinking: string } {
|
|
336
|
-
if (!modelId) return { enabled: false, model: modelSpec(fallbackModelId), thinking: "high" };
|
|
337
|
-
return { enabled: true, model: modelSpec(modelId), thinking: "high" };
|
|
600
|
+
function makeVariant(modelId: string | null, fallbackModelId: string, sub = false): { enabled: boolean; model: string; thinking: string } {
|
|
601
|
+
if (!modelId) return { enabled: false, model: modelSpec(fallbackModelId, sub), thinking: "high" };
|
|
602
|
+
return { enabled: true, model: modelSpec(modelId, sub), thinking: "high" };
|
|
338
603
|
}
|
|
339
604
|
|
|
340
|
-
|
|
605
|
+
function makeVariantWithThinking(
|
|
606
|
+
modelId: string | null,
|
|
607
|
+
fallbackModelId: string,
|
|
608
|
+
thinking: string,
|
|
609
|
+
sub = false,
|
|
610
|
+
): { enabled: boolean; model: string; thinking: string } {
|
|
611
|
+
if (!modelId) return { enabled: false, model: modelSpec(fallbackModelId, sub), thinking };
|
|
612
|
+
return { enabled: true, model: modelSpec(modelId, sub), thinking };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function buildPresetGroup(
|
|
616
|
+
presets: Record<string, { enabled?: boolean; agents: Record<string, { enabled: boolean; model: string; thinking: string }> }>,
|
|
617
|
+
defaultPreset = "regular",
|
|
618
|
+
): { default: string; presets: typeof presets } {
|
|
619
|
+
return { default: defaultPreset, presets };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function generateFlantConfig(models: string[], subscriptionActive = false): Partial<PiPiConfig> {
|
|
341
623
|
const uniqueModels = [...new Set(models)];
|
|
342
624
|
if (uniqueModels.length === 0) return {};
|
|
625
|
+
const sub = subscriptionActive;
|
|
343
626
|
|
|
344
627
|
const latestOpus = pickLatest(uniqueModels.filter((m) => /^claude-opus-/.test(m)));
|
|
345
628
|
const latestClaude = pickLatest(uniqueModels.filter((m) => /^claude-/.test(m)));
|
|
@@ -358,36 +641,81 @@ export function generateFlantConfig(models: string[]): Partial<PiPiConfig> {
|
|
|
358
641
|
const fastModel = fastest ?? debugModel;
|
|
359
642
|
|
|
360
643
|
return {
|
|
361
|
-
mainModel: {
|
|
362
|
-
implement: { model: modelSpec(implementModel), thinking: "high" },
|
|
363
|
-
debug: { model: modelSpec(debugModel), thinking: "high" },
|
|
364
|
-
brainstorm: { model: modelSpec(brainstormModel), thinking: "high" },
|
|
365
|
-
review: { model: modelSpec(implementModel), thinking: "high" },
|
|
366
|
-
},
|
|
367
|
-
planners: {
|
|
368
|
-
opus: makeVariant(latestOpus, fallback),
|
|
369
|
-
gpt: makeVariant(latestGpt, fallback),
|
|
370
|
-
gemini: makeVariant(latestGeminiPro, fallback),
|
|
371
|
-
},
|
|
372
|
-
planReviewers: {
|
|
373
|
-
opus: makeVariant(latestOpus, fallback),
|
|
374
|
-
gpt: makeVariant(latestGpt, fallback),
|
|
375
|
-
gemini: makeVariant(latestGeminiPro, fallback),
|
|
376
|
-
},
|
|
377
|
-
codeReviewers: {
|
|
378
|
-
opus: makeVariant(latestOpus, fallback),
|
|
379
|
-
gpt: makeVariant(latestGpt, fallback),
|
|
380
|
-
gemini: makeVariant(latestGeminiPro, fallback),
|
|
381
|
-
},
|
|
382
|
-
brainstormReviewers: {
|
|
383
|
-
opus: makeVariant(latestOpus, fallback),
|
|
384
|
-
gpt: makeVariant(latestGpt, fallback),
|
|
385
|
-
gemini: makeVariant(latestGeminiPro, fallback),
|
|
386
|
-
},
|
|
387
644
|
agents: {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
645
|
+
orchestrators: {
|
|
646
|
+
implement: { model: modelSpec(implementModel, sub), thinking: "high" },
|
|
647
|
+
plan: { model: modelSpec(implementModel, sub), thinking: "high" },
|
|
648
|
+
debug: { model: modelSpec(debugModel, sub), thinking: "high" },
|
|
649
|
+
brainstorm: { model: modelSpec(brainstormModel, sub), thinking: "high" },
|
|
650
|
+
review: { model: modelSpec(implementModel, sub), thinking: "high" },
|
|
651
|
+
quick: { model: modelSpec(implementModel, sub), thinking: "high" },
|
|
652
|
+
},
|
|
653
|
+
subagents: {
|
|
654
|
+
simple: {
|
|
655
|
+
explore: { model: modelSpec(fastModel, sub), thinking: "low" },
|
|
656
|
+
librarian: { model: modelSpec(fastModel, sub), thinking: "medium" },
|
|
657
|
+
task: { model: modelSpec(taskModel, sub), thinking: "medium" },
|
|
658
|
+
},
|
|
659
|
+
presetGroups: {
|
|
660
|
+
planners: buildPresetGroup({
|
|
661
|
+
regular: {
|
|
662
|
+
agents: {
|
|
663
|
+
opus: makeVariant(latestOpus, fallback, sub),
|
|
664
|
+
gpt: makeVariant(latestGpt, fallback, sub),
|
|
665
|
+
gemini: makeVariant(latestGeminiPro, fallback, sub),
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
}),
|
|
669
|
+
planReviewers: buildPresetGroup({
|
|
670
|
+
regular: {
|
|
671
|
+
agents: {
|
|
672
|
+
opus: makeVariant(latestOpus, fallback, sub),
|
|
673
|
+
gpt: makeVariant(latestGpt, fallback, sub),
|
|
674
|
+
gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
deep: {
|
|
678
|
+
agents: {
|
|
679
|
+
opus: makeVariantWithThinking(latestOpus, fallback, "xhigh", sub),
|
|
680
|
+
gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh", sub),
|
|
681
|
+
gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
}),
|
|
685
|
+
codeReviewers: buildPresetGroup({
|
|
686
|
+
regular: {
|
|
687
|
+
agents: {
|
|
688
|
+
opus: makeVariant(latestOpus, fallback, sub),
|
|
689
|
+
gpt: makeVariant(latestGpt, fallback, sub),
|
|
690
|
+
gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
deep: {
|
|
694
|
+
agents: {
|
|
695
|
+
opus: makeVariantWithThinking(latestOpus, fallback, "xhigh", sub),
|
|
696
|
+
gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh", sub),
|
|
697
|
+
gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
}),
|
|
701
|
+
brainstormReviewers: buildPresetGroup({
|
|
702
|
+
regular: {
|
|
703
|
+
agents: {
|
|
704
|
+
opus: makeVariant(latestOpus, fallback, sub),
|
|
705
|
+
gpt: makeVariant(latestGpt, fallback, sub),
|
|
706
|
+
gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
deep: {
|
|
710
|
+
agents: {
|
|
711
|
+
opus: makeVariantWithThinking(latestOpus, fallback, "xhigh", sub),
|
|
712
|
+
gpt: makeVariantWithThinking(latestGpt, fallback, "xhigh", sub),
|
|
713
|
+
gemini: makeVariantWithThinking(latestGeminiPro, fallback, "xhigh", sub),
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
}),
|
|
717
|
+
},
|
|
718
|
+
},
|
|
391
719
|
},
|
|
392
720
|
};
|
|
393
721
|
}
|
|
@@ -412,6 +740,12 @@ export async function updateFlantInfra(
|
|
|
412
740
|
setPI(pi);
|
|
413
741
|
const settings = loadFlantSettings();
|
|
414
742
|
|
|
743
|
+
// Refresh the personal-subscription Claude OAuth token before (re)registering
|
|
744
|
+
// providers so the sub provider is built with a valid, non-expired token.
|
|
745
|
+
if (settings.subscription) {
|
|
746
|
+
await refreshClaudeOAuthToken();
|
|
747
|
+
}
|
|
748
|
+
|
|
415
749
|
let models = isCacheValid(settings) ? settings.cachedFlantModels : null;
|
|
416
750
|
let metadata = isCacheValid(settings) ? settings.cachedOpenRouterData : null;
|
|
417
751
|
let refreshed = false;
|
|
@@ -455,7 +789,7 @@ export async function updateFlantInfra(
|
|
|
455
789
|
|
|
456
790
|
try {
|
|
457
791
|
registerFlantProviders(pi, models, metadata);
|
|
458
|
-
generatedFlantConfig = generateFlantConfig(models);
|
|
792
|
+
generatedFlantConfig = generateFlantConfig(models, isSubscriptionActive(settings));
|
|
459
793
|
if (!refreshed && settings.cachedFlantModels && settings.cachedOpenRouterData && !settings.lastUpdated) {
|
|
460
794
|
settings.lastUpdated = new Date().toISOString();
|
|
461
795
|
saveFlantSettings(settings);
|
|
@@ -469,13 +803,15 @@ export async function updateFlantInfra(
|
|
|
469
803
|
export function initFlantSync(pi: ExtensionAPI): void {
|
|
470
804
|
setPI(pi);
|
|
471
805
|
const settings = loadFlantSettings();
|
|
806
|
+
const log = getLogger();
|
|
472
807
|
if (!settings.enabled) {
|
|
808
|
+
log.debug({ s: "flant" }, "flant disabled");
|
|
473
809
|
generatedFlantConfig = null;
|
|
474
810
|
return;
|
|
475
811
|
}
|
|
476
812
|
if (settings.cachedFlantModels && settings.cachedOpenRouterData) {
|
|
477
813
|
registerFlantProviders(pi, settings.cachedFlantModels, settings.cachedOpenRouterData);
|
|
478
|
-
generatedFlantConfig = generateFlantConfig(settings.cachedFlantModels);
|
|
814
|
+
generatedFlantConfig = generateFlantConfig(settings.cachedFlantModels, isSubscriptionActive(settings));
|
|
479
815
|
}
|
|
480
816
|
}
|
|
481
817
|
|
|
@@ -486,6 +822,11 @@ export async function initFlantOnStartup(pi: ExtensionAPI): Promise<void> {
|
|
|
486
822
|
generatedFlantConfig = null;
|
|
487
823
|
return;
|
|
488
824
|
}
|
|
489
|
-
if (!settings.autoUpdate)
|
|
825
|
+
if (!settings.autoUpdate) {
|
|
826
|
+
// updateFlantInfra (which refreshes the token) is skipped when auto-update
|
|
827
|
+
// is off, but any registered sub provider still needs a fresh token.
|
|
828
|
+
if (settings.subscription) await refreshClaudeOAuthToken();
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
490
831
|
await updateFlantInfra(pi);
|
|
491
832
|
}
|
|
@@ -11,15 +11,22 @@ import { validatePlan, validateArtifact } from "./validate-artifacts.js";
|
|
|
11
11
|
import { initFlantSync } from "./flant-infra.js";
|
|
12
12
|
|
|
13
13
|
const ORCHESTRATOR_KEY = Symbol.for("pi-pi:orchestrator-initialized");
|
|
14
|
+
const ORCHESTRATOR_CWD_KEY = Symbol.for("pi-pi:orchestrator-cwd");
|
|
15
|
+
// Shared with 3p/pi-subagents/src/agent-runner.ts: the value is a { depth: number }
|
|
16
|
+
// marking that this process runs as a subagent. The orchestrator only reads it for
|
|
17
|
+
// truthiness ("am I a subagent?"); agent-runner uses depth for nesting.
|
|
14
18
|
export const SUBAGENT_SESSION_KEY = Symbol.for("pi-pi:subagent-session");
|
|
15
19
|
|
|
16
20
|
export default function (pi: ExtensionAPI) {
|
|
17
21
|
if ((globalThis as any)[ORCHESTRATOR_KEY]) {
|
|
18
|
-
(globalThis as any)[SUBAGENT_SESSION_KEY]
|
|
22
|
+
if (!(globalThis as any)[SUBAGENT_SESSION_KEY]) {
|
|
23
|
+
(globalThis as any)[SUBAGENT_SESSION_KEY] = { depth: 1 };
|
|
24
|
+
}
|
|
19
25
|
registerSubagentTools(pi);
|
|
20
26
|
return;
|
|
21
27
|
}
|
|
22
28
|
(globalThis as any)[ORCHESTRATOR_KEY] = true;
|
|
29
|
+
(globalThis as any)[ORCHESTRATOR_CWD_KEY] = process.cwd();
|
|
23
30
|
|
|
24
31
|
initFlantSync(pi);
|
|
25
32
|
|
|
@@ -29,7 +36,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
function registerSubagentTools(pi: ExtensionAPI): void {
|
|
32
|
-
|
|
39
|
+
// Subagents run in-process; bind cbm/ast-search and plan validation to the
|
|
40
|
+
// orchestrator's project root (seeded to process.cwd() at init, then refreshed
|
|
41
|
+
// to ctx.cwd on session_start) rather than a raw process.cwd() captured here,
|
|
42
|
+
// which is the launch dir and wrong for worktree-isolated tasks.
|
|
43
|
+
const cwd = (globalThis as any)[ORCHESTRATOR_CWD_KEY] ?? process.cwd();
|
|
33
44
|
registerCbmTools(pi, cwd);
|
|
34
45
|
registerExaTools(pi);
|
|
35
46
|
registerAstSearchTool(pi, cwd);
|