@centerforagenticai/pi-multi-account 0.1.1
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/LICENSE +21 -0
- package/NOTICE +29 -0
- package/README.md +999 -0
- package/config/models/pi-multi-account.v1.json +32 -0
- package/config/subscription-plans.v1.json +122 -0
- package/package.json +76 -0
- package/packages/pi-anthropic-oauth/LICENSE +21 -0
- package/packages/pi-anthropic-oauth/package.json +54 -0
- package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
- package/packages/pi-anthropic-oauth/src/context.ts +116 -0
- package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
- package/packages/pi-anthropic-oauth/src/index.ts +37 -0
- package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
- package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
- package/packages/pi-antigravity/LICENSE +21 -0
- package/packages/pi-antigravity/package.json +77 -0
- package/packages/pi-antigravity/src/auth/index.ts +14 -0
- package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
- package/packages/pi-antigravity/src/client/client.ts +561 -0
- package/packages/pi-antigravity/src/client/index.ts +1 -0
- package/packages/pi-antigravity/src/context.ts +110 -0
- package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
- package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
- package/packages/pi-antigravity/src/image/image.ts +336 -0
- package/packages/pi-antigravity/src/image/index.ts +1 -0
- package/packages/pi-antigravity/src/index.ts +280 -0
- package/packages/pi-antigravity/src/models/discovery.ts +154 -0
- package/packages/pi-antigravity/src/models/grouping.ts +424 -0
- package/packages/pi-antigravity/src/models/index.ts +3 -0
- package/packages/pi-antigravity/src/models/models.ts +500 -0
- package/packages/pi-antigravity/src/stream/index.ts +1 -0
- package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
- package/packages/pi-antigravity/src/types/enums.ts +42 -0
- package/packages/pi-antigravity/src/types/index.ts +2 -0
- package/packages/pi-antigravity/src/types/types.ts +292 -0
- package/packages/pi-antigravity/src/usage/index.ts +1 -0
- package/packages/pi-antigravity/src/usage/usage.ts +416 -0
- package/packages/pi-antigravity/src/utils/http.ts +91 -0
- package/packages/pi-antigravity/src/utils/index.ts +3 -0
- package/packages/pi-antigravity/src/utils/security.ts +73 -0
- package/packages/pi-antigravity/src/utils/util.ts +132 -0
- package/scripts/multi-account.mjs +44 -0
- package/src/account-labels.ts +223 -0
- package/src/account-plan-assignment.ts +340 -0
- package/src/account-rate-history.ts +372 -0
- package/src/anthropic-adaptive-stream.ts +531 -0
- package/src/anthropic-alias-stream.ts +140 -0
- package/src/anthropic-context-compat.ts +80 -0
- package/src/api-pricing.ts +579 -0
- package/src/bounded-file-lines.ts +97 -0
- package/src/catalog-rebinding.ts +177 -0
- package/src/catalog-registration-probe.ts +111 -0
- package/src/codex-adapter.ts +345 -0
- package/src/codex-model-defaults.ts +785 -0
- package/src/command-completions.ts +404 -0
- package/src/commands.ts +2000 -0
- package/src/compaction.ts +14 -0
- package/src/config.ts +1317 -0
- package/src/continuation.ts +569 -0
- package/src/cooldowns.ts +110 -0
- package/src/cost-digest-store.ts +332 -0
- package/src/cost-digest.ts +1044 -0
- package/src/cost-history.ts +251 -0
- package/src/cost-period-closer.ts +160 -0
- package/src/cost-report-json.ts +318 -0
- package/src/cost-report-reader.ts +368 -0
- package/src/cost-report-render.ts +207 -0
- package/src/cost-report.ts +1104 -0
- package/src/coverage-attestation.ts +397 -0
- package/src/credential-lifecycle.ts +169 -0
- package/src/credential-refresh.ts +248 -0
- package/src/declaration-notice-marker.ts +238 -0
- package/src/diagnostic-store.ts +276 -0
- package/src/diagnostics.ts +309 -0
- package/src/discovery.ts +471 -0
- package/src/duration.ts +13 -0
- package/src/error-classification.ts +256 -0
- package/src/fuzzy.ts +15 -0
- package/src/group-policy.ts +81 -0
- package/src/history-store.ts +897 -0
- package/src/index.ts +5572 -0
- package/src/lifecycle.ts +378 -0
- package/src/logical-dispatch.ts +279 -0
- package/src/logical-model-selector.ts +254 -0
- package/src/logical-model-switcher.ts +430 -0
- package/src/logical-provider-attribution.ts +544 -0
- package/src/logical-provider.ts +1237 -0
- package/src/logical-route-indicator.ts +215 -0
- package/src/machine-lease.ts +445 -0
- package/src/model-support.ts +66 -0
- package/src/models-declaration.ts +1091 -0
- package/src/openai-adapter.ts +117 -0
- package/src/openrouter-budget.ts +304 -0
- package/src/openrouter-fallback.ts +146 -0
- package/src/period-boundaries.ts +376 -0
- package/src/pi-anthropic-oauth.d.ts +6 -0
- package/src/preflight.ts +253 -0
- package/src/pricing-cache.ts +235 -0
- package/src/project-identity.ts +100 -0
- package/src/provider-registration.ts +942 -0
- package/src/rate-formula.ts +163 -0
- package/src/recovery-engine.ts +853 -0
- package/src/recovery-output.ts +837 -0
- package/src/recovery-plan.ts +239 -0
- package/src/report-range.ts +203 -0
- package/src/route-resolver.ts +789 -0
- package/src/routing-config-transaction.ts +232 -0
- package/src/routing.ts +1163 -0
- package/src/runtime-state.ts +630 -0
- package/src/session-account-groups.ts +284 -0
- package/src/session-restore.ts +287 -0
- package/src/shared-usage.ts +1392 -0
- package/src/standalone-cli.ts +720 -0
- package/src/status-view.ts +578 -0
- package/src/subscription-plan-catalog.ts +346 -0
- package/src/tier-model-resolver.ts +46 -0
- package/src/upstream-anthropic.ts +315 -0
- package/src/upstream-antigravity.ts +327 -0
- package/src/usage-fetch.ts +1634 -0
- package/src/usage.ts +1026 -0
- package/src/vendor.ts +87 -0
- package/src/warmer.ts +231 -0
- package/src/watchdog.ts +219 -0
- package/src/window-history.ts +270 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI platform-API alias registration (REQ-NATIVE-API-ALIAS-AUTH).
|
|
3
|
+
*
|
|
4
|
+
* The distinct `openai` platform family is reached by the vendor's own
|
|
5
|
+
* pay-per-token API using a stored `api_key` credential, not an OAuth
|
|
6
|
+
* subscription. Unlike the Codex alias, this needs no stream reattribution and
|
|
7
|
+
* no OAuth capture: the OpenAI platform provider is a plain API-key provider on
|
|
8
|
+
* Pi's built-in `openai-responses` handler, so an alias only has to declare
|
|
9
|
+
* real API-key auth and the `OPENAI_MODELS` catalog re-pointed at its own
|
|
10
|
+
* provider id. Pi resolves the alias slot's stored `api_key` at dispatch; this
|
|
11
|
+
* module never reads the key value.
|
|
12
|
+
*
|
|
13
|
+
* Catalog source is capture-or-compose: the live model registry's base `openai`
|
|
14
|
+
* provider catalog when the host has registered it (the pinned Pi 0.84.4 ships
|
|
15
|
+
* it), else the pinned `@earendil-works/pi-ai` `openaiProvider()` factory. Base
|
|
16
|
+
* providers are only read, never mutated or re-registered.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
ProviderConfig,
|
|
21
|
+
ProviderModelConfig,
|
|
22
|
+
} from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
|
|
24
|
+
import { cloneProviderModelCatalog } from "./catalog-rebinding.js";
|
|
25
|
+
|
|
26
|
+
/** pi-ai model API id for the OpenAI platform Responses API. */
|
|
27
|
+
export const OPENAI_ALIAS_API = "openai-responses" as const;
|
|
28
|
+
/** The base OpenAI platform provider id shipped by pi-ai. */
|
|
29
|
+
export const OPENAI_BASE_PROVIDER = "openai" as const;
|
|
30
|
+
/** Env var the OpenAI platform provider resolves its API key from. */
|
|
31
|
+
const OPENAI_API_KEY_ENV = "OPENAI_API_KEY" as const;
|
|
32
|
+
/** Base URL the pinned pi-ai `openaiProvider()` declares. */
|
|
33
|
+
const OPENAI_BASE_URL = "https://api.openai.com/v1" as const;
|
|
34
|
+
|
|
35
|
+
export class OpenAiAdapterContractError extends Error {
|
|
36
|
+
constructor(message: string) {
|
|
37
|
+
super(
|
|
38
|
+
`[openai platform contract] ${message} ` +
|
|
39
|
+
"Run npm run upstream:check and review UPSTREAM.md before release.",
|
|
40
|
+
);
|
|
41
|
+
this.name = "OpenAiAdapterContractError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Model configs for the OpenAI platform catalog, deeply isolated from any
|
|
47
|
+
* registry-owned object. Prefers the live registry's base `openai` catalog;
|
|
48
|
+
* falls back to the pinned pi-ai `openaiProvider()` factory when the host has
|
|
49
|
+
* not registered a base `openai` provider. The registry-owned `provider`
|
|
50
|
+
* identity is stripped so the caller can re-point each model at the alias id.
|
|
51
|
+
*/
|
|
52
|
+
export function openaiPlatformModels(
|
|
53
|
+
registry: unknown,
|
|
54
|
+
): readonly ProviderModelConfig[] {
|
|
55
|
+
const captured = tryCaptureRegistryModels(registry);
|
|
56
|
+
if (captured.length > 0) return captured;
|
|
57
|
+
return composeModelsFromFactory();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tryCaptureRegistryModels(
|
|
61
|
+
registry: unknown,
|
|
62
|
+
): readonly ProviderModelConfig[] {
|
|
63
|
+
try {
|
|
64
|
+
return cloneProviderModelCatalog(registry, OPENAI_BASE_PROVIDER);
|
|
65
|
+
} catch {
|
|
66
|
+
// The base openai provider is not registered in this session's registry;
|
|
67
|
+
// fall back to the pinned factory catalog rather than failing.
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function composeModelsFromFactory(): readonly ProviderModelConfig[] {
|
|
73
|
+
const provider = openaiProvider();
|
|
74
|
+
const models = provider.getModels();
|
|
75
|
+
if (models.length === 0) {
|
|
76
|
+
throw new OpenAiAdapterContractError(
|
|
77
|
+
"The pinned pi-ai openai provider factory yielded no models.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return models.map((model: unknown) => {
|
|
81
|
+
// Strip the registry-owned provider identity; keep every other field so
|
|
82
|
+
// the alias catalog stays a lossless superset of the base catalog.
|
|
83
|
+
const clone = structuredClone(model) as ProviderModelConfig & {
|
|
84
|
+
provider?: unknown;
|
|
85
|
+
};
|
|
86
|
+
delete clone.provider;
|
|
87
|
+
return clone as ProviderModelConfig;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Builds an OpenAI platform-API alias `ProviderConfig` for one numbered slot:
|
|
93
|
+
* real API-key auth (`$OPENAI_API_KEY`), the `openai-responses` API, and the
|
|
94
|
+
* platform catalog re-pointed at the alias. No OAuth, no custom stream — Pi's
|
|
95
|
+
* built-in `openai-responses` handler serves it once it resolves the slot's
|
|
96
|
+
* stored `api_key`.
|
|
97
|
+
*/
|
|
98
|
+
export function createOpenAiAliasProviderConfig(
|
|
99
|
+
sourceModels: readonly ProviderModelConfig[],
|
|
100
|
+
aliasDisplayLabel: string,
|
|
101
|
+
): ProviderConfig {
|
|
102
|
+
if (sourceModels.length === 0) {
|
|
103
|
+
throw new OpenAiAdapterContractError(
|
|
104
|
+
"Cannot register an OpenAI platform alias with an empty model catalog.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
api: OPENAI_ALIAS_API,
|
|
109
|
+
baseUrl: OPENAI_BASE_URL,
|
|
110
|
+
apiKey: `$${OPENAI_API_KEY_ENV}`,
|
|
111
|
+
models: sourceModels.map((model) => ({
|
|
112
|
+
...structuredClone(model),
|
|
113
|
+
api: OPENAI_ALIAS_API,
|
|
114
|
+
name: `${model.name} (${aliasDisplayLabel})`,
|
|
115
|
+
})),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
fsyncSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
renameSync,
|
|
10
|
+
statSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { acquireMachineLease } from "./machine-lease.js";
|
|
16
|
+
import { getPeriodBounds } from "./period-boundaries.js";
|
|
17
|
+
import { isProjectKey } from "./project-identity.js";
|
|
18
|
+
|
|
19
|
+
const SCHEMA_VERSION = 1;
|
|
20
|
+
const MAX_STATE_BYTES = 64 * 1024;
|
|
21
|
+
const MAX_PROJECTS = 512;
|
|
22
|
+
const MAX_AMOUNT_USD = 1_000_000;
|
|
23
|
+
const RETENTION_DAYS = 7;
|
|
24
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
const LEASE_TTL_MS = 5_000;
|
|
26
|
+
const ROUNDING_SCALE = 1_000_000;
|
|
27
|
+
|
|
28
|
+
interface BudgetEntry {
|
|
29
|
+
readonly dayStartMs: number;
|
|
30
|
+
readonly reservedUsd: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface BudgetState {
|
|
34
|
+
readonly schemaVersion: 1;
|
|
35
|
+
readonly entries: Readonly<Record<string, BudgetEntry>>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type OpenRouterBudgetReservation =
|
|
39
|
+
| {
|
|
40
|
+
readonly status: "reserved";
|
|
41
|
+
readonly dayStartMs: number;
|
|
42
|
+
readonly reservedTodayUsd: number;
|
|
43
|
+
readonly remainingTodayUsd: number;
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
readonly status: "exhausted";
|
|
47
|
+
readonly dayStartMs: number;
|
|
48
|
+
readonly reservedTodayUsd: number;
|
|
49
|
+
readonly remainingTodayUsd: number;
|
|
50
|
+
}
|
|
51
|
+
| { readonly status: "unavailable" };
|
|
52
|
+
|
|
53
|
+
function defaultPaths(): { readonly path: string; readonly lockPath: string } {
|
|
54
|
+
const agentDir =
|
|
55
|
+
process.env.PI_CODING_AGENT_DIR ??
|
|
56
|
+
join(process.env.HOME ?? ".", ".pi", "agent");
|
|
57
|
+
// Package storage identity stays decoupled from the logical provider ID.
|
|
58
|
+
const directory = join(agentDir, "pi-multi-account");
|
|
59
|
+
return {
|
|
60
|
+
path: join(directory, "openrouter-budget.json"),
|
|
61
|
+
lockPath: join(directory, "openrouter-budget.lock"),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function boundedAmount(value: unknown): value is number {
|
|
66
|
+
return (
|
|
67
|
+
typeof value === "number" &&
|
|
68
|
+
Number.isFinite(value) &&
|
|
69
|
+
value >= 0 &&
|
|
70
|
+
value <= MAX_AMOUNT_USD
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseState(value: unknown): BudgetState | undefined {
|
|
75
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const candidate = value as Record<string, unknown>;
|
|
79
|
+
if (
|
|
80
|
+
candidate.schemaVersion !== SCHEMA_VERSION ||
|
|
81
|
+
typeof candidate.entries !== "object" ||
|
|
82
|
+
candidate.entries === null ||
|
|
83
|
+
Array.isArray(candidate.entries) ||
|
|
84
|
+
Object.keys(candidate).some(
|
|
85
|
+
(key) => key !== "schemaVersion" && key !== "entries",
|
|
86
|
+
)
|
|
87
|
+
) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const rawEntries = candidate.entries as Record<string, unknown>;
|
|
91
|
+
if (Object.keys(rawEntries).length > MAX_PROJECTS) return undefined;
|
|
92
|
+
const entries: Record<string, BudgetEntry> = {};
|
|
93
|
+
for (const [projectKey, rawEntry] of Object.entries(rawEntries)) {
|
|
94
|
+
if (
|
|
95
|
+
!isProjectKey(projectKey) ||
|
|
96
|
+
typeof rawEntry !== "object" ||
|
|
97
|
+
rawEntry === null ||
|
|
98
|
+
Array.isArray(rawEntry)
|
|
99
|
+
) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const entry = rawEntry as Record<string, unknown>;
|
|
103
|
+
if (
|
|
104
|
+
!Number.isSafeInteger(entry.dayStartMs) ||
|
|
105
|
+
(entry.dayStartMs as number) < 0 ||
|
|
106
|
+
!boundedAmount(entry.reservedUsd) ||
|
|
107
|
+
Object.keys(entry).some(
|
|
108
|
+
(key) => key !== "dayStartMs" && key !== "reservedUsd",
|
|
109
|
+
)
|
|
110
|
+
) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
entries[projectKey] = {
|
|
114
|
+
dayStartMs: entry.dayStartMs as number,
|
|
115
|
+
reservedUsd: entry.reservedUsd,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return { schemaVersion: SCHEMA_VERSION, entries };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function roundUpUsd(value: number): number {
|
|
122
|
+
return Math.ceil(value * ROUNDING_SCALE) / ROUNDING_SCALE;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Persistent per-project worst-case reservations for metered fallback turns. */
|
|
126
|
+
export class OpenRouterBudgetStore {
|
|
127
|
+
readonly #path: string;
|
|
128
|
+
readonly #lockPath: string;
|
|
129
|
+
readonly #now: () => number;
|
|
130
|
+
readonly #beforeWrite: (() => void) | undefined;
|
|
131
|
+
|
|
132
|
+
constructor(options: {
|
|
133
|
+
readonly path?: string;
|
|
134
|
+
readonly lockPath?: string;
|
|
135
|
+
readonly now?: () => number;
|
|
136
|
+
/** Test seam for a peer attempting a reservation while this lease is held. */
|
|
137
|
+
readonly beforeWrite?: () => void;
|
|
138
|
+
} = {}) {
|
|
139
|
+
const defaults = defaultPaths();
|
|
140
|
+
this.#path = options.path ?? defaults.path;
|
|
141
|
+
this.#lockPath = options.lockPath ?? defaults.lockPath;
|
|
142
|
+
this.#now = options.now ?? Date.now;
|
|
143
|
+
this.#beforeWrite = options.beforeWrite;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
path(): string {
|
|
147
|
+
return this.#path;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
reservedToday(projectKey: string, nowMs = this.#now()): number | undefined {
|
|
151
|
+
if (!isProjectKey(projectKey)) return undefined;
|
|
152
|
+
try {
|
|
153
|
+
const state = this.#readState(true);
|
|
154
|
+
if (state === undefined) return undefined;
|
|
155
|
+
const entry = state.entries[projectKey];
|
|
156
|
+
return entry?.dayStartMs === getPeriodBounds(nowMs, "day").startMs
|
|
157
|
+
? entry.reservedUsd
|
|
158
|
+
: 0;
|
|
159
|
+
} catch {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
reserve(options: {
|
|
165
|
+
readonly projectKey: string;
|
|
166
|
+
readonly reservationUsd: number;
|
|
167
|
+
readonly dailyLimitUsd: number;
|
|
168
|
+
readonly nowMs?: number;
|
|
169
|
+
}): OpenRouterBudgetReservation {
|
|
170
|
+
if (
|
|
171
|
+
!isProjectKey(options.projectKey) ||
|
|
172
|
+
!boundedAmount(options.reservationUsd) ||
|
|
173
|
+
options.reservationUsd <= 0 ||
|
|
174
|
+
!boundedAmount(options.dailyLimitUsd) ||
|
|
175
|
+
options.dailyLimitUsd <= 0
|
|
176
|
+
) {
|
|
177
|
+
return { status: "unavailable" };
|
|
178
|
+
}
|
|
179
|
+
const reservationUsd = roundUpUsd(options.reservationUsd);
|
|
180
|
+
const dailyLimitUsd = roundUpUsd(options.dailyLimitUsd);
|
|
181
|
+
const nowMs = options.nowMs ?? this.#now();
|
|
182
|
+
let dayStartMs: number;
|
|
183
|
+
try {
|
|
184
|
+
dayStartMs = getPeriodBounds(nowMs, "day").startMs;
|
|
185
|
+
} catch {
|
|
186
|
+
return { status: "unavailable" };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let lease: ReturnType<typeof acquireMachineLease>;
|
|
190
|
+
try {
|
|
191
|
+
lease = acquireMachineLease({
|
|
192
|
+
lockPath: this.#lockPath,
|
|
193
|
+
ttlMs: LEASE_TTL_MS,
|
|
194
|
+
now: this.#now,
|
|
195
|
+
// A wedged lock would otherwise fail every reservation closed and
|
|
196
|
+
// disable OpenRouter permanently.
|
|
197
|
+
reclaimMalformed: true,
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
return { status: "unavailable" };
|
|
201
|
+
}
|
|
202
|
+
if (lease === undefined) return { status: "unavailable" };
|
|
203
|
+
try {
|
|
204
|
+
if (!lease.renew()) return { status: "unavailable" };
|
|
205
|
+
const existing = this.#readState(true);
|
|
206
|
+
if (existing === undefined) return { status: "unavailable" };
|
|
207
|
+
const current = existing.entries[options.projectKey];
|
|
208
|
+
const reservedTodayUsd =
|
|
209
|
+
current?.dayStartMs === dayStartMs ? current.reservedUsd : 0;
|
|
210
|
+
const remainingTodayUsd = Math.max(
|
|
211
|
+
0,
|
|
212
|
+
dailyLimitUsd - reservedTodayUsd,
|
|
213
|
+
);
|
|
214
|
+
if (reservedTodayUsd + reservationUsd > dailyLimitUsd) {
|
|
215
|
+
return {
|
|
216
|
+
status: "exhausted",
|
|
217
|
+
dayStartMs,
|
|
218
|
+
reservedTodayUsd,
|
|
219
|
+
remainingTodayUsd,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const retainedEntries = Object.fromEntries(
|
|
224
|
+
Object.entries(existing.entries).filter(
|
|
225
|
+
([, entry]) => entry.dayStartMs >= dayStartMs - RETENTION_DAYS * DAY_MS,
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
retainedEntries[options.projectKey] = {
|
|
229
|
+
dayStartMs,
|
|
230
|
+
reservedUsd: roundUpUsd(reservedTodayUsd + reservationUsd),
|
|
231
|
+
};
|
|
232
|
+
if (Object.keys(retainedEntries).length > MAX_PROJECTS) {
|
|
233
|
+
return { status: "unavailable" };
|
|
234
|
+
}
|
|
235
|
+
this.#beforeWrite?.();
|
|
236
|
+
if (!lease.renew()) return { status: "unavailable" };
|
|
237
|
+
if (!this.#writeState({ schemaVersion: SCHEMA_VERSION, entries: retainedEntries })) {
|
|
238
|
+
return { status: "unavailable" };
|
|
239
|
+
}
|
|
240
|
+
const nextReserved = retainedEntries[options.projectKey]?.reservedUsd;
|
|
241
|
+
if (nextReserved === undefined) return { status: "unavailable" };
|
|
242
|
+
return {
|
|
243
|
+
status: "reserved",
|
|
244
|
+
dayStartMs,
|
|
245
|
+
reservedTodayUsd: nextReserved,
|
|
246
|
+
remainingTodayUsd: Math.max(0, dailyLimitUsd - nextReserved),
|
|
247
|
+
};
|
|
248
|
+
} catch {
|
|
249
|
+
return { status: "unavailable" };
|
|
250
|
+
} finally {
|
|
251
|
+
lease.release();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#readState(missingIsEmpty: boolean): BudgetState | undefined {
|
|
256
|
+
try {
|
|
257
|
+
const stats = statSync(this.#path);
|
|
258
|
+
if (!stats.isFile() || stats.size > MAX_STATE_BYTES) return undefined;
|
|
259
|
+
return parseState(JSON.parse(readFileSync(this.#path, "utf8")));
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (
|
|
262
|
+
missingIsEmpty &&
|
|
263
|
+
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
264
|
+
) {
|
|
265
|
+
return { schemaVersion: SCHEMA_VERSION, entries: {} };
|
|
266
|
+
}
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#writeState(state: BudgetState): boolean {
|
|
272
|
+
const encoded = `${JSON.stringify(state)}\n`;
|
|
273
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_STATE_BYTES) return false;
|
|
274
|
+
const directory = dirname(this.#path);
|
|
275
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
276
|
+
chmodSync(directory, 0o700);
|
|
277
|
+
const temporaryPath = `${this.#path}.${process.pid}.${randomUUID()}.tmp`;
|
|
278
|
+
let descriptor: number | undefined;
|
|
279
|
+
try {
|
|
280
|
+
descriptor = openSync(temporaryPath, "wx", 0o600);
|
|
281
|
+
writeFileSync(descriptor, encoded, { encoding: "utf8" });
|
|
282
|
+
fsyncSync(descriptor);
|
|
283
|
+
closeSync(descriptor);
|
|
284
|
+
descriptor = undefined;
|
|
285
|
+
renameSync(temporaryPath, this.#path);
|
|
286
|
+
chmodSync(this.#path, 0o600);
|
|
287
|
+
return true;
|
|
288
|
+
} catch {
|
|
289
|
+
if (descriptor !== undefined) {
|
|
290
|
+
try {
|
|
291
|
+
closeSync(descriptor);
|
|
292
|
+
} catch {
|
|
293
|
+
// Continue with temporary-file cleanup.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
unlinkSync(temporaryPath);
|
|
298
|
+
} catch {
|
|
299
|
+
// A failed reservation must not escape into routing.
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { Api, Model, ModelCostRates } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export const OPENROUTER_PROVIDER_ID = "openrouter";
|
|
4
|
+
export const OPENROUTER_ENABLE_ENV = "PI_MULTI_ACCOUNT_OPENROUTER_ENABLED";
|
|
5
|
+
export const OPENROUTER_ENABLE_VALUE = "conversation-egress";
|
|
6
|
+
export const OPENROUTER_MODEL_ENV = "PI_MULTI_ACCOUNT_OPENROUTER_MODEL";
|
|
7
|
+
export const OPENROUTER_DAILY_LIMIT_ENV =
|
|
8
|
+
"PI_MULTI_ACCOUNT_OPENROUTER_DAILY_USD_LIMIT";
|
|
9
|
+
|
|
10
|
+
const MAX_MODEL_ID_LENGTH = 200;
|
|
11
|
+
const MAX_DAILY_LIMIT_USD = 1_000;
|
|
12
|
+
const MODEL_ID_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._:/-]+$/;
|
|
13
|
+
const USD_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d{1,4})?$/;
|
|
14
|
+
|
|
15
|
+
export type OpenRouterPolicyReason =
|
|
16
|
+
| "enabled"
|
|
17
|
+
| "not-enabled"
|
|
18
|
+
| "delegate-session"
|
|
19
|
+
| "model-unconfigured"
|
|
20
|
+
| "budget-unconfigured"
|
|
21
|
+
| "session-disabled";
|
|
22
|
+
|
|
23
|
+
export type OpenRouterEnvironmentPolicy =
|
|
24
|
+
| {
|
|
25
|
+
readonly enabled: true;
|
|
26
|
+
readonly reason: "enabled";
|
|
27
|
+
readonly modelId: string;
|
|
28
|
+
readonly dailyLimitUsd: number;
|
|
29
|
+
readonly conversationEgressConsented: true;
|
|
30
|
+
readonly delegatesAllowed: false;
|
|
31
|
+
}
|
|
32
|
+
| {
|
|
33
|
+
readonly enabled: false;
|
|
34
|
+
readonly reason: Exclude<OpenRouterPolicyReason, "enabled">;
|
|
35
|
+
readonly conversationEgressConsented: boolean;
|
|
36
|
+
readonly delegatesAllowed: false;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function isDelegateEnvironment(
|
|
40
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
41
|
+
): boolean {
|
|
42
|
+
return Object.keys(environment).some((key) => {
|
|
43
|
+
if (!key.startsWith("PI_DELEGATE_LINEAGE_")) return false;
|
|
44
|
+
const value = environment[key];
|
|
45
|
+
return typeof value === "string" && value.length > 0;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the explicit per-project OpenRouter policy without reading the API key.
|
|
51
|
+
* Partial, malformed, absent, and delegate-process configurations all disable the
|
|
52
|
+
* metered rung without turning an optional capability into a startup failure.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveOpenRouterEnvironmentPolicy(options: {
|
|
55
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
56
|
+
readonly sessionDisabled?: boolean;
|
|
57
|
+
} = {}): OpenRouterEnvironmentPolicy {
|
|
58
|
+
const environment = options.environment ?? process.env;
|
|
59
|
+
const consented =
|
|
60
|
+
environment[OPENROUTER_ENABLE_ENV] === OPENROUTER_ENABLE_VALUE;
|
|
61
|
+
const disabled = (
|
|
62
|
+
reason: Exclude<OpenRouterPolicyReason, "enabled">,
|
|
63
|
+
): OpenRouterEnvironmentPolicy => ({
|
|
64
|
+
enabled: false,
|
|
65
|
+
reason,
|
|
66
|
+
conversationEgressConsented: consented,
|
|
67
|
+
delegatesAllowed: false,
|
|
68
|
+
});
|
|
69
|
+
if (options.sessionDisabled) return disabled("session-disabled");
|
|
70
|
+
if (isDelegateEnvironment(environment)) return disabled("delegate-session");
|
|
71
|
+
if (!consented) return disabled("not-enabled");
|
|
72
|
+
|
|
73
|
+
const modelId = environment[OPENROUTER_MODEL_ENV]?.trim();
|
|
74
|
+
if (
|
|
75
|
+
modelId === undefined ||
|
|
76
|
+
modelId.length === 0 ||
|
|
77
|
+
modelId.length > MAX_MODEL_ID_LENGTH ||
|
|
78
|
+
!MODEL_ID_PATTERN.test(modelId)
|
|
79
|
+
) {
|
|
80
|
+
return disabled("model-unconfigured");
|
|
81
|
+
}
|
|
82
|
+
const rawLimit = environment[OPENROUTER_DAILY_LIMIT_ENV]?.trim();
|
|
83
|
+
if (rawLimit === undefined || !USD_PATTERN.test(rawLimit)) {
|
|
84
|
+
return disabled("budget-unconfigured");
|
|
85
|
+
}
|
|
86
|
+
const dailyLimitUsd = Number(rawLimit);
|
|
87
|
+
if (
|
|
88
|
+
!Number.isFinite(dailyLimitUsd) ||
|
|
89
|
+
dailyLimitUsd <= 0 ||
|
|
90
|
+
dailyLimitUsd > MAX_DAILY_LIMIT_USD
|
|
91
|
+
) {
|
|
92
|
+
return disabled("budget-unconfigured");
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
enabled: true,
|
|
96
|
+
reason: "enabled",
|
|
97
|
+
modelId,
|
|
98
|
+
dailyLimitUsd,
|
|
99
|
+
conversationEgressConsented: true,
|
|
100
|
+
delegatesAllowed: false,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validRates(value: ModelCostRates): boolean {
|
|
105
|
+
return [value.input, value.output, value.cacheRead, value.cacheWrite].every(
|
|
106
|
+
(rate) => Number.isFinite(rate) && rate >= 0,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Conservative maximum charge for one request using Pi's live model catalogue.
|
|
112
|
+
* Context and output maxima are both charged, intentionally double-counting the
|
|
113
|
+
* output share of a context window. The largest base/tier/cache rate wins.
|
|
114
|
+
*/
|
|
115
|
+
export function worstCaseOpenRouterTurnUsd(
|
|
116
|
+
model: Model<Api>,
|
|
117
|
+
): number | undefined {
|
|
118
|
+
if (
|
|
119
|
+
model.provider !== OPENROUTER_PROVIDER_ID ||
|
|
120
|
+
!Number.isSafeInteger(model.contextWindow) ||
|
|
121
|
+
model.contextWindow <= 0 ||
|
|
122
|
+
!Number.isSafeInteger(model.maxTokens) ||
|
|
123
|
+
model.maxTokens <= 0 ||
|
|
124
|
+
!validRates(model.cost)
|
|
125
|
+
) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const rates: readonly ModelCostRates[] = [
|
|
129
|
+
model.cost,
|
|
130
|
+
...(model.cost.tiers ?? []),
|
|
131
|
+
];
|
|
132
|
+
if (!rates.every(validRates)) return undefined;
|
|
133
|
+
const inputRate = Math.max(
|
|
134
|
+
...rates.flatMap((rate) => [
|
|
135
|
+
rate.input,
|
|
136
|
+
rate.input * 2,
|
|
137
|
+
rate.cacheRead,
|
|
138
|
+
rate.cacheWrite,
|
|
139
|
+
]),
|
|
140
|
+
);
|
|
141
|
+
const outputRate = Math.max(...rates.map((rate) => rate.output));
|
|
142
|
+
const total =
|
|
143
|
+
(model.contextWindow * inputRate + model.maxTokens * outputRate) /
|
|
144
|
+
1_000_000;
|
|
145
|
+
return Number.isFinite(total) && total > 0 ? total : undefined;
|
|
146
|
+
}
|