@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.
- package/3p/pi-ask-user/index.ts +1 -1
- package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
- package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
- package/3p/pi-subagents/src/agent-manager.ts +34 -1
- package/3p/pi-subagents/src/agent-runner.ts +66 -33
- package/3p/pi-subagents/src/index.ts +3 -38
- package/3p/pi-subagents/src/types.ts +4 -0
- package/extensions/orchestrator/agents/advisor.ts +35 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/constraints.test.ts +44 -0
- package/extensions/orchestrator/agents/constraints.ts +3 -0
- package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/planner.ts +9 -8
- package/extensions/orchestrator/agents/prompts.test.ts +120 -0
- package/extensions/orchestrator/agents/reviewer.ts +48 -0
- package/extensions/orchestrator/agents/task.ts +6 -20
- package/extensions/orchestrator/agents/tool-routing.ts +23 -1
- package/extensions/orchestrator/command-handlers.ts +1 -1
- package/extensions/orchestrator/config.ts +8 -4
- package/extensions/orchestrator/context.test.ts +54 -0
- package/extensions/orchestrator/context.ts +65 -2
- package/extensions/orchestrator/custom-footer.ts +5 -2
- package/extensions/orchestrator/doctor.test.ts +3 -1
- package/extensions/orchestrator/doctor.ts +40 -2
- package/extensions/orchestrator/event-handlers.test.ts +97 -1
- package/extensions/orchestrator/event-handlers.ts +222 -48
- package/extensions/orchestrator/flant-infra.test.ts +312 -0
- package/extensions/orchestrator/flant-infra.ts +407 -44
- package/extensions/orchestrator/index.ts +1 -1
- package/extensions/orchestrator/integration.test.ts +312 -18
- package/extensions/orchestrator/messages.test.ts +30 -0
- package/extensions/orchestrator/messages.ts +6 -0
- package/extensions/orchestrator/model-registry.test.ts +124 -13
- package/extensions/orchestrator/model-registry.ts +91 -33
- package/extensions/orchestrator/orchestrator.test.ts +113 -0
- package/extensions/orchestrator/orchestrator.ts +163 -3
- package/extensions/orchestrator/phases/brainstorm.ts +9 -20
- package/extensions/orchestrator/phases/implementation.test.ts +11 -0
- package/extensions/orchestrator/phases/implementation.ts +4 -6
- package/extensions/orchestrator/phases/planning.test.ts +16 -0
- package/extensions/orchestrator/phases/planning.ts +11 -4
- package/extensions/orchestrator/phases/review-task.ts +1 -4
- package/extensions/orchestrator/phases/review.test.ts +62 -0
- package/extensions/orchestrator/phases/review.ts +58 -15
- package/extensions/orchestrator/plannotator.ts +9 -6
- package/extensions/orchestrator/pp-menu.test.ts +74 -1
- package/extensions/orchestrator/pp-menu.ts +366 -94
- package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
- package/extensions/orchestrator/pp-state-tools.ts +249 -0
- package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
- package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
- package/extensions/orchestrator/state.ts +41 -20
- package/extensions/orchestrator/test-helpers.ts +18 -3
- package/extensions/orchestrator/usage-tracker.test.ts +131 -3
- package/extensions/orchestrator/usage-tracker.ts +78 -11
- package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
- package/extensions/orchestrator/validate-artifacts.ts +2 -2
- 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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
};
|