@centerforagenticai/pi-multi-account 0.1.4 → 0.1.6
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/README.md +17 -3
- package/package.json +5 -5
- package/packages/pi-anthropic-oauth/package.json +2 -2
- package/packages/pi-anthropic-oauth/src/stream.ts +26 -8
- package/packages/pi-anthropic-oauth/src/transport-activity.ts +59 -0
- package/packages/pi-antigravity/package.json +2 -2
- package/packages/pi-antigravity/src/models/discovery.ts +2 -1
- package/packages/pi-antigravity/src/models/grouping.ts +12 -10
- package/packages/pi-antigravity/src/models/models.ts +11 -4
- package/src/account-group-failure.ts +149 -0
- package/src/account-group-members.ts +139 -0
- package/src/anthropic-adaptive-stream.ts +18 -9
- package/src/anthropic-alias-stream.ts +13 -68
- package/src/codex-adapter.ts +78 -50
- package/src/commands.ts +3 -3
- package/src/config.ts +197 -2
- package/src/diagnostic-store.ts +17 -15
- package/src/diagnostics.ts +97 -35
- package/src/host-final-stop-message.ts +20 -118
- package/src/index.ts +452 -87
- package/src/logical-dispatch.ts +12 -13
- package/src/logical-provider.ts +1282 -355
- package/src/model-fallback-policy.ts +384 -0
- package/src/models-declaration.ts +15 -8
- package/src/public-assistant-projection.ts +163 -0
- package/src/recovery-engine.ts +648 -110
- package/src/recovery-plan.ts +7 -1
- package/src/recovery-send-evidence.ts +29 -0
- package/src/refusal-advice.ts +139 -0
- package/src/routing.ts +4 -8
- package/src/runtime-state.ts +7 -0
- package/src/shared-usage.ts +21 -4
- package/src/upstream-anthropic.ts +12 -4
- package/src/upstream-antigravity.ts +2 -38
- package/src/usage-fetch.ts +42 -59
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Concurrent Anthropic, OpenAI Codex, and Google Antigravity OAuth accounts for Pi
|
|
|
6
6
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
9
|
-
When a managed account reaches a limit or loses authorization, `pi-multi-account` waits for Pi's own retries to settle, selects an eligible route, and sends one fixed continuation. It never replays the failed provider request.
|
|
9
|
+
When a managed account reaches a limit or loses authorization, `pi-multi-account` waits for Pi's own retries to settle, selects an eligible route, and sends one fixed continuation. It never replays the failed provider request, with one bounded exception: a `unified` logical call that fails before showing any output may move once to another account serving the same model.
|
|
10
10
|
|
|
11
11
|
- Registers numbered OAuth account aliases without copying credentials out of Pi's `AuthStorage`.
|
|
12
12
|
- Routes within a provider family first, then through explicit owning-vendor or cross-family policy.
|
|
@@ -54,7 +54,7 @@ The extension reads one machine-global file and no project-local config:
|
|
|
54
54
|
$PI_CODING_AGENT_DIR/pi-multi-account/config.json
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
When `PI_CODING_AGENT_DIR` is unset, the root is `~/.pi/agent`. Missing configuration uses conservative defaults. Same-family failover and usage fetches ship on; cross-family routing and OpenRouter ship off. Malformed or unknown fields
|
|
57
|
+
When `PI_CODING_AGENT_DIR` is unset, the root is `~/.pi/agent`. Missing configuration uses conservative defaults. Same-family failover and usage fetches ship on; cross-family routing and OpenRouter ship off. Malformed or unknown fields keep discovery, `/login`, and repair commands available, but block requests and automatic routing until valid config is loaded. Correct the global config and run `/multi-account reload` or `/reload`. A failed reload retains the last valid policy.
|
|
58
58
|
|
|
59
59
|
| Key | Default | Purpose |
|
|
60
60
|
| --- | --- | --- |
|
|
@@ -65,13 +65,27 @@ When `PI_CODING_AGENT_DIR` is unset, the root is `~/.pi/agent`. Missing configur
|
|
|
65
65
|
| `accountGroups` and defaults | `{}` | Restrict a session, exact cwd, or global fallback to named account allow-lists. |
|
|
66
66
|
| `usageFetchEnabled` | all managed families `true` | Enable fail-soft provider usage fetches. |
|
|
67
67
|
|
|
68
|
+
Account groups may list exact configured Pi provider IDs, including built-in API
|
|
69
|
+
and custom providers. Unknown, removed, or unavailable members stay inactive;
|
|
70
|
+
other listed usable members can serve, with no fallback outside the group.
|
|
71
|
+
Membership does not transfer credential or routing ownership to this extension.
|
|
72
|
+
Under an active group, select physical models: host virtual models can choose
|
|
73
|
+
other providers and are unsupported. Physical models on mixed providers still work.
|
|
74
|
+
Unrestricted virtual selection keeps the host's behavior.
|
|
75
|
+
When a group prevents `unified` selection, the turn error names the group, its
|
|
76
|
+
original source, and a bounded cause. It reports outside-group availability
|
|
77
|
+
without naming excluded accounts. This guidance does not authorize a send or
|
|
78
|
+
prove that request credentials have resolved.
|
|
79
|
+
Listing `openrouter` does not enable it: the current named-group metered block
|
|
80
|
+
remains in force.
|
|
81
|
+
|
|
68
82
|
The full schema, defaults, examples, timing controls, labels, and rate-history fields are in [Configuration reference](docs/configuration.md).
|
|
69
83
|
|
|
70
84
|
## When it runs
|
|
71
85
|
|
|
72
86
|
The extension discovers accounts and registers aliases at `session_start`. It checks account-group and OpenRouter safety before a run, tracks model selection and provider responses, repairs failed assistant history at `message_end`, and releases resources at `session_shutdown`.
|
|
73
87
|
|
|
74
|
-
Reactive routing starts only at `agent_settled`, after Pi's provider retry and compaction loop. A classified provider failure can schedule one fixed `deliverAs: "followUp"` continuation. It does not replay the prompt, provider request, or uncertain tool work. More detail is in [Routing and recovery](docs/routing.md).
|
|
88
|
+
Reactive routing starts only at `agent_settled`, after Pi's provider retry and compaction loop. A classified provider failure can schedule one fixed `deliverAs: "followUp"` continuation. It does not replay the prompt, provider request, or uncertain tool work. A `unified` call is different: it recovers inside the call, before any output, with at most two physical sends, and never continues after settlement. `recoveryStallTimeoutMs` bounds the wait for a physical attempt's response to start. More detail is in [Routing and recovery](docs/routing.md#in-call-recovery).
|
|
75
89
|
|
|
76
90
|
## Develop
|
|
77
91
|
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.6",
|
|
3
3
|
"description": "Global Anthropic and OpenAI Codex multi-account OAuth routing for Pi.",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
@@ -59,11 +59,11 @@
|
|
|
59
59
|
"undici": "^8.10.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@earendil-works/pi-ai": "0.
|
|
63
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
64
|
-
"@earendil-works/pi-tui": "0.
|
|
62
|
+
"@earendil-works/pi-ai": "^1.0.2",
|
|
63
|
+
"@earendil-works/pi-coding-agent": "^1.0.2",
|
|
64
|
+
"@earendil-works/pi-tui": "^1.0.2",
|
|
65
65
|
"@types/node": "^24.0.0",
|
|
66
|
-
"typebox": "1.3.
|
|
66
|
+
"typebox": "1.3.34",
|
|
67
67
|
"typescript": "^5.9.0",
|
|
68
68
|
"vitest": "^4.1.11"
|
|
69
69
|
},
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"@earendil-works/pi-coding-agent": ">=0.80.8"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@earendil-works/pi-ai": "0.84.4",
|
|
51
|
-
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
50
|
+
"@earendil-works/pi-ai": ">=0.84.4",
|
|
51
|
+
"@earendil-works/pi-coding-agent": ">=0.84.4",
|
|
52
52
|
"typescript": "^5.9.3"
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type Model,
|
|
11
11
|
type SimpleStreamOptions,
|
|
12
12
|
type StopReason,
|
|
13
|
+
type ToolCall,
|
|
13
14
|
} from "@earendil-works/pi-ai";
|
|
14
15
|
import { isClaudeOAuthAccessToken, USER_AGENT } from "./auth.js";
|
|
15
16
|
import {
|
|
@@ -20,6 +21,10 @@ import {
|
|
|
20
21
|
} from "./convert.js";
|
|
21
22
|
import { projectContext } from "./context.js";
|
|
22
23
|
import { buildAnthropicSystemPrompt } from "./prompt.js";
|
|
24
|
+
import {
|
|
25
|
+
createTransportActivityFetch,
|
|
26
|
+
transportActivityListener,
|
|
27
|
+
} from "./transport-activity.js";
|
|
23
28
|
|
|
24
29
|
const REQUIRED_BETAS = [
|
|
25
30
|
"claude-code-20250219",
|
|
@@ -132,12 +137,19 @@ export function streamAnthropicOAuth(
|
|
|
132
137
|
|
|
133
138
|
if (isOAuth) defaultHeaders.authorization = `Bearer ${apiKey}`;
|
|
134
139
|
|
|
140
|
+
// A caller that supplies onTransportActivity hears about every response
|
|
141
|
+
// body chunk, including the pings the SDK drops; otherwise the SDK keeps
|
|
142
|
+
// its default fetch.
|
|
143
|
+
const onTransportActivity = transportActivityListener(options);
|
|
135
144
|
const client = new Anthropic({
|
|
136
145
|
baseURL: model.baseUrl,
|
|
137
146
|
apiKey: isOAuth ? null : apiKey,
|
|
138
147
|
authToken: isOAuth ? apiKey : null,
|
|
139
148
|
defaultHeaders,
|
|
140
149
|
dangerouslyAllowBrowser: true,
|
|
150
|
+
...(onTransportActivity === undefined
|
|
151
|
+
? {}
|
|
152
|
+
: { fetch: createTransportActivityFetch(onTransportActivity) }),
|
|
141
153
|
});
|
|
142
154
|
|
|
143
155
|
const maxTokens =
|
|
@@ -221,10 +233,18 @@ export function streamAnthropicOAuth(
|
|
|
221
233
|
// which throws under fine-grained-tool-streaming (input may be invalid
|
|
222
234
|
// mid-flight) and aborts the turn. The raw stream yields the same
|
|
223
235
|
// RawMessageStreamEvents; tool args are already parsed leniently below.
|
|
236
|
+
// A finite non-negative integer request-local maxRetries bounds the SDK's
|
|
237
|
+
// own retries (0 sends exactly once); any other value keeps the SDK default.
|
|
238
|
+
const maxRetries = options?.maxRetries;
|
|
224
239
|
const { data: anthropicStream, response: httpResponse } =
|
|
225
240
|
await client.messages
|
|
226
241
|
.create(params, {
|
|
227
242
|
signal: options?.signal,
|
|
243
|
+
...(Number.isFinite(maxRetries) &&
|
|
244
|
+
Number.isInteger(maxRetries) &&
|
|
245
|
+
(maxRetries ?? -1) >= 0
|
|
246
|
+
? { maxRetries }
|
|
247
|
+
: {}),
|
|
228
248
|
})
|
|
229
249
|
.withResponse();
|
|
230
250
|
|
|
@@ -363,10 +383,9 @@ export function streamAnthropicOAuth(
|
|
|
363
383
|
) {
|
|
364
384
|
block.partialJson += event.delta.partial_json;
|
|
365
385
|
try {
|
|
366
|
-
block.arguments = JSON.parse(
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
>;
|
|
386
|
+
block.arguments = JSON.parse(
|
|
387
|
+
block.partialJson,
|
|
388
|
+
) as ToolCall["arguments"];
|
|
370
389
|
} catch {}
|
|
371
390
|
stream.push({
|
|
372
391
|
type: "toolcall_delta",
|
|
@@ -402,10 +421,9 @@ export function streamAnthropicOAuth(
|
|
|
402
421
|
});
|
|
403
422
|
} else if (block.type === "toolCall") {
|
|
404
423
|
try {
|
|
405
|
-
block.arguments = JSON.parse(
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
>;
|
|
424
|
+
block.arguments = JSON.parse(
|
|
425
|
+
block.partialJson,
|
|
426
|
+
) as ToolCall["arguments"];
|
|
409
427
|
} catch {}
|
|
410
428
|
delete (block as { partialJson?: string }).partialJson;
|
|
411
429
|
stream.push({
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-level transport liveness for one streamed Anthropic request.
|
|
3
|
+
*
|
|
4
|
+
* The Anthropic SDK drops the server's `ping` events before they reach a
|
|
5
|
+
* stream consumer, so a model that thinks silently for minutes is
|
|
6
|
+
* indistinguishable from a dead connection at the event level. A caller that
|
|
7
|
+
* needs to tell them apart passes `onTransportActivity` in the request options;
|
|
8
|
+
* this wrapper then reports every response body chunk (pings included) without
|
|
9
|
+
* reading, buffering, or retaining its content.
|
|
10
|
+
*
|
|
11
|
+
* The callback receives no arguments. A throwing callback is contained so it
|
|
12
|
+
* can never break the response. Without the option the SDK keeps its default
|
|
13
|
+
* fetch and the request path is unchanged.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type TransportActivityListener = () => void;
|
|
17
|
+
|
|
18
|
+
export function transportActivityListener(
|
|
19
|
+
options: unknown,
|
|
20
|
+
): TransportActivityListener | undefined {
|
|
21
|
+
if (typeof options !== "object" || options === null) return undefined;
|
|
22
|
+
const listener = (options as { onTransportActivity?: unknown })
|
|
23
|
+
.onTransportActivity;
|
|
24
|
+
return typeof listener === "function"
|
|
25
|
+
? (listener as TransportActivityListener)
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createTransportActivityFetch(
|
|
30
|
+
onActivity: TransportActivityListener,
|
|
31
|
+
): typeof fetch {
|
|
32
|
+
// Read the global at construction, as the SDK's own default does.
|
|
33
|
+
const baseFetch = globalThis.fetch;
|
|
34
|
+
const notify = (): void => {
|
|
35
|
+
try {
|
|
36
|
+
onActivity();
|
|
37
|
+
} catch {
|
|
38
|
+
// Liveness reporting is best-effort and must not break the response.
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
return async (input, init) => {
|
|
42
|
+
const response = await baseFetch(input, init);
|
|
43
|
+
const body = response.body;
|
|
44
|
+
if (body === null) return response;
|
|
45
|
+
const observed = body.pipeThrough(
|
|
46
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
47
|
+
transform(chunk, controller) {
|
|
48
|
+
notify();
|
|
49
|
+
controller.enqueue(chunk);
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
return new Response(observed, {
|
|
54
|
+
status: response.status,
|
|
55
|
+
statusText: response.statusText,
|
|
56
|
+
headers: response.headers,
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -60,13 +60,13 @@
|
|
|
60
60
|
"@earendil-works/pi-coding-agent": ">=0.80.0"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@earendil-works/pi-ai": "
|
|
63
|
+
"@earendil-works/pi-ai": ">=0.84.2",
|
|
64
64
|
"@eslint/js": "^10.0.1",
|
|
65
65
|
"@types/node": "^26.1.1",
|
|
66
66
|
"eslint": "^10.7.0",
|
|
67
67
|
"eslint-config-prettier": "^10.1.8",
|
|
68
68
|
"globals": "^17.7.0",
|
|
69
|
-
"pi-ai-087": "npm:@earendil-works/pi-ai
|
|
69
|
+
"pi-ai-087": "npm:@earendil-works/pi-ai@>=0.87.1",
|
|
70
70
|
"prettier": "^3.9.5",
|
|
71
71
|
"typescript": "^6.0.3",
|
|
72
72
|
"typescript-eslint": "^8.64.0"
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
registerDiscoveredModelEnums,
|
|
20
20
|
restoreDynamicModelEnums,
|
|
21
21
|
snapshotDynamicModelEnums,
|
|
22
|
+
type AntigravityModelConfig,
|
|
22
23
|
} from "./models.js";
|
|
23
24
|
|
|
24
25
|
export const DEFAULT_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;
|
|
@@ -144,7 +145,7 @@ function apiKeyFromCredential(credential: Credential | undefined): string | unde
|
|
|
144
145
|
return undefined;
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
function toStoredModels(models:
|
|
148
|
+
function toStoredModels(models: AntigravityModelConfig[]): Model<Api>[] {
|
|
148
149
|
return models.map((model) => ({
|
|
149
150
|
...model,
|
|
150
151
|
api: ANTIGRAVITY_API,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AntigravityModelConfig } from "./models.js";
|
|
2
2
|
import { ThinkingEffort } from "../types/enums.js";
|
|
3
3
|
import type { AntigravityRouting, ModelInfoRaw } from "../types/types.js";
|
|
4
4
|
|
|
5
5
|
export type AntigravityCatalog = {
|
|
6
|
-
models:
|
|
6
|
+
models: AntigravityModelConfig[];
|
|
7
7
|
routing: Record<string, AntigravityRouting>;
|
|
8
8
|
};
|
|
9
9
|
|
|
@@ -108,7 +108,7 @@ export function buildAntigravityCatalog(
|
|
|
108
108
|
|
|
109
109
|
if (groups.size === 0) return fallback;
|
|
110
110
|
|
|
111
|
-
const models:
|
|
111
|
+
const models: AntigravityModelConfig[] = [];
|
|
112
112
|
const routing: Record<string, AntigravityRouting> = {};
|
|
113
113
|
|
|
114
114
|
for (const group of groups.values()) {
|
|
@@ -222,8 +222,8 @@ function displayFamily(displayName: string | undefined): string | undefined {
|
|
|
222
222
|
|
|
223
223
|
function synthesizeModel(
|
|
224
224
|
group: RuntimeGroup,
|
|
225
|
-
fallbackModels:
|
|
226
|
-
): { model:
|
|
225
|
+
fallbackModels: AntigravityModelConfig[],
|
|
226
|
+
): { model: AntigravityModelConfig; routing: AntigravityRouting } {
|
|
227
227
|
const template = familyTemplate(group.publicId, fallbackModels);
|
|
228
228
|
const advertisedLevels = advertisedThinkingLevels(group);
|
|
229
229
|
const routing = routingFromVariants(group.publicId, group.variants, group.unsuffixed);
|
|
@@ -322,8 +322,10 @@ function routingFromVariants(
|
|
|
322
322
|
};
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
function thinkingLevelMapFromLevels(
|
|
326
|
-
|
|
325
|
+
function thinkingLevelMapFromLevels(
|
|
326
|
+
levels: Set<string>,
|
|
327
|
+
): AntigravityModelConfig["thinkingLevelMap"] {
|
|
328
|
+
const map: NonNullable<AntigravityModelConfig["thinkingLevelMap"]> = {};
|
|
327
329
|
for (const level of PI_LEVELS) {
|
|
328
330
|
map[level] = levels.has(level) ? level : null;
|
|
329
331
|
}
|
|
@@ -333,8 +335,8 @@ function thinkingLevelMapFromLevels(levels: Set<string>): ProviderModelConfig["t
|
|
|
333
335
|
|
|
334
336
|
function familyTemplate(
|
|
335
337
|
publicId: string,
|
|
336
|
-
fallbackModels:
|
|
337
|
-
):
|
|
338
|
+
fallbackModels: AntigravityModelConfig[],
|
|
339
|
+
): AntigravityModelConfig | undefined {
|
|
338
340
|
if (/^gemini-.*-flash/i.test(publicId)) {
|
|
339
341
|
return fallbackModels.find((model) => /^gemini-.*-flash/i.test(model.id));
|
|
340
342
|
}
|
|
@@ -403,7 +405,7 @@ function parseGeminiVersion(id: string): number {
|
|
|
403
405
|
return Number(match[1]) * 1000 + Number(match[2] || 0);
|
|
404
406
|
}
|
|
405
407
|
|
|
406
|
-
function comparePublicModels(a:
|
|
408
|
+
function comparePublicModels(a: AntigravityModelConfig, b: AntigravityModelConfig): number {
|
|
407
409
|
const rankA = modelRank(a.id);
|
|
408
410
|
const rankB = modelRank(b.id);
|
|
409
411
|
if (rankA[0] !== rankB[0]) return rankA[0] - rankB[0];
|
|
@@ -3,6 +3,13 @@ import type { AntigravityRouting, ThinkingWire } from "../types/types.js";
|
|
|
3
3
|
import { ThinkingEffort } from "../types/enums.js";
|
|
4
4
|
import type { AntigravityCatalog } from "./grouping.js";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Chat-model registration shape. Pi 1.0 widened `ProviderModelConfig` to a union
|
|
8
|
+
* of chat, image, and classifier configs; Antigravity registers chat models only.
|
|
9
|
+
* On older Pi hosts the type is not a union and this resolves to it unchanged.
|
|
10
|
+
*/
|
|
11
|
+
export type AntigravityModelConfig = Extract<ProviderModelConfig, { reasoning: boolean }>;
|
|
12
|
+
|
|
6
13
|
export const PROVIDER_ID = "antigravity";
|
|
7
14
|
export const PROVIDER_NAME = "Antigravity";
|
|
8
15
|
|
|
@@ -210,10 +217,10 @@ const thinkingLevelMaps = {
|
|
|
210
217
|
xhigh: null,
|
|
211
218
|
max: null,
|
|
212
219
|
},
|
|
213
|
-
} satisfies Record<string,
|
|
220
|
+
} satisfies Record<string, AntigravityModelConfig["thinkingLevelMap"]>;
|
|
214
221
|
|
|
215
222
|
/** Same set as `agy models`, collapsed to public Pi model IDs. */
|
|
216
|
-
export const ANTIGRAVITY_MODELS:
|
|
223
|
+
export const ANTIGRAVITY_MODELS: AntigravityModelConfig[] = [
|
|
217
224
|
{
|
|
218
225
|
id: "gemini-3.8-flash",
|
|
219
226
|
name: "Gemini 3.8 Flash (Antigravity)",
|
|
@@ -296,10 +303,10 @@ export const ANTIGRAVITY_MODELS: ProviderModelConfig[] = [
|
|
|
296
303
|
},
|
|
297
304
|
];
|
|
298
305
|
|
|
299
|
-
let currentModels:
|
|
306
|
+
let currentModels: AntigravityModelConfig[] = ANTIGRAVITY_MODELS;
|
|
300
307
|
let currentRouting: Record<string, AntigravityRouting> = { ...ANTIGRAVITY_ROUTING };
|
|
301
308
|
|
|
302
|
-
export function getCurrentAntigravityModels():
|
|
309
|
+
export function getCurrentAntigravityModels(): AntigravityModelConfig[] {
|
|
303
310
|
return currentModels;
|
|
304
311
|
}
|
|
305
312
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { isAccountGroupMemberReference, type AccountGroupMemberAvailability } from "./account-group-members.js";
|
|
2
|
+
import { MANAGED_FAMILIES } from "./config.js";
|
|
3
|
+
import type { EffectiveAccountGroupResolution } from "./group-policy.js";
|
|
4
|
+
|
|
5
|
+
export type AccountGroupFailureBlockReason =
|
|
6
|
+
| "delegate-origin-unresolved" | "context-unavailable"
|
|
7
|
+
| "driver-origin-unresolved" | "delegate-resolution-unavailable"
|
|
8
|
+
| "driver-resolution-unavailable" | "config-invalid" | "resolution-failed";
|
|
9
|
+
const BLOCK_REASONS: readonly AccountGroupFailureBlockReason[] = [
|
|
10
|
+
"delegate-origin-unresolved", "context-unavailable", "driver-origin-unresolved",
|
|
11
|
+
"delegate-resolution-unavailable", "driver-resolution-unavailable", "config-invalid", "resolution-failed",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/** Already-authorized policy only; never resolve a cwd or a delegate identity here. */
|
|
15
|
+
export type AccountGroupFailurePolicy =
|
|
16
|
+
| { readonly kind: "resolved"; readonly resolution: EffectiveAccountGroupResolution; readonly members: readonly string[] }
|
|
17
|
+
| { readonly kind: "blocked"; readonly reason: AccountGroupFailureBlockReason };
|
|
18
|
+
|
|
19
|
+
/** Copied availability facts, not credentials, status metadata or raw provider errors. */
|
|
20
|
+
export interface AccountGroupFailureCandidate extends Pick<AccountGroupMemberAvailability, "providerId" | "eligible"> {
|
|
21
|
+
readonly servesModel: boolean;
|
|
22
|
+
readonly authenticated?: boolean;
|
|
23
|
+
readonly exhausted?: boolean;
|
|
24
|
+
readonly coolingDown?: boolean;
|
|
25
|
+
readonly reason?: AccountGroupMemberAvailability["reason"];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AccountGroupFailureReason =
|
|
29
|
+
| AccountGroupFailureBlockReason | "policy-unavailable"
|
|
30
|
+
| "no-serving-members" | "no-managed-serving-members"
|
|
31
|
+
| "authentication-unavailable" | "cooldown" | "exhausted"
|
|
32
|
+
| "authorization-snapshot-unavailable" | "availability-unresolved";
|
|
33
|
+
|
|
34
|
+
export interface AccountGroupFailureView {
|
|
35
|
+
readonly reason: AccountGroupFailureReason;
|
|
36
|
+
readonly source?: EffectiveAccountGroupResolution["source"];
|
|
37
|
+
readonly groupId?: string;
|
|
38
|
+
readonly outsideEligible: boolean;
|
|
39
|
+
readonly message: string;
|
|
40
|
+
readonly help: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AccountGroupFailureInput {
|
|
44
|
+
readonly policy: AccountGroupFailurePolicy;
|
|
45
|
+
readonly modelId: string;
|
|
46
|
+
readonly accountLimit: number;
|
|
47
|
+
readonly candidates: readonly AccountGroupFailureCandidate[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Pinned pi-ai retry.js/overflow.js act on error prose, not a group error code.
|
|
51
|
+
// Plain labels cannot contain the space-bearing patterns. Exclude the remaining
|
|
52
|
+
// compact patterns too; never manufacture an AssistantMessage to validate text.
|
|
53
|
+
const HOST_TRIGGER_LABEL = /overloaded|rate.?limit|429|500|502|503|504|524|service.?unavailable|server.?error|internal.?error|provider.?returned.?error|network.?error|connection.?(?:error|refused|lost)|getaddrinfo|ENOTFOUND|EAI_AGAIN|upstream.?connect|timeout|terminated|websocket.?(?:closed|error)|ResourceExhausted|request_too_large|model_context_window_exceeded|context[_]length[_]exceeded/i;
|
|
54
|
+
|
|
55
|
+
function safeLabel(value: string, maxLength: number, fallback: string): string {
|
|
56
|
+
return typeof value === "string" && value.length <= maxLength &&
|
|
57
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && !HOST_TRIGGER_LABEL.test(value)
|
|
58
|
+
? value : fallback;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function managedProvider(providerId: string): boolean {
|
|
62
|
+
return MANAGED_FAMILIES.some((family) => providerId === family || providerId.startsWith(`${family}-account-`));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const REASON_TEXT: Readonly<Record<AccountGroupFailureReason, readonly [string, string]>> = {
|
|
66
|
+
"delegate-origin-unresolved": ["delegate-origin-unresolved: the delegate parent could not be verified", "Start a worker from a verified parent session."],
|
|
67
|
+
"context-unavailable": ["context-unavailable: the session policy context is unavailable", "Wait for the session context, then try again."],
|
|
68
|
+
"driver-origin-unresolved": ["driver-origin-unresolved: the durable delegate parent could not be verified", "Start a child from a verified parent session."],
|
|
69
|
+
"delegate-resolution-unavailable": ["delegate-resolution-unavailable: the parent policy is unavailable", "Restore the verified parent session policy before starting a worker."],
|
|
70
|
+
"driver-resolution-unavailable": ["driver-resolution-unavailable: the durable parent policy is unavailable", "Restore the verified parent session policy before starting a child."],
|
|
71
|
+
"config-invalid": ["config-invalid: the account policy configuration is invalid", "Correct the global configuration and run /multi-account reload."],
|
|
72
|
+
"resolution-failed": ["resolution-failed: the session policy could not be read safely", "Repair the session account-group store before trying again."],
|
|
73
|
+
"policy-unavailable": ["the authorized account-group policy is unavailable", "Check /multi-account group status before trying again."],
|
|
74
|
+
"no-serving-members": ["no listed member serves this model", "Select a model served by a listed member or choose another group."],
|
|
75
|
+
"no-managed-serving-members": ["no eligible managed member serves this unified model", "Select a listed physical model or choose a group with managed members."],
|
|
76
|
+
"authentication-unavailable": ["serving managed members need usable authorization; sign in", "Sign in with /login, then run /multi-account rediscover, or choose another group."],
|
|
77
|
+
"cooldown": ["serving managed members are cooling down", "Wait for recovery or choose another group."],
|
|
78
|
+
"exhausted": ["serving managed members have exhausted their available usage", "Wait for usage to recover or choose another group."],
|
|
79
|
+
"authorization-snapshot-unavailable": ["the authorization snapshot is unavailable", "Check provider availability, then try again or choose another group."],
|
|
80
|
+
"availability-unresolved": ["no eligible managed member is available; the cause is not established", "Check /multi-account group status, sign in if needed, or choose another group."],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function failureView(
|
|
84
|
+
reason: AccountGroupFailureReason,
|
|
85
|
+
modelId: string,
|
|
86
|
+
outsideEligible: boolean,
|
|
87
|
+
resolution?: Exclude<EffectiveAccountGroupResolution, { readonly source: "unrestricted" }>,
|
|
88
|
+
): AccountGroupFailureView {
|
|
89
|
+
const [text, help] = REASON_TEXT[reason];
|
|
90
|
+
const model = safeLabel(modelId, 128, "selected-model");
|
|
91
|
+
const groupId = resolution === undefined ? undefined : safeLabel(resolution.groupId, 64, "selected-group");
|
|
92
|
+
const source = resolution?.source;
|
|
93
|
+
const heading = groupId === undefined ? "Account group scope" : `Account group "${groupId}" (${source})`;
|
|
94
|
+
const message = `${heading} cannot serve ${model}: ${text}. ${help}${outsideEligible ? " Eligible accounts exist outside this group; they remain excluded." : ""}`;
|
|
95
|
+
// Only named output facts; never return caller policy, candidate objects or prose.
|
|
96
|
+
if (groupId === undefined || source === undefined) {
|
|
97
|
+
return Object.freeze({ reason, outsideEligible, message, help });
|
|
98
|
+
}
|
|
99
|
+
return Object.freeze({ reason, groupId, source, outsideEligible, message, help });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Format a failed unified selection from one call's already-authorized facts.
|
|
104
|
+
* This observes nothing, changes no policy and grants no dispatch permission.
|
|
105
|
+
* Undefined preserves the caller's existing unrestricted error byte-for-byte.
|
|
106
|
+
* Mixed or unobserved causes stay unresolved, never guessed as missing auth.
|
|
107
|
+
*/
|
|
108
|
+
export function formatAccountGroupFailure(input: AccountGroupFailureInput): AccountGroupFailureView | undefined {
|
|
109
|
+
const policy = input.policy;
|
|
110
|
+
if (policy.kind === "blocked") {
|
|
111
|
+
const reason = Object.hasOwn(REASON_TEXT, policy.reason) && BLOCK_REASONS.includes(policy.reason)
|
|
112
|
+
? policy.reason : "policy-unavailable";
|
|
113
|
+
return failureView(reason, input.modelId, false);
|
|
114
|
+
}
|
|
115
|
+
if (policy.kind !== "resolved") return failureView("policy-unavailable", input.modelId, false);
|
|
116
|
+
const resolution = policy.resolution;
|
|
117
|
+
if (resolution.source === "unrestricted") return undefined;
|
|
118
|
+
if (resolution.source !== "session-override" && resolution.source !== "cwd-default" && resolution.source !== "global-default") {
|
|
119
|
+
return failureView("policy-unavailable", input.modelId, false);
|
|
120
|
+
}
|
|
121
|
+
if (!Number.isInteger(input.accountLimit) || input.accountLimit < 1 || input.accountLimit > 32) {
|
|
122
|
+
return failureView("policy-unavailable", input.modelId, false, resolution);
|
|
123
|
+
}
|
|
124
|
+
const validId = (id: string): boolean => isAccountGroupMemberReference(id, input.accountLimit, MANAGED_FAMILIES);
|
|
125
|
+
const members = new Set(policy.members.filter(validId));
|
|
126
|
+
const candidates = input.candidates.filter((row) => validId(row.providerId)).map((row) => ({
|
|
127
|
+
providerId: row.providerId,
|
|
128
|
+
servesModel: row.servesModel === true,
|
|
129
|
+
eligible: row.eligible === true,
|
|
130
|
+
authenticated: row.authenticated === false ? false : undefined,
|
|
131
|
+
exhausted: row.exhausted === true,
|
|
132
|
+
coolingDown: row.coolingDown === true,
|
|
133
|
+
reason: row.reason === "authentication unavailable" || row.reason === "authorization snapshot unavailable" ? row.reason : undefined,
|
|
134
|
+
}));
|
|
135
|
+
const inside = candidates.filter((row) => members.has(row.providerId));
|
|
136
|
+
const serving = inside.filter((row) => row.servesModel === true);
|
|
137
|
+
const managed = serving.filter((row) => managedProvider(row.providerId));
|
|
138
|
+
if (managed.some((row) => row.eligible === true)) return undefined;
|
|
139
|
+
const outsideEligible = candidates.some((row) => !members.has(row.providerId) && row.servesModel === true && row.eligible === true);
|
|
140
|
+
let reason: AccountGroupFailureReason;
|
|
141
|
+
if (inside.some((row) => row.reason === "authorization snapshot unavailable")) reason = "authorization-snapshot-unavailable";
|
|
142
|
+
else if (serving.length === 0) reason = "no-serving-members";
|
|
143
|
+
else if (managed.length === 0 || serving.some((row) => !managedProvider(row.providerId) && row.eligible === true)) reason = "no-managed-serving-members";
|
|
144
|
+
else if (managed.every((row) => row.authenticated === false || row.reason === "authentication unavailable")) reason = "authentication-unavailable";
|
|
145
|
+
else if (managed.every((row) => row.exhausted === true)) reason = "exhausted";
|
|
146
|
+
else if (managed.every((row) => row.coolingDown === true)) reason = "cooldown";
|
|
147
|
+
else reason = "availability-unresolved";
|
|
148
|
+
return failureView(reason, input.modelId, outsideEligible, resolution);
|
|
149
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
type GroupMemberRegistry = Pick<
|
|
4
|
+
ExtensionContext["modelRegistry"],
|
|
5
|
+
"getAll" | "getAvailable"
|
|
6
|
+
> & Partial<Pick<
|
|
7
|
+
ExtensionContext["modelRegistry"],
|
|
8
|
+
"getRegisteredProviderIds" | "getProviderAuthStatus"
|
|
9
|
+
>>;
|
|
10
|
+
|
|
11
|
+
export type AccountGroupMemberReason =
|
|
12
|
+
| "recognized and available"
|
|
13
|
+
| "excluded by active group"
|
|
14
|
+
| "unknown or removed provider"
|
|
15
|
+
| "authentication unavailable"
|
|
16
|
+
| "model unavailable"
|
|
17
|
+
| "virtual model unsupported by active group"
|
|
18
|
+
| "authorization snapshot unavailable";
|
|
19
|
+
|
|
20
|
+
export interface AccountGroupMemberAvailability {
|
|
21
|
+
readonly providerId: string;
|
|
22
|
+
readonly eligible: boolean;
|
|
23
|
+
readonly reason: AccountGroupMemberReason;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AccountGroupRegistrySnapshot {
|
|
27
|
+
readonly providerIds: ReadonlySet<string>;
|
|
28
|
+
readonly modelProviderIds: ReadonlySet<string>;
|
|
29
|
+
readonly availableModels: readonly {
|
|
30
|
+
readonly providerId: string;
|
|
31
|
+
readonly modelId: string;
|
|
32
|
+
readonly api: string;
|
|
33
|
+
}[] | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Public Pi virtual API marker; provider identity/auth cannot confine its targets. */
|
|
37
|
+
export function isAccountGroupVirtualModel(
|
|
38
|
+
model: Readonly<{ api: string }> | undefined,
|
|
39
|
+
): boolean {
|
|
40
|
+
return model?.api === "pi-virtual";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Syntax alone grants no authority and does not enroll a provider in routing. */
|
|
44
|
+
export function isAccountGroupMemberReference(
|
|
45
|
+
value: unknown,
|
|
46
|
+
accountLimit: number,
|
|
47
|
+
managedFamilies: readonly string[],
|
|
48
|
+
): value is string {
|
|
49
|
+
if (
|
|
50
|
+
typeof value !== "string" ||
|
|
51
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)
|
|
52
|
+
) return false;
|
|
53
|
+
if (!Number.isInteger(accountLimit) || accountLimit < 1 || accountLimit > 32) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
for (const family of managedFamilies) {
|
|
57
|
+
if (value === family) return true;
|
|
58
|
+
const prefix = `${family}-account-`;
|
|
59
|
+
if (!value.startsWith(prefix)) continue;
|
|
60
|
+
const suffix = value.slice(prefix.length);
|
|
61
|
+
const slot = Number(suffix);
|
|
62
|
+
return Number.isSafeInteger(slot) && slot >= 2 &&
|
|
63
|
+
slot <= accountLimit && String(slot) === suffix;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Public synchronous snapshots only: no credential resolver, refresh, label or key. */
|
|
69
|
+
export function readAccountGroupRegistrySnapshot(
|
|
70
|
+
registry: GroupMemberRegistry | undefined,
|
|
71
|
+
): AccountGroupRegistrySnapshot {
|
|
72
|
+
const providerIds = new Set<string>();
|
|
73
|
+
const modelProviderIds = new Set<string>();
|
|
74
|
+
let availableModels: AccountGroupRegistrySnapshot["availableModels"];
|
|
75
|
+
try {
|
|
76
|
+
for (const row of registry?.getAll() ?? []) {
|
|
77
|
+
providerIds.add(row.provider);
|
|
78
|
+
modelProviderIds.add(row.provider);
|
|
79
|
+
}
|
|
80
|
+
for (const id of registry?.getRegisteredProviderIds?.() ?? []) {
|
|
81
|
+
providerIds.add(id);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
return {
|
|
85
|
+
providerIds: new Set(), modelProviderIds: new Set(),
|
|
86
|
+
availableModels: undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
// getAvailable() is cached catalog/auth-check metadata, not resolved request
|
|
91
|
+
// credentials. A configured command can pass without executing. A failed
|
|
92
|
+
// metadata read excludes only its member; request auth remains host-owned.
|
|
93
|
+
const authConfigured = new Map<string, boolean>();
|
|
94
|
+
availableModels = registry?.getAvailable().filter((row) => {
|
|
95
|
+
if (!authConfigured.has(row.provider)) {
|
|
96
|
+
try {
|
|
97
|
+
const configured = registry.getProviderAuthStatus === undefined ||
|
|
98
|
+
registry.getProviderAuthStatus(row.provider).configured === true;
|
|
99
|
+
authConfigured.set(row.provider, configured);
|
|
100
|
+
} catch {
|
|
101
|
+
authConfigured.set(row.provider, false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return authConfigured.get(row.provider) === true;
|
|
105
|
+
}).map((row) => ({ providerId: row.provider, modelId: row.id, api: row.api }));
|
|
106
|
+
} catch {
|
|
107
|
+
availableModels = undefined;
|
|
108
|
+
}
|
|
109
|
+
return { providerIds, modelProviderIds, availableModels };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Exact membership and live availability, independent of managed routing ownership. */
|
|
113
|
+
export function accountGroupMemberAvailability(
|
|
114
|
+
providerId: string,
|
|
115
|
+
members: readonly string[],
|
|
116
|
+
snapshot: AccountGroupRegistrySnapshot,
|
|
117
|
+
modelId?: string,
|
|
118
|
+
): AccountGroupMemberAvailability {
|
|
119
|
+
let reason: AccountGroupMemberReason;
|
|
120
|
+
const available = snapshot.availableModels;
|
|
121
|
+
if (!snapshot.providerIds.has(providerId)) reason = "unknown or removed provider";
|
|
122
|
+
else if (!members.includes(providerId)) reason = "excluded by active group";
|
|
123
|
+
else if (available === undefined) reason = "authorization snapshot unavailable";
|
|
124
|
+
else if (!snapshot.modelProviderIds.has(providerId)) reason = "model unavailable";
|
|
125
|
+
else if (!available.some((row) => row.providerId === providerId)) {
|
|
126
|
+
reason = "authentication unavailable";
|
|
127
|
+
} else if (modelId !== undefined && !available.some(
|
|
128
|
+
(row) => row.providerId === providerId && row.modelId === modelId,
|
|
129
|
+
)) {
|
|
130
|
+
reason = "model unavailable";
|
|
131
|
+
} else if (!available.some((row) =>
|
|
132
|
+
row.providerId === providerId &&
|
|
133
|
+
(modelId === undefined || row.modelId === modelId) &&
|
|
134
|
+
!isAccountGroupVirtualModel(row),
|
|
135
|
+
)) {
|
|
136
|
+
reason = "virtual model unsupported by active group";
|
|
137
|
+
} else reason = "recognized and available";
|
|
138
|
+
return { providerId, eligible: reason === "recognized and available", reason };
|
|
139
|
+
}
|