talon-agent 3.16.1 → 3.18.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/package.json +1 -1
- package/src/app.ts +2 -0
- package/src/backend/codex/factory.ts +9 -0
- package/src/backend/codex/plan-usage.ts +160 -0
- package/src/backend/kilo/factory.ts +2 -19
- package/src/backend/opencode/factory.ts +2 -15
- package/src/backend/remote-server/model-catalog/presentation.ts +102 -25
- package/src/backend/remote-server/model-catalog/provider.ts +10 -6
- package/src/bootstrap.ts +14 -0
- package/src/cli/doctor.ts +11 -1
- package/src/core/background/plan-alerts.ts +115 -0
- package/src/core/doctor.ts +71 -8
- package/src/frontend/discord/callbacks/components.ts +260 -16
- package/src/frontend/discord/commands/admin.ts +188 -14
- package/src/frontend/discord/commands/definitions.ts +37 -2
- package/src/frontend/discord/commands/info.ts +38 -1
- package/src/frontend/discord/commands/router.ts +21 -1
- package/src/frontend/discord/commands/settings.ts +10 -18
- package/src/frontend/discord/helpers.ts +148 -1
- package/src/frontend/discord/index.ts +10 -1
- package/src/frontend/discord/middleware.ts +24 -0
- package/src/frontend/discord/model-picker.ts +234 -0
- package/src/frontend/shared/plan-usage-report.ts +72 -0
- package/src/frontend/telegram/commands/admin.ts +10 -0
- package/src/frontend/telegram/commands/definitions.ts +1 -0
- package/src/frontend/telegram/helpers/diagnostics.ts +38 -4
- package/src/util/config.ts +10 -0
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { startUploadCleanup, stopUploadCleanup } from "./util/workspace.js";
|
|
|
11
11
|
import { flushDatabase } from "./storage/db.js";
|
|
12
12
|
import { getActiveCount } from "./core/engine/dispatcher.js";
|
|
13
13
|
import { startPulseTimer, stopPulseTimer } from "./core/background/pulse.js";
|
|
14
|
+
import { stopPlanAlerts } from "./core/background/plan-alerts.js";
|
|
14
15
|
import {
|
|
15
16
|
startHeartbeatTimer,
|
|
16
17
|
stopHeartbeatTimer,
|
|
@@ -179,6 +180,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
|
|
|
179
180
|
await awaitHeartbeat();
|
|
180
181
|
});
|
|
181
182
|
await shutdownStep("cron timer", stopCronTimer);
|
|
183
|
+
await shutdownStep("plan alerts", stopPlanAlerts);
|
|
182
184
|
await shutdownStep("trigger prune timer", () => {
|
|
183
185
|
if (triggerPruneTimer) clearInterval(triggerPruneTimer);
|
|
184
186
|
triggerPruneTimer = null;
|
|
@@ -16,7 +16,9 @@ import {
|
|
|
16
16
|
type ChatBackend,
|
|
17
17
|
type BackgroundRunner,
|
|
18
18
|
type ModelCatalog,
|
|
19
|
+
type UsageTelemetry,
|
|
19
20
|
} from "../../core/agent-runtime/capabilities.js";
|
|
21
|
+
import { getPlanUsage as getCodexPlanUsage } from "./plan-usage.js";
|
|
20
22
|
|
|
21
23
|
import { initCodexAgent, getCodexAuthInfo } from "./init.js";
|
|
22
24
|
import { handleMessage as codexHandleMessage } from "./handler/index.js";
|
|
@@ -77,6 +79,12 @@ const codexFactory: BackendFactory = {
|
|
|
77
79
|
// the per-chat thread lifecycle directly via `setSessionId` on
|
|
78
80
|
// `storage/sessions.ts`. No `sessions` slot needed; `/reset`
|
|
79
81
|
// clears the stored thread id through the storage path.
|
|
82
|
+
// No per-session snapshot to offer, but a ChatGPT-plan install can
|
|
83
|
+
// report its rate-limit windows.
|
|
84
|
+
const usage: UsageTelemetry = {
|
|
85
|
+
getPlanUsage: () => getCodexPlanUsage(),
|
|
86
|
+
};
|
|
87
|
+
|
|
80
88
|
const backend = composeBackend({
|
|
81
89
|
id: "codex",
|
|
82
90
|
label: "Codex",
|
|
@@ -84,6 +92,7 @@ const codexFactory: BackendFactory = {
|
|
|
84
92
|
chat,
|
|
85
93
|
background,
|
|
86
94
|
models,
|
|
95
|
+
usage,
|
|
87
96
|
});
|
|
88
97
|
|
|
89
98
|
return {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT subscription rate-limit windows for the Codex backend.
|
|
3
|
+
*
|
|
4
|
+
* The Codex CLI reads these from an endpoint on the ChatGPT backend and
|
|
5
|
+
* caches the result in its session transcripts; `/status` renders that cache
|
|
6
|
+
* rather than re-fetching. Talon queries the endpoint directly so `/usage`
|
|
7
|
+
* reports the current state instead of whatever the last turn happened to
|
|
8
|
+
* see, using the OAuth token `codex login` already stored.
|
|
9
|
+
*
|
|
10
|
+
* Degrades to `undefined` for API-key installs (no plan to report), a
|
|
11
|
+
* missing or expired token, or any transport failure.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { logWarn } from "../../util/log.js";
|
|
18
|
+
import type {
|
|
19
|
+
PlanUsage,
|
|
20
|
+
PlanWindow,
|
|
21
|
+
} from "../../core/agent-runtime/capabilities.js";
|
|
22
|
+
|
|
23
|
+
const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 5_000;
|
|
25
|
+
const CACHE_TTL_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
let cache: { value: PlanUsage; fetchedAt: number } | undefined;
|
|
28
|
+
let inFlight: Promise<PlanUsage | undefined> | undefined;
|
|
29
|
+
|
|
30
|
+
function authPath(): string {
|
|
31
|
+
const home = process.env.CODEX_HOME?.trim();
|
|
32
|
+
return home && home.length > 0
|
|
33
|
+
? join(home, "auth.json")
|
|
34
|
+
: join(homedir(), ".codex", "auth.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CodexAuth {
|
|
38
|
+
accessToken: string;
|
|
39
|
+
accountId?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readAuth(): Promise<CodexAuth | undefined> {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(await readFile(authPath(), "utf8")) as {
|
|
45
|
+
auth_mode?: string;
|
|
46
|
+
tokens?: { access_token?: string; account_id?: string };
|
|
47
|
+
};
|
|
48
|
+
const token = parsed.tokens?.access_token;
|
|
49
|
+
if (!token) return undefined;
|
|
50
|
+
return {
|
|
51
|
+
accessToken: token,
|
|
52
|
+
...(parsed.tokens?.account_id
|
|
53
|
+
? { accountId: parsed.tokens.account_id }
|
|
54
|
+
: {}),
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface RawWindow {
|
|
62
|
+
used_percent?: number;
|
|
63
|
+
limit_window_seconds?: number;
|
|
64
|
+
reset_at?: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Window label from its length. The plan exposes a weekly window and,
|
|
69
|
+
* historically, a shorter one; naming them by duration keeps the label
|
|
70
|
+
* right whichever windows the account actually has.
|
|
71
|
+
*/
|
|
72
|
+
function windowLabel(seconds: number | undefined): string {
|
|
73
|
+
if (!seconds || !Number.isFinite(seconds)) return "limit";
|
|
74
|
+
const hours = Math.round(seconds / 3600);
|
|
75
|
+
if (hours % 24 === 0 && hours >= 24) return `${hours / 24}d`;
|
|
76
|
+
return `${hours}h`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toWindow(raw: RawWindow | null | undefined): PlanWindow | undefined {
|
|
80
|
+
if (!raw || typeof raw.used_percent !== "number") return undefined;
|
|
81
|
+
return {
|
|
82
|
+
label: windowLabel(raw.limit_window_seconds),
|
|
83
|
+
percent: Math.max(0, Math.min(100, Math.round(raw.used_percent))),
|
|
84
|
+
// `reset_at` is unix seconds; the shared shape speaks ISO.
|
|
85
|
+
...(typeof raw.reset_at === "number" && raw.reset_at > 0
|
|
86
|
+
? { resetsAt: new Date(raw.reset_at * 1000).toISOString() }
|
|
87
|
+
: {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseCodexUsage(body: unknown): PlanUsage | undefined {
|
|
92
|
+
const data = body as {
|
|
93
|
+
plan_type?: string;
|
|
94
|
+
rate_limit?: {
|
|
95
|
+
primary_window?: RawWindow | null;
|
|
96
|
+
secondary_window?: RawWindow | null;
|
|
97
|
+
};
|
|
98
|
+
} | null;
|
|
99
|
+
const limit = data?.rate_limit;
|
|
100
|
+
if (!limit) return undefined;
|
|
101
|
+
|
|
102
|
+
const windows = [
|
|
103
|
+
toWindow(limit.primary_window),
|
|
104
|
+
toWindow(limit.secondary_window),
|
|
105
|
+
].filter((w): w is PlanWindow => Boolean(w));
|
|
106
|
+
if (windows.length === 0) return undefined;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
...(data?.plan_type ? { plan: data.plan_type } : {}),
|
|
110
|
+
windows,
|
|
111
|
+
fetchedAt: Date.now(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function load(): Promise<PlanUsage | undefined> {
|
|
116
|
+
const auth = await readAuth();
|
|
117
|
+
if (!auth) return undefined;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(USAGE_ENDPOINT, {
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
123
|
+
...(auth.accountId ? { "chatgpt-account-id": auth.accountId } : {}),
|
|
124
|
+
Accept: "application/json",
|
|
125
|
+
},
|
|
126
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
127
|
+
});
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
logWarn("agent", `codex usage: endpoint returned ${res.status}`);
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
return parseCodexUsage(await res.json());
|
|
133
|
+
} catch (err) {
|
|
134
|
+
logWarn(
|
|
135
|
+
"agent",
|
|
136
|
+
`codex usage: ${err instanceof Error ? err.message : String(err)}`,
|
|
137
|
+
);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Plan windows for `/usage`, cached for a minute. A failed refresh keeps
|
|
144
|
+
* serving the last known values — `fetchedAt` lets the caller age them.
|
|
145
|
+
*/
|
|
146
|
+
export async function getPlanUsage(): Promise<PlanUsage | undefined> {
|
|
147
|
+
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) return cache.value;
|
|
148
|
+
|
|
149
|
+
inFlight ??= load().finally(() => {
|
|
150
|
+
inFlight = undefined;
|
|
151
|
+
});
|
|
152
|
+
const loaded = await inFlight;
|
|
153
|
+
if (loaded) cache = { value: loaded, fetchedAt: loaded.fetchedAt };
|
|
154
|
+
return loaded ?? cache?.value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function resetCodexPlanUsageForTest(): void {
|
|
158
|
+
cache = undefined;
|
|
159
|
+
inFlight = undefined;
|
|
160
|
+
}
|
|
@@ -69,25 +69,8 @@ const kiloFactory: BackendFactory = {
|
|
|
69
69
|
// through to `config.backendDefaults.kilo`.
|
|
70
70
|
getDefaultModelId: () => undefined,
|
|
71
71
|
getRawModelInfo: (id) => kiloGetModelInfo(id),
|
|
72
|
-
getSettingsPresentation:
|
|
73
|
-
|
|
74
|
-
// wrap into the canonical `ModelPickerResult`. Kilo doesn't
|
|
75
|
-
// expose pagination or a free-tier filter so the result is
|
|
76
|
-
// always page 1 of 1 with filter "all".
|
|
77
|
-
const inner = await kiloGetSettingsPresentation(
|
|
78
|
-
m,
|
|
79
|
-
options?.callbackPrefix,
|
|
80
|
-
);
|
|
81
|
-
return {
|
|
82
|
-
...inner,
|
|
83
|
-
view: "models" as const,
|
|
84
|
-
page: 1,
|
|
85
|
-
totalPages: 1,
|
|
86
|
-
filter: "all" as const,
|
|
87
|
-
freeCount: 0,
|
|
88
|
-
totalCount: inner.modelButtons.length,
|
|
89
|
-
};
|
|
90
|
-
},
|
|
72
|
+
getSettingsPresentation: (m, options) =>
|
|
73
|
+
kiloGetSettingsPresentation(m, options),
|
|
91
74
|
getProviders: () => kiloGetProviders(),
|
|
92
75
|
getProviderModels: (p, pg, ps) => kiloGetProviderModels(p, pg, ps),
|
|
93
76
|
formatModelError: (q, r) => kiloFormatModelError(q, r),
|
|
@@ -66,21 +66,8 @@ const opencodeFactory: BackendFactory = {
|
|
|
66
66
|
// Catalog-driven backend with no canonical default.
|
|
67
67
|
getDefaultModelId: () => undefined,
|
|
68
68
|
getRawModelInfo: (id) => ocGetModelInfo(id),
|
|
69
|
-
getSettingsPresentation:
|
|
70
|
-
|
|
71
|
-
m,
|
|
72
|
-
options?.callbackPrefix,
|
|
73
|
-
);
|
|
74
|
-
return {
|
|
75
|
-
...inner,
|
|
76
|
-
view: "models" as const,
|
|
77
|
-
page: 1,
|
|
78
|
-
totalPages: 1,
|
|
79
|
-
filter: "all" as const,
|
|
80
|
-
freeCount: 0,
|
|
81
|
-
totalCount: inner.modelButtons.length,
|
|
82
|
-
};
|
|
83
|
-
},
|
|
69
|
+
getSettingsPresentation: (m, options) =>
|
|
70
|
+
ocGetSettingsPresentation(m, options),
|
|
84
71
|
getProviders: () => ocGetProviders(),
|
|
85
72
|
getProviderModels: (p, pg, ps) => ocGetProviderModels(p, pg, ps),
|
|
86
73
|
formatModelError: (q, r) => ocFormatModelError(q, r),
|
|
@@ -35,8 +35,40 @@ export interface RemotePresentationOptions {
|
|
|
35
35
|
* true where the transport accepts arbitrary characters (Discord).
|
|
36
36
|
*/
|
|
37
37
|
allowCallbackSeparators: boolean;
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Default page size when the frontend doesn't ask for one — also the
|
|
40
|
+
* legacy quick-pick count (Telegram 4, Discord 24).
|
|
41
|
+
*/
|
|
39
42
|
quickPickLimit: number;
|
|
43
|
+
/**
|
|
44
|
+
* Flat model lists longer than this collapse into provider chips, so a
|
|
45
|
+
* catalog of several hundred models opens as a handful of providers
|
|
46
|
+
* rather than dozens of indistinguishable pages. Defaults to 60.
|
|
47
|
+
*/
|
|
48
|
+
groupThreshold?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Paging / filtering knobs, mirroring `ModelPickerOptions`. */
|
|
52
|
+
export interface RemotePickerOptions {
|
|
53
|
+
callbackPrefix?: string;
|
|
54
|
+
navCallbackPrefix?: string;
|
|
55
|
+
page?: number;
|
|
56
|
+
pageSize?: number;
|
|
57
|
+
filter?: "all" | "free";
|
|
58
|
+
provider?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What the frontend needs to render one page of the picker. */
|
|
62
|
+
export interface RemotePickerResult {
|
|
63
|
+
modelButtons: Array<ModelButton>;
|
|
64
|
+
modelDetails: Array<string>;
|
|
65
|
+
view: "groups" | "models";
|
|
66
|
+
page: number;
|
|
67
|
+
totalPages: number;
|
|
68
|
+
filter: "all" | "free";
|
|
69
|
+
freeCount: number;
|
|
70
|
+
totalCount: number;
|
|
71
|
+
provider?: string;
|
|
40
72
|
}
|
|
41
73
|
|
|
42
74
|
function getAvailabilityLabel(model: RemoteModelCatalogEntry) {
|
|
@@ -69,8 +101,8 @@ export interface RemoteModelPresentation {
|
|
|
69
101
|
): Array<RemoteModelCatalogEntry>;
|
|
70
102
|
getSettingsPresentation(
|
|
71
103
|
activeModel: string,
|
|
72
|
-
|
|
73
|
-
): Promise<
|
|
104
|
+
options?: RemotePickerOptions,
|
|
105
|
+
): Promise<RemotePickerResult>;
|
|
74
106
|
renderModelSummary(
|
|
75
107
|
activeModel: string,
|
|
76
108
|
defaultModel: string,
|
|
@@ -92,6 +124,7 @@ export function createRemoteModelPresentation(
|
|
|
92
124
|
maxCallbackIdLength,
|
|
93
125
|
allowCallbackSeparators,
|
|
94
126
|
quickPickLimit,
|
|
127
|
+
groupThreshold = 60,
|
|
95
128
|
} = options;
|
|
96
129
|
|
|
97
130
|
function isCallbackSafeModelID(modelID: string): boolean {
|
|
@@ -140,30 +173,64 @@ export function createRemoteModelPresentation(
|
|
|
140
173
|
|
|
141
174
|
async function getSettingsPresentation(
|
|
142
175
|
activeModel: string,
|
|
143
|
-
|
|
144
|
-
): Promise<{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}> {
|
|
176
|
+
options: RemotePickerOptions = {},
|
|
177
|
+
): Promise<RemotePickerResult> {
|
|
178
|
+
const callbackPrefix = options.callbackPrefix ?? "settings:model:";
|
|
179
|
+
const navPrefix = options.navCallbackPrefix ?? "settings:models";
|
|
148
180
|
const catalog = await getCatalog();
|
|
149
181
|
const current = getRemoteModelInfo(catalog, activeModel);
|
|
150
|
-
const picks = getQuickPickModels(catalog, activeModel);
|
|
151
182
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
183
|
+
const filter = options.filter === "free" ? "free" : "all";
|
|
184
|
+
const selectable = catalog.connectedModels.filter((m) =>
|
|
185
|
+
isCallbackSafeModelID(m.id),
|
|
186
|
+
);
|
|
187
|
+
const freeCount = selectable.filter((m) => m.free).length;
|
|
188
|
+
const scoped = selectable.filter(
|
|
189
|
+
(m) =>
|
|
190
|
+
(filter === "all" || m.free) &&
|
|
191
|
+
(!options.provider || m.providerID === options.provider),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// A remote catalog can run to hundreds of models, and only the first
|
|
195
|
+
// page's worth ever fit on screen — so offer the provider list as the
|
|
196
|
+
// way in when nothing narrower was asked for and the flat list would be
|
|
197
|
+
// unreadable anyway.
|
|
198
|
+
const providers = catalog.connectedProviders;
|
|
199
|
+
const asGroups =
|
|
200
|
+
!options.provider &&
|
|
201
|
+
providers.length > 1 &&
|
|
202
|
+
scoped.length > groupThreshold;
|
|
203
|
+
|
|
204
|
+
const pageSize = Math.max(1, options.pageSize ?? quickPickLimit);
|
|
205
|
+
const source = asGroups ? providers : scoped;
|
|
206
|
+
const totalPages = Math.max(1, Math.ceil(source.length / pageSize));
|
|
207
|
+
const page = Math.min(Math.max(1, options.page ?? 1), totalPages);
|
|
208
|
+
const slice = source.slice((page - 1) * pageSize, page * pageSize);
|
|
209
|
+
|
|
210
|
+
const modelButtons: Array<ModelButton> = asGroups
|
|
211
|
+
? (slice as typeof providers).map((p) => ({
|
|
212
|
+
text: `${p.name} (${p.modelCount})`,
|
|
213
|
+
callback_data: `${navPrefix}:provider:${p.id}`,
|
|
214
|
+
}))
|
|
215
|
+
: (slice as typeof scoped).map((m) => {
|
|
216
|
+
const btnLabel =
|
|
217
|
+
m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id;
|
|
218
|
+
const txt = m.free ? `${btnLabel} ★` : btnLabel;
|
|
219
|
+
const sel =
|
|
220
|
+
current &&
|
|
221
|
+
m.id === current.id &&
|
|
222
|
+
m.providerID === current.providerID;
|
|
223
|
+
return {
|
|
224
|
+
text: sel ? `✓ ${txt}` : txt,
|
|
225
|
+
callback_data: `${callbackPrefix}${m.id}`,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
if (!asGroups) {
|
|
229
|
+
modelButtons.push({
|
|
230
|
+
text: "Reset",
|
|
231
|
+
callback_data: `${callbackPrefix}reset`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
167
234
|
|
|
168
235
|
const details: Array<string> = [];
|
|
169
236
|
if (current) {
|
|
@@ -189,7 +256,17 @@ export function createRemoteModelPresentation(
|
|
|
189
256
|
);
|
|
190
257
|
}
|
|
191
258
|
details.push("Hint: use /model <name> to switch.");
|
|
192
|
-
return {
|
|
259
|
+
return {
|
|
260
|
+
modelButtons,
|
|
261
|
+
modelDetails: details,
|
|
262
|
+
view: asGroups ? "groups" : "models",
|
|
263
|
+
page,
|
|
264
|
+
totalPages,
|
|
265
|
+
filter,
|
|
266
|
+
freeCount,
|
|
267
|
+
totalCount: selectable.length,
|
|
268
|
+
...(options.provider ? { provider: options.provider } : {}),
|
|
269
|
+
};
|
|
193
270
|
}
|
|
194
271
|
|
|
195
272
|
async function renderModelSummary(
|
|
@@ -19,6 +19,10 @@ import type {
|
|
|
19
19
|
RemoteModelCatalogEntry,
|
|
20
20
|
RemoteModelResolution,
|
|
21
21
|
} from "./types.js";
|
|
22
|
+
import type {
|
|
23
|
+
RemotePickerOptions,
|
|
24
|
+
RemotePickerResult,
|
|
25
|
+
} from "./presentation.js";
|
|
22
26
|
|
|
23
27
|
export interface RemoteModelProviderDeps {
|
|
24
28
|
/** Human label — "OpenCode" / "Kilo" — used in error strings. */
|
|
@@ -36,8 +40,8 @@ export interface RemoteModelProviderDeps {
|
|
|
36
40
|
formatUnavailableModel(model: RemoteModelCatalogEntry): string;
|
|
37
41
|
getSettingsPresentation(
|
|
38
42
|
activeModel: string,
|
|
39
|
-
|
|
40
|
-
): Promise<
|
|
43
|
+
options?: RemotePickerOptions,
|
|
44
|
+
): Promise<RemotePickerResult>;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
export interface RemoteModelProvider {
|
|
@@ -45,8 +49,8 @@ export interface RemoteModelProvider {
|
|
|
45
49
|
getModelInfo(id: string): Promise<UnifiedModelInfo | undefined>;
|
|
46
50
|
getSettingsPresentation(
|
|
47
51
|
activeModel: string,
|
|
48
|
-
|
|
49
|
-
): Promise<
|
|
52
|
+
options?: RemotePickerOptions,
|
|
53
|
+
): Promise<RemotePickerResult>;
|
|
50
54
|
getProviders(): Promise<UnifiedProviderInfo[]>;
|
|
51
55
|
getProviderModels(
|
|
52
56
|
providerId: string,
|
|
@@ -118,8 +122,8 @@ export function createRemoteModelProvider(
|
|
|
118
122
|
return entry ? toUnifiedModelInfo(entry) : undefined;
|
|
119
123
|
},
|
|
120
124
|
|
|
121
|
-
getSettingsPresentation(activeModel,
|
|
122
|
-
return deps.getSettingsPresentation(activeModel,
|
|
125
|
+
getSettingsPresentation(activeModel, options) {
|
|
126
|
+
return deps.getSettingsPresentation(activeModel, options);
|
|
123
127
|
},
|
|
124
128
|
|
|
125
129
|
async getProviders() {
|
package/src/bootstrap.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { bus } from "./core/bus/index.js";
|
|
|
26
26
|
import { appendToJournal } from "./storage/journal.js";
|
|
27
27
|
import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
|
|
28
28
|
import { initCron } from "./core/background/cron.js";
|
|
29
|
+
import { initPlanAlerts } from "./core/background/plan-alerts.js";
|
|
29
30
|
import {
|
|
30
31
|
initTriggers,
|
|
31
32
|
resumeAfterRestart as resumeTriggersAfterRestart,
|
|
@@ -420,6 +421,19 @@ export async function initBackendAndDispatcher(
|
|
|
420
421
|
log("triggers", `resumeAfterRestart failed: ${err}`),
|
|
421
422
|
);
|
|
422
423
|
|
|
424
|
+
initPlanAlerts({
|
|
425
|
+
sendMessage: async (chatId: number, text: string, stringId?: string) =>
|
|
426
|
+
resolveFrontendByNumericId(chatId, stringId, frontends).sendMessage(
|
|
427
|
+
chatId,
|
|
428
|
+
text,
|
|
429
|
+
),
|
|
430
|
+
enabled: config.planAlerts,
|
|
431
|
+
threshold: config.planAlertThreshold,
|
|
432
|
+
chatId:
|
|
433
|
+
config.planAlertChatId ??
|
|
434
|
+
(config.adminUserId ? String(config.adminUserId) : undefined),
|
|
435
|
+
});
|
|
436
|
+
|
|
423
437
|
// Soul — initialize the identity kernel singleton from config so the prompt
|
|
424
438
|
// injection / dream hooks see the right enabled state. Off by default; a
|
|
425
439
|
// failure here must never block startup.
|
package/src/cli/doctor.ts
CHANGED
|
@@ -25,9 +25,19 @@ export async function runDoctor(): Promise<void> {
|
|
|
25
25
|
config: hasConfigFile ? loadConfig() : undefined,
|
|
26
26
|
hasConfigFile,
|
|
27
27
|
});
|
|
28
|
-
|
|
28
|
+
const print = (check: (typeof report.checks)[number]): void => {
|
|
29
29
|
const detail = check.detail ? ` ${pc.dim(`(${check.detail})`)}` : "";
|
|
30
30
|
console.log(` ${DOCTOR_ICONS[check.status]} ${check.label}${detail}`);
|
|
31
|
+
};
|
|
32
|
+
for (const check of report.checks.filter((c) => !c.inactive)) print(check);
|
|
33
|
+
|
|
34
|
+
// Configured-but-idle backends describe what a switch would run into,
|
|
35
|
+
// not the state of the running deployment.
|
|
36
|
+
const idle = report.checks.filter((c) => c.inactive);
|
|
37
|
+
if (idle.length > 0) {
|
|
38
|
+
console.log(`\n ${pc.bold("Other backends")}\n`);
|
|
39
|
+
for (const check of idle) print(check);
|
|
40
|
+
console.log();
|
|
31
41
|
}
|
|
32
42
|
// Native plane, one line per embedded module with provenance.
|
|
33
43
|
for (const mod of report.native) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan rate-limit warnings.
|
|
3
|
+
*
|
|
4
|
+
* Off unless `planAlerts` is enabled. Polls the subscription's windows on a
|
|
5
|
+
* timer and messages the admin chat the first time one crosses the
|
|
6
|
+
* threshold, so a long background run doesn't walk into the ceiling
|
|
7
|
+
* unannounced.
|
|
8
|
+
*
|
|
9
|
+
* One message per window per reset cycle: the reset timestamp identifies the
|
|
10
|
+
* cycle, and a window that drops back under the threshold re-arms (which is
|
|
11
|
+
* also what covers windows the plan reports no reset for).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getPooledBackend } from "../engine/backend-controller/index.js";
|
|
15
|
+
import { log, logWarn } from "../../util/log.js";
|
|
16
|
+
import { formatSmartTimestamp } from "../../util/time.js";
|
|
17
|
+
|
|
18
|
+
const CHECK_INTERVAL_MS = 5 * 60_000;
|
|
19
|
+
|
|
20
|
+
export interface PlanAlertDeps {
|
|
21
|
+
sendMessage: (
|
|
22
|
+
chatId: number,
|
|
23
|
+
text: string,
|
|
24
|
+
stringId?: string,
|
|
25
|
+
) => Promise<void>;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
threshold: number;
|
|
28
|
+
/** Chat the warnings go to. Callers resolve the admin default. */
|
|
29
|
+
chatId: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let deps: PlanAlertDeps | undefined;
|
|
33
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
34
|
+
|
|
35
|
+
/** Window label → the reset cycle it was last warned about. */
|
|
36
|
+
const warned = new Map<string, string>();
|
|
37
|
+
|
|
38
|
+
export function initPlanAlerts(d: PlanAlertDeps): void {
|
|
39
|
+
deps = d;
|
|
40
|
+
stopPlanAlerts();
|
|
41
|
+
warned.clear();
|
|
42
|
+
if (!d.enabled) return;
|
|
43
|
+
if (!d.chatId) {
|
|
44
|
+
logWarn(
|
|
45
|
+
"bot",
|
|
46
|
+
"planAlerts is on but no chat to warn — set planAlertChatId or adminUserId",
|
|
47
|
+
);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
timer = setInterval(() => {
|
|
51
|
+
void checkPlanAlerts();
|
|
52
|
+
}, CHECK_INTERVAL_MS);
|
|
53
|
+
timer.unref?.();
|
|
54
|
+
log("bot", `Plan alerts: on (threshold ${d.threshold}%)`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function stopPlanAlerts(): void {
|
|
58
|
+
if (timer) {
|
|
59
|
+
clearInterval(timer);
|
|
60
|
+
timer = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function warningText(
|
|
65
|
+
label: string,
|
|
66
|
+
percent: number,
|
|
67
|
+
resetsAt: string | undefined,
|
|
68
|
+
): string {
|
|
69
|
+
const ts = resetsAt ? Date.parse(resetsAt) : NaN;
|
|
70
|
+
const reset = Number.isFinite(ts)
|
|
71
|
+
? `, resets ${formatSmartTimestamp(Math.round(ts / 60_000) * 60_000)}`
|
|
72
|
+
: "";
|
|
73
|
+
return `⚠️ Plan limit — ${label} at ${percent}% used${reset}.`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One pass. Exported so the timer isn't the only way to drive it. */
|
|
77
|
+
export async function checkPlanAlerts(): Promise<void> {
|
|
78
|
+
const d = deps;
|
|
79
|
+
if (!d?.enabled || !d.chatId) return;
|
|
80
|
+
|
|
81
|
+
const usage = await getPooledBackend("claude")
|
|
82
|
+
?.usage?.getPlanUsage?.()
|
|
83
|
+
.catch(() => undefined);
|
|
84
|
+
if (!usage) return;
|
|
85
|
+
|
|
86
|
+
for (const window of usage.windows) {
|
|
87
|
+
// An unwarned window that is back under the threshold re-arms.
|
|
88
|
+
if (window.percent < d.threshold) {
|
|
89
|
+
warned.delete(window.label);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const cycle = window.resetsAt ?? "";
|
|
93
|
+
if (warned.get(window.label) === cycle) continue;
|
|
94
|
+
warned.set(window.label, cycle);
|
|
95
|
+
|
|
96
|
+
const text = warningText(window.label, window.percent, window.resetsAt);
|
|
97
|
+
try {
|
|
98
|
+
await d.sendMessage(Number(d.chatId), text, d.chatId);
|
|
99
|
+
log("bot", `Plan alert sent: ${window.label} at ${window.percent}%`);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
// Keep it marked as warned — a frontend that can't deliver now won't
|
|
102
|
+
// deliver on the next tick either, and retrying would spam on recovery.
|
|
103
|
+
logWarn(
|
|
104
|
+
"bot",
|
|
105
|
+
`Plan alert delivery failed: ${err instanceof Error ? err.message : err}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function resetPlanAlertsForTest(): void {
|
|
112
|
+
stopPlanAlerts();
|
|
113
|
+
deps = undefined;
|
|
114
|
+
warned.clear();
|
|
115
|
+
}
|