@offerpilot/axiomruntime 0.0.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/README.md +185 -0
- package/dist/cli/commands/add.js +29 -0
- package/dist/cli/commands/context.js +29 -0
- package/dist/cli/commands/doctor.js +62 -0
- package/dist/cli/commands/edit.js +85 -0
- package/dist/cli/commands/help.js +16 -0
- package/dist/cli/commands/list.js +25 -0
- package/dist/cli/commands/log.js +63 -0
- package/dist/cli/commands/memory.js +241 -0
- package/dist/cli/commands/report.js +35 -0
- package/dist/cli/commands/session.js +74 -0
- package/dist/cli/commands/setup.js +86 -0
- package/dist/cli/commands/status.js +50 -0
- package/dist/cli/commands/telegram.js +701 -0
- package/dist/cli/commands/use.js +108 -0
- package/dist/cli/commands/version.js +12 -0
- package/dist/cli/index.js +276 -0
- package/dist/cli/output/table.js +22 -0
- package/dist/cli/prompts/prompt.js +37 -0
- package/dist/cli/registry.js +16 -0
- package/dist/core/config/cache-store.js +193 -0
- package/dist/core/config/json-store.js +114 -0
- package/dist/core/config/paths.js +85 -0
- package/dist/core/config/providers-store.js +89 -0
- package/dist/core/config/schema.js +60 -0
- package/dist/core/config/session-store.js +30 -0
- package/dist/core/config/usage-store.js +18 -0
- package/dist/core/context/context-service.js +186 -0
- package/dist/core/integrations/integration-state.js +105 -0
- package/dist/core/logs/log-service.js +56 -0
- package/dist/core/memory/embedding-check.js +121 -0
- package/dist/core/memory/memory-config.js +122 -0
- package/dist/core/models/model-discovery.js +430 -0
- package/dist/core/models/model-filter.js +13 -0
- package/dist/core/providers/provider-service.js +212 -0
- package/dist/core/reports/report-service.js +166 -0
- package/dist/core/runner/command-resolver.js +60 -0
- package/dist/core/runner/engine-registry.js +93 -0
- package/dist/core/runner/fallback.js +114 -0
- package/dist/core/runner/openai-usage-http.js +82 -0
- package/dist/core/runner/openai-usage-proxy.js +1 -0
- package/dist/core/runner/openai-usage-recording.js +172 -0
- package/dist/core/runner/openai-usage-responses.js +469 -0
- package/dist/core/runner/openai-usage-server.js +319 -0
- package/dist/core/runner/openai-usage-types.js +1 -0
- package/dist/core/runner/tool-runner.js +138 -0
- package/dist/core/sessions/session-service.js +47 -0
- package/dist/core/status/doctor-service.js +391 -0
- package/dist/core/status/status-service.js +60 -0
- package/dist/core/types.js +1 -0
- package/dist/core/usage/pricing.js +113 -0
- package/dist/core/usage/usage-service.js +30 -0
- package/dist/core/utils/is-record.js +3 -0
- package/dist/server/index.js +28 -0
- package/dist/server/runtime-server.js +430 -0
- package/dist/telegram/bot-registry.js +80 -0
- package/dist/telegram/bot.js +128 -0
- package/dist/telegram/config.js +235 -0
- package/dist/telegram/engine/claude-engine.js +240 -0
- package/dist/telegram/engine/codex-engine.js +437 -0
- package/dist/telegram/engine/engine-utils.js +67 -0
- package/dist/telegram/engine/process-utils.js +132 -0
- package/dist/telegram/engine/registry.js +31 -0
- package/dist/telegram/engine/types.js +1 -0
- package/dist/telegram/handler-registry.js +28 -0
- package/dist/telegram/handlers/callback.js +311 -0
- package/dist/telegram/handlers/command.js +272 -0
- package/dist/telegram/handlers/document.js +108 -0
- package/dist/telegram/handlers/memory.js +305 -0
- package/dist/telegram/handlers/message.js +701 -0
- package/dist/telegram/handlers/provider.js +332 -0
- package/dist/telegram/handlers/setup.js +527 -0
- package/dist/telegram/handlers/usage.js +124 -0
- package/dist/telegram/index.js +93 -0
- package/dist/telegram/interaction/approval.js +108 -0
- package/dist/telegram/interaction/command-menu.js +253 -0
- package/dist/telegram/interaction/formatter.js +487 -0
- package/dist/telegram/interaction/keyboards.js +145 -0
- package/dist/telegram/interaction/progress-reporter.js +160 -0
- package/dist/telegram/interaction/prompt-middleware.js +168 -0
- package/dist/telegram/interaction/result-store.js +41 -0
- package/dist/telegram/interaction/token-budget.js +21 -0
- package/dist/telegram/interaction/tool-name.js +41 -0
- package/dist/telegram/lifecycle-registry.js +47 -0
- package/dist/telegram/log.js +46 -0
- package/dist/telegram/memory/memory-inject.js +52 -0
- package/dist/telegram/memory/memory-service.js +413 -0
- package/dist/telegram/memory/memory-store.js +216 -0
- package/dist/telegram/memory/types.js +1 -0
- package/dist/telegram/network-retry.js +22 -0
- package/dist/telegram/network.js +53 -0
- package/dist/telegram/session/manager.js +229 -0
- package/dist/telegram/session/store.js +363 -0
- package/dist/telegram/session/types.js +1 -0
- package/dist/telegram/supervisor.js +57 -0
- package/dist/telegram/templates/messages.js +1 -0
- package/docs/README.md +98 -0
- package/docs/USAGE.html +853 -0
- package/package.json +57 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Agent, fetch } from "undici";
|
|
6
|
+
import { readCache, recordProviderRuntimeFailure, recordProviderRuntimeSuccess } from "../core/config/cache-store.js";
|
|
7
|
+
import { readProviders } from "../core/config/providers-store.js";
|
|
8
|
+
import { writeLog } from "../core/logs/log-service.js";
|
|
9
|
+
import { resolveProviderCandidatesForTool } from "../core/runner/fallback.js";
|
|
10
|
+
import { buildUpstreamHeaders, buildUpstreamUrl, methodMayHaveBody, streamResponse, writeResponseHead } from "../core/runner/openai-usage-http.js";
|
|
11
|
+
import { recordUsageFromResponse } from "../core/runner/openai-usage-recording.js";
|
|
12
|
+
import { buildChatCompletionsResponsesSse, isResponsesStreamRequest, shouldFallbackResponsesToChat } from "../core/runner/openai-usage-responses.js";
|
|
13
|
+
import { getToolBaseUrl } from "../core/runner/tool-runner.js";
|
|
14
|
+
import { createTelegramEngine } from "../telegram/engine/registry.js";
|
|
15
|
+
const DEFAULT_MAX_REQUEST_BYTES = 16 * 1024 * 1024;
|
|
16
|
+
const DEFAULT_HEADERS_TIMEOUT_MS = 45_000;
|
|
17
|
+
const ALLOWED_PROXY_PATHS = new Set([
|
|
18
|
+
"/v1/chat/completions",
|
|
19
|
+
"/v1/responses",
|
|
20
|
+
"/v1/embeddings"
|
|
21
|
+
]);
|
|
22
|
+
export async function startRuntimeServer(options) {
|
|
23
|
+
const host = options.host ?? "127.0.0.1";
|
|
24
|
+
const port = options.port ?? 8080;
|
|
25
|
+
const token = options.token.trim();
|
|
26
|
+
if (!token)
|
|
27
|
+
throw new Error("AI Gateway server token cannot be empty.");
|
|
28
|
+
const dispatcher = new Agent({
|
|
29
|
+
connections: 32,
|
|
30
|
+
pipelining: 0,
|
|
31
|
+
headersTimeout: normalizePositiveInteger(options.upstreamHeadersTimeoutMs, DEFAULT_HEADERS_TIMEOUT_MS),
|
|
32
|
+
bodyTimeout: 0
|
|
33
|
+
});
|
|
34
|
+
const maxRequestBytes = normalizePositiveInteger(options.maxRequestBytes, DEFAULT_MAX_REQUEST_BYTES);
|
|
35
|
+
const agentRuntime = {
|
|
36
|
+
workspaceRoot: path.resolve(options.agentWorkspaceRoot ?? path.join(process.cwd(), ".ai-gateway-workspaces")),
|
|
37
|
+
maxConcurrentRuns: normalizePositiveInteger(options.agentMaxConcurrentRuns, 2),
|
|
38
|
+
timeoutMs: normalizePositiveInteger(options.agentTimeoutMs, 10 * 60 * 1000),
|
|
39
|
+
activeRuns: 0
|
|
40
|
+
};
|
|
41
|
+
await fs.mkdir(agentRuntime.workspaceRoot, { recursive: true });
|
|
42
|
+
const server = http.createServer(async (req, res) => {
|
|
43
|
+
const requestId = normalizeRequestId(req.headers["x-request-id"]);
|
|
44
|
+
res.setHeader("x-request-id", requestId);
|
|
45
|
+
try {
|
|
46
|
+
await handleRequest({ req, res, requestId, token, maxRequestBytes, dispatcher, agentRuntime });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error instanceof RequestBodyTooLargeError && !res.headersSent) {
|
|
50
|
+
sendJson(res, 413, { error: { message: error.message, request_id: requestId } });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
54
|
+
await writeLog({
|
|
55
|
+
level: "error",
|
|
56
|
+
category: "error",
|
|
57
|
+
action: "runtime_server_request_failed",
|
|
58
|
+
message: `Runtime server request failed: ${message}`,
|
|
59
|
+
metadata: { requestId, path: req.url ?? "/" }
|
|
60
|
+
});
|
|
61
|
+
if (!res.headersSent)
|
|
62
|
+
sendJson(res, 500, { error: { message: "Internal AI Gateway error.", request_id: requestId } });
|
|
63
|
+
else
|
|
64
|
+
res.end();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
await new Promise((resolve, reject) => {
|
|
68
|
+
const onError = (error) => reject(error);
|
|
69
|
+
server.once("error", onError);
|
|
70
|
+
server.listen(port, host, () => {
|
|
71
|
+
server.off("error", onError);
|
|
72
|
+
resolve();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
const address = server.address();
|
|
76
|
+
if (!address || typeof address === "string")
|
|
77
|
+
throw new Error("Failed to bind AI Gateway runtime server.");
|
|
78
|
+
return {
|
|
79
|
+
baseUrl: `http://${displayHost(host)}:${address.port}`,
|
|
80
|
+
async close() {
|
|
81
|
+
const closeServer = server.listening
|
|
82
|
+
? new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
|
83
|
+
: Promise.resolve();
|
|
84
|
+
await Promise.all([closeServer, dispatcher.close()]);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
async function handleRequest(input) {
|
|
89
|
+
const { req, res, requestId } = input;
|
|
90
|
+
const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
|
|
91
|
+
if (req.method === "GET" && path === "/health/live") {
|
|
92
|
+
sendJson(res, 200, { status: "ok" });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (req.method === "GET" && path === "/health/ready") {
|
|
96
|
+
const providers = await readProviders();
|
|
97
|
+
sendJson(res, providers.length ? 200 : 503, {
|
|
98
|
+
status: providers.length ? "ready" : "not_ready",
|
|
99
|
+
providers: providers.length
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!isAuthorized(req.headers.authorization, input.token)) {
|
|
104
|
+
res.setHeader("www-authenticate", "Bearer");
|
|
105
|
+
sendJson(res, 401, { error: { message: "Unauthorized.", request_id: requestId } });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (req.method === "GET" && path === "/v1/models") {
|
|
109
|
+
await sendModels(res);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (req.method === "POST" && path === "/v1/agent/runs") {
|
|
113
|
+
const requestBody = await readBoundedRequestBody(req, input.maxRequestBytes);
|
|
114
|
+
await runAgent(input.res, requestBody, requestId, input.agentRuntime);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (req.method !== "POST" || !ALLOWED_PROXY_PATHS.has(path)) {
|
|
118
|
+
sendJson(res, 404, { error: { message: "Route not found.", request_id: requestId } });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const requestBody = await readBoundedRequestBody(req, input.maxRequestBytes);
|
|
122
|
+
const requestedModel = extractModel(requestBody);
|
|
123
|
+
if (!requestedModel) {
|
|
124
|
+
sendJson(res, 400, { error: { message: "Request body must include model.", request_id: requestId } });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const candidates = await resolveProviderCandidatesForTool("codex", undefined, requestedModel);
|
|
128
|
+
if (!candidates.length) {
|
|
129
|
+
sendJson(res, 503, { error: { message: `No healthy provider offers model ${requestedModel}.`, request_id: requestId } });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await proxyWithFallback({ ...input, requestBody, requestedModel, candidates });
|
|
133
|
+
}
|
|
134
|
+
async function runAgent(res, body, requestId, runtime) {
|
|
135
|
+
const request = parseAgentRunRequest(body);
|
|
136
|
+
if (!request) {
|
|
137
|
+
sendJson(res, 400, {
|
|
138
|
+
error: {
|
|
139
|
+
message: "Agent run requires engine, prompt, and a safe workspace_id.",
|
|
140
|
+
request_id: requestId
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (runtime.activeRuns >= runtime.maxConcurrentRuns) {
|
|
146
|
+
res.setHeader("retry-after", "5");
|
|
147
|
+
sendJson(res, 429, { error: { message: "Agent run concurrency limit reached.", request_id: requestId } });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const cwd = path.join(runtime.workspaceRoot, request.workspaceId);
|
|
151
|
+
await fs.mkdir(cwd, { recursive: true });
|
|
152
|
+
const engine = createTelegramEngine(request.engine);
|
|
153
|
+
const runId = stableRunId(requestId);
|
|
154
|
+
runtime.activeRuns += 1;
|
|
155
|
+
try {
|
|
156
|
+
const result = await engine.execute({
|
|
157
|
+
chatId: runId,
|
|
158
|
+
prompt: request.prompt,
|
|
159
|
+
cwd,
|
|
160
|
+
permissionMode: request.permissionMode === "workspace_write" ? "acceptEdits" : "readOnly",
|
|
161
|
+
providerName: request.providerName,
|
|
162
|
+
modelName: request.modelName,
|
|
163
|
+
timeoutMs: runtime.timeoutMs,
|
|
164
|
+
onPermission: async () => false
|
|
165
|
+
});
|
|
166
|
+
sendJson(res, 200, serializeAgentResult(requestId, request, result));
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
170
|
+
sendJson(res, 502, { error: { message, request_id: requestId } });
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
runtime.activeRuns -= 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function parseAgentRunRequest(body) {
|
|
177
|
+
try {
|
|
178
|
+
const value = JSON.parse(body.toString("utf8"));
|
|
179
|
+
const engine = value.engine;
|
|
180
|
+
const prompt = typeof value.prompt === "string" ? value.prompt.trim() : "";
|
|
181
|
+
const workspaceId = typeof value.workspace_id === "string" ? value.workspace_id.trim() : "";
|
|
182
|
+
const permissionMode = value.permission_mode === "workspace_write" ? "workspace_write" : "read_only";
|
|
183
|
+
if ((engine !== "claude" && engine !== "codex") || !prompt || prompt.length > 200_000)
|
|
184
|
+
return null;
|
|
185
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(workspaceId))
|
|
186
|
+
return null;
|
|
187
|
+
return {
|
|
188
|
+
engine,
|
|
189
|
+
prompt,
|
|
190
|
+
workspaceId,
|
|
191
|
+
permissionMode,
|
|
192
|
+
...(typeof value.provider === "string" && value.provider.trim() ? { providerName: value.provider.trim() } : {}),
|
|
193
|
+
...(typeof value.model === "string" && value.model.trim() ? { modelName: value.model.trim() } : {})
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function serializeAgentResult(requestId, request, result) {
|
|
201
|
+
return {
|
|
202
|
+
id: requestId,
|
|
203
|
+
object: "agent.run",
|
|
204
|
+
engine: request.engine,
|
|
205
|
+
workspace_id: request.workspaceId,
|
|
206
|
+
response: result.response,
|
|
207
|
+
session_id: result.sessionId ?? null,
|
|
208
|
+
provider: result.provider ?? null,
|
|
209
|
+
model: result.model ?? null,
|
|
210
|
+
usage: result.usage ?? null,
|
|
211
|
+
cost_usd: result.cost ?? null,
|
|
212
|
+
duration_ms: result.duration ?? null,
|
|
213
|
+
files_changed: result.filesChanged ?? [],
|
|
214
|
+
tool_calls: result.toolCalls ?? []
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function stableRunId(requestId) {
|
|
218
|
+
const digest = crypto.createHash("sha256").update(requestId).digest();
|
|
219
|
+
return digest.readUInt32BE(0) || 1;
|
|
220
|
+
}
|
|
221
|
+
async function proxyWithFallback(input) {
|
|
222
|
+
const records = [];
|
|
223
|
+
let lastFailure = null;
|
|
224
|
+
for (const [index, candidate] of input.candidates.entries()) {
|
|
225
|
+
const upstreamBaseUrl = getToolBaseUrl("codex", candidate.provider);
|
|
226
|
+
const upstreamUrl = buildUpstreamUrl(upstreamBaseUrl, input.req.url ?? "/");
|
|
227
|
+
try {
|
|
228
|
+
const response = await fetch(upstreamUrl, {
|
|
229
|
+
method: input.req.method,
|
|
230
|
+
headers: buildUpstreamHeaders(input.req.headers, candidate.provider.apiKey),
|
|
231
|
+
body: input.requestBody.length && methodMayHaveBody(input.req.method) ? input.requestBody : undefined,
|
|
232
|
+
dispatcher: input.dispatcher
|
|
233
|
+
});
|
|
234
|
+
const responsesStream = isResponsesStreamRequest(input.req.url ?? "/", input.requestBody);
|
|
235
|
+
if (responsesStream && !isUsableEventStream(response)) {
|
|
236
|
+
const nativeFailure = await bufferUpstream(response);
|
|
237
|
+
if (shouldFallbackResponsesToChat({
|
|
238
|
+
status: nativeFailure.status,
|
|
239
|
+
contentType: nativeFailure.contentType,
|
|
240
|
+
bodyText: nativeFailure.body.toString("utf8")
|
|
241
|
+
})) {
|
|
242
|
+
const translated = await buildChatCompletionsResponsesSse({
|
|
243
|
+
upstreamBaseUrl,
|
|
244
|
+
requestBody: input.requestBody,
|
|
245
|
+
apiKey: candidate.provider.apiKey,
|
|
246
|
+
dispatcher: input.dispatcher
|
|
247
|
+
});
|
|
248
|
+
const fallbackResponse = {
|
|
249
|
+
status: translated.status,
|
|
250
|
+
statusText: translated.statusText,
|
|
251
|
+
contentType: translated.contentType,
|
|
252
|
+
body: translated.body
|
|
253
|
+
};
|
|
254
|
+
if (isRetryableStatus(fallbackResponse.status) && index < input.candidates.length - 1) {
|
|
255
|
+
await recordCandidateFailure(candidate, fallbackResponse);
|
|
256
|
+
lastFailure = fallbackResponse;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
await sendBufferedResult(input.res, candidate, fallbackResponse, records, input.requestedModel);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (isRetryableStatus(nativeFailure.status) && index < input.candidates.length - 1) {
|
|
263
|
+
await recordCandidateFailure(candidate, nativeFailure);
|
|
264
|
+
lastFailure = nativeFailure;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
await sendBufferedResult(input.res, candidate, nativeFailure, records, input.requestedModel);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (isRetryableStatus(response.status) && index < input.candidates.length - 1) {
|
|
271
|
+
const failure = await bufferUpstream(response);
|
|
272
|
+
await recordCandidateFailure(candidate, failure);
|
|
273
|
+
lastFailure = failure;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
input.res.setHeader("x-agent-router-provider", candidate.provider.name);
|
|
277
|
+
writeResponseHead(input.res, response);
|
|
278
|
+
const captured = await streamResponse(response, input.res);
|
|
279
|
+
await recordUsageFromResponse({
|
|
280
|
+
text: captured.toString("utf8"),
|
|
281
|
+
contentType: response.headers.get("content-type") ?? "",
|
|
282
|
+
fallbackModel: input.requestedModel,
|
|
283
|
+
provider: candidate.provider.name,
|
|
284
|
+
tool: "codex",
|
|
285
|
+
records
|
|
286
|
+
});
|
|
287
|
+
if (response.ok)
|
|
288
|
+
await recordProviderRuntimeSuccess(candidate.provider.name);
|
|
289
|
+
else
|
|
290
|
+
await recordProviderRuntimeFailure(candidate.provider.name, `HTTP ${response.status}`);
|
|
291
|
+
input.res.end();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
await recordProviderRuntimeFailure(candidate.provider.name, error);
|
|
296
|
+
await logFallback(candidate, input.requestId, error);
|
|
297
|
+
if (index === input.candidates.length - 1) {
|
|
298
|
+
sendJson(input.res, 502, {
|
|
299
|
+
error: { message: "All configured AI providers failed.", request_id: input.requestId }
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (lastFailure) {
|
|
306
|
+
sendBuffered(input.res, lastFailure);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
sendJson(input.res, 503, { error: { message: "No usable AI provider.", request_id: input.requestId } });
|
|
310
|
+
}
|
|
311
|
+
async function sendModels(res) {
|
|
312
|
+
const [providers, cache] = await Promise.all([readProviders(), readCache()]);
|
|
313
|
+
const ids = new Set();
|
|
314
|
+
for (const provider of providers) {
|
|
315
|
+
if (provider.model)
|
|
316
|
+
ids.add(provider.model);
|
|
317
|
+
for (const model of cache.providers[provider.name]?.models ?? [])
|
|
318
|
+
ids.add(model);
|
|
319
|
+
}
|
|
320
|
+
sendJson(res, 200, {
|
|
321
|
+
object: "list",
|
|
322
|
+
data: [...ids].sort().map((id) => ({ id, object: "model", owned_by: "agent-router" }))
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
async function sendBufferedResult(res, candidate, result, records, fallbackModel) {
|
|
326
|
+
res.setHeader("x-agent-router-provider", candidate.provider.name);
|
|
327
|
+
await recordUsageFromResponse({
|
|
328
|
+
text: result.body.toString("utf8"),
|
|
329
|
+
contentType: result.contentType,
|
|
330
|
+
fallbackModel,
|
|
331
|
+
provider: candidate.provider.name,
|
|
332
|
+
tool: "codex",
|
|
333
|
+
records
|
|
334
|
+
});
|
|
335
|
+
if (result.status >= 200 && result.status < 400)
|
|
336
|
+
await recordProviderRuntimeSuccess(candidate.provider.name);
|
|
337
|
+
else
|
|
338
|
+
await recordProviderRuntimeFailure(candidate.provider.name, `HTTP ${result.status}`);
|
|
339
|
+
sendBuffered(res, result);
|
|
340
|
+
}
|
|
341
|
+
async function bufferUpstream(response) {
|
|
342
|
+
return {
|
|
343
|
+
status: response.status,
|
|
344
|
+
statusText: response.statusText,
|
|
345
|
+
contentType: response.headers.get("content-type") ?? "application/json; charset=utf-8",
|
|
346
|
+
body: Buffer.from(await response.arrayBuffer())
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function sendBuffered(res, result) {
|
|
350
|
+
res.statusCode = result.status;
|
|
351
|
+
res.statusMessage = result.statusText;
|
|
352
|
+
res.setHeader("content-type", result.contentType);
|
|
353
|
+
res.setHeader("content-length", String(result.body.length));
|
|
354
|
+
res.end(result.body);
|
|
355
|
+
}
|
|
356
|
+
async function recordCandidateFailure(candidate, failure) {
|
|
357
|
+
await recordProviderRuntimeFailure(candidate.provider.name, `HTTP ${failure.status}`);
|
|
358
|
+
await logFallback(candidate, crypto.randomUUID(), `HTTP ${failure.status}`);
|
|
359
|
+
}
|
|
360
|
+
async function logFallback(candidate, requestId, error) {
|
|
361
|
+
await writeLog({
|
|
362
|
+
level: "warn",
|
|
363
|
+
category: "strategy",
|
|
364
|
+
action: "runtime_server_provider_fallback",
|
|
365
|
+
message: `Provider ${candidate.provider.name} failed; trying the next candidate.`,
|
|
366
|
+
metadata: {
|
|
367
|
+
requestId,
|
|
368
|
+
provider: candidate.provider.name,
|
|
369
|
+
model: candidate.model,
|
|
370
|
+
error: error instanceof Error ? error.message : String(error)
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
async function readBoundedRequestBody(req, maxBytes) {
|
|
375
|
+
const chunks = [];
|
|
376
|
+
let total = 0;
|
|
377
|
+
for await (const chunk of req) {
|
|
378
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
379
|
+
total += buffer.length;
|
|
380
|
+
if (total > maxBytes)
|
|
381
|
+
throw new RequestBodyTooLargeError();
|
|
382
|
+
chunks.push(buffer);
|
|
383
|
+
}
|
|
384
|
+
return Buffer.concat(chunks);
|
|
385
|
+
}
|
|
386
|
+
class RequestBodyTooLargeError extends Error {
|
|
387
|
+
constructor() {
|
|
388
|
+
super("Request body exceeds configured limit.");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function extractModel(body) {
|
|
392
|
+
try {
|
|
393
|
+
const value = JSON.parse(body.toString("utf8"));
|
|
394
|
+
return typeof value.model === "string" && value.model.trim() ? value.model.trim() : null;
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function isAuthorized(header, token) {
|
|
401
|
+
if (!header?.startsWith("Bearer "))
|
|
402
|
+
return false;
|
|
403
|
+
const supplied = Buffer.from(header.slice(7));
|
|
404
|
+
const expected = Buffer.from(token);
|
|
405
|
+
return supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
|
|
406
|
+
}
|
|
407
|
+
function isRetryableStatus(status) {
|
|
408
|
+
return status === 401 || status === 403 || status === 404 || status === 408 || status === 409
|
|
409
|
+
|| status === 425 || status === 429 || status >= 500;
|
|
410
|
+
}
|
|
411
|
+
function isUsableEventStream(response) {
|
|
412
|
+
return response.ok && (response.headers.get("content-type") ?? "").toLowerCase().includes("text/event-stream");
|
|
413
|
+
}
|
|
414
|
+
function normalizePositiveInteger(value, fallback) {
|
|
415
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
416
|
+
}
|
|
417
|
+
function normalizeRequestId(value) {
|
|
418
|
+
const candidate = Array.isArray(value) ? value[0] : value;
|
|
419
|
+
return candidate?.trim().slice(0, 128) || `req_${crypto.randomUUID()}`;
|
|
420
|
+
}
|
|
421
|
+
function displayHost(host) {
|
|
422
|
+
return host === "0.0.0.0" ? "127.0.0.1" : host;
|
|
423
|
+
}
|
|
424
|
+
function sendJson(res, status, value) {
|
|
425
|
+
const body = Buffer.from(JSON.stringify(value));
|
|
426
|
+
res.statusCode = status;
|
|
427
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
428
|
+
res.setHeader("content-length", String(body.length));
|
|
429
|
+
res.end(body);
|
|
430
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsPromises from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getConfigRoot, getTelegramBotConfigPath, getTelegramBotDbPath, getTelegramBotDir, getTelegramBotLogPath, getTelegramBotPidPath, getTelegramConfigPath, getTelegramDbPath } from "../core/config/paths.js";
|
|
5
|
+
const BOT_NAME_RE = /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/;
|
|
6
|
+
export function isValidBotName(name) {
|
|
7
|
+
return BOT_NAME_RE.test(name) && name.length <= 32;
|
|
8
|
+
}
|
|
9
|
+
export function listBots() {
|
|
10
|
+
const dir = getTelegramBotDir();
|
|
11
|
+
if (!fs.existsSync(dir))
|
|
12
|
+
return [];
|
|
13
|
+
const entries = [];
|
|
14
|
+
for (const file of fs.readdirSync(dir)) {
|
|
15
|
+
if (file.endsWith(".json")) {
|
|
16
|
+
const name = file.slice(0, -5);
|
|
17
|
+
entries.push({ name, configPath: path.join(dir, file) });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
21
|
+
}
|
|
22
|
+
export function botExists(name) {
|
|
23
|
+
return fs.existsSync(getTelegramBotConfigPath(name));
|
|
24
|
+
}
|
|
25
|
+
export function isBotRunning(name) {
|
|
26
|
+
const pidPath = getTelegramBotPidPath(name);
|
|
27
|
+
if (!fs.existsSync(pidPath))
|
|
28
|
+
return { running: false, pid: null };
|
|
29
|
+
const raw = fs.readFileSync(pidPath, "utf8").trim();
|
|
30
|
+
const pid = Number(raw);
|
|
31
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
32
|
+
return { running: false, pid: null };
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return { running: true, pid };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return { running: false, pid };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function migrateLegacyConfig() {
|
|
42
|
+
const legacyPath = getTelegramConfigPath();
|
|
43
|
+
if (!fs.existsSync(legacyPath))
|
|
44
|
+
return;
|
|
45
|
+
if (botExists("default"))
|
|
46
|
+
return;
|
|
47
|
+
const dir = getTelegramBotDir();
|
|
48
|
+
await fsPromises.mkdir(dir, { recursive: true });
|
|
49
|
+
await fsPromises.copyFile(legacyPath, getTelegramBotConfigPath("default"));
|
|
50
|
+
}
|
|
51
|
+
export async function removeBotFiles(name, configuredDbPath) {
|
|
52
|
+
const managedConfiguredDbPath = configuredDbPath && isManagedConfigPath(configuredDbPath)
|
|
53
|
+
? path.resolve(configuredDbPath)
|
|
54
|
+
: undefined;
|
|
55
|
+
const dbPaths = new Set([
|
|
56
|
+
getTelegramBotDbPath(name),
|
|
57
|
+
...(managedConfiguredDbPath ? [managedConfiguredDbPath] : []),
|
|
58
|
+
...(name === "default" ? [getTelegramDbPath()] : [])
|
|
59
|
+
]);
|
|
60
|
+
const paths = [
|
|
61
|
+
getTelegramBotConfigPath(name),
|
|
62
|
+
getTelegramBotPidPath(name),
|
|
63
|
+
getTelegramBotLogPath(name),
|
|
64
|
+
...[...dbPaths].flatMap((dbPath) => [dbPath, `${dbPath}-shm`, `${dbPath}-wal`]),
|
|
65
|
+
...(name === "default" ? [getTelegramConfigPath()] : [])
|
|
66
|
+
];
|
|
67
|
+
for (const p of paths) {
|
|
68
|
+
try {
|
|
69
|
+
await fsPromises.unlink(p);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error.code !== "ENOENT")
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function isManagedConfigPath(filePath) {
|
|
78
|
+
const relative = path.relative(getConfigRoot(), path.resolve(filePath));
|
|
79
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
80
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Bot } from "grammy";
|
|
2
|
+
import { loadBotConfig, loadTelegramConfig, updateBotConfig, updateTelegramConfig } from "./config.js";
|
|
3
|
+
import { createTelegramEngines } from "./engine/registry.js";
|
|
4
|
+
import { registerCallbackHandler } from "./handlers/callback.js";
|
|
5
|
+
import { registerCommands } from "./handlers/command.js";
|
|
6
|
+
import { registerDocumentHandler } from "./handlers/document.js";
|
|
7
|
+
import { registerMessageHandler } from "./handlers/message.js";
|
|
8
|
+
import { isProviderSecretInput } from "./handlers/provider.js";
|
|
9
|
+
import { installTelegramHandlers, registerTelegramHandler } from "./handler-registry.js";
|
|
10
|
+
import { ApprovalManager } from "./interaction/approval.js";
|
|
11
|
+
import { safeResetChatCommandMenu, safeSetChatCommandMenu, safeSetDefaultCommandMenu } from "./interaction/command-menu.js";
|
|
12
|
+
import { getBotIdFromToken, MemoryService } from "./memory/memory-service.js";
|
|
13
|
+
import { SessionManager } from "./session/manager.js";
|
|
14
|
+
import { SessionStore } from "./session/store.js";
|
|
15
|
+
import { writeTelegramLog } from "./log.js";
|
|
16
|
+
import { configureTelegramNetwork, createTelegramApiClientOptions } from "./network.js";
|
|
17
|
+
export async function createBot(botName) {
|
|
18
|
+
const config = botName ? await loadBotConfig(botName) : await loadTelegramConfig();
|
|
19
|
+
if (!config.token) {
|
|
20
|
+
throw new Error("Telegram token is not configured. Run `ai telegram config set token <token>`.");
|
|
21
|
+
}
|
|
22
|
+
if (!config.allowAllUsers && !config.allowedUserIds.length) {
|
|
23
|
+
throw new Error("No Telegram users are allowed. Run `ai telegram config set allowedUserIds <id>` or `ai telegram config set allowAllUsers true`.");
|
|
24
|
+
}
|
|
25
|
+
const proxyUrl = configureTelegramNetwork(config);
|
|
26
|
+
if (proxyUrl) {
|
|
27
|
+
await writeTelegramLog({
|
|
28
|
+
action: "proxy_enabled",
|
|
29
|
+
message: `Telegram API proxy enabled: ${proxyUrl}`
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const bot = new Bot(config.token, {
|
|
33
|
+
client: createTelegramApiClientOptions(proxyUrl)
|
|
34
|
+
});
|
|
35
|
+
const store = new SessionStore(config.dbPath);
|
|
36
|
+
const sessionManager = new SessionManager(store, config);
|
|
37
|
+
const memoryService = new MemoryService({ botId: getBotIdFromToken(config.token) });
|
|
38
|
+
const engines = createTelegramEngines();
|
|
39
|
+
const approvalManager = new ApprovalManager(bot);
|
|
40
|
+
sessionManager.cleanupExpired();
|
|
41
|
+
const orphanCount = sessionManager.resetOrphanSessions();
|
|
42
|
+
if (orphanCount > 0) {
|
|
43
|
+
void writeTelegramLog({
|
|
44
|
+
action: "orphan_reset",
|
|
45
|
+
message: `Reset ${orphanCount} orphan session(s) stuck in running/waiting_approval`
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
memoryService.cleanupExpired();
|
|
49
|
+
void memoryService.backup();
|
|
50
|
+
bot.catch((error) => {
|
|
51
|
+
void writeTelegramLog({
|
|
52
|
+
level: "error",
|
|
53
|
+
action: "bot_error",
|
|
54
|
+
message: error.error instanceof Error ? error.error.message : String(error.error),
|
|
55
|
+
metadata: { updateId: error.ctx.update.update_id }
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
bot.use(async (ctx, next) => {
|
|
59
|
+
const text = ctx.message && "text" in ctx.message ? ctx.message.text : undefined;
|
|
60
|
+
const loggedText = isProviderSecretInput(ctx.chat?.id, ctx.me.id) ? "[REDACTED_PROVIDER_API_KEY]" : text;
|
|
61
|
+
await writeTelegramLog({
|
|
62
|
+
action: "update_received",
|
|
63
|
+
message: loggedText ? `Received Telegram message: ${loggedText.slice(0, 80)}` : `Received Telegram update ${ctx.update.update_id}`,
|
|
64
|
+
metadata: {
|
|
65
|
+
updateId: ctx.update.update_id,
|
|
66
|
+
userId: ctx.from?.id,
|
|
67
|
+
chatId: ctx.chat?.id,
|
|
68
|
+
messageType: ctx.message ? "message" : "other",
|
|
69
|
+
command: text?.startsWith("/") ? text.split(/\s+/, 1)[0] : undefined
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const userId = ctx.from?.id;
|
|
73
|
+
if (!userId || (!config.allowAllUsers && !config.allowedUserIds.includes(userId))) {
|
|
74
|
+
await ctx.reply("Access denied.");
|
|
75
|
+
await writeTelegramLog({
|
|
76
|
+
level: "warn",
|
|
77
|
+
action: "access_denied",
|
|
78
|
+
message: `Denied Telegram user ${userId ?? "(missing)"}`,
|
|
79
|
+
metadata: { userId, chatId: ctx.chat?.id }
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await next();
|
|
84
|
+
});
|
|
85
|
+
const deps = {
|
|
86
|
+
bot,
|
|
87
|
+
sessionManager,
|
|
88
|
+
claudeEngine: engines.claude,
|
|
89
|
+
codexEngine: engines.codex,
|
|
90
|
+
approvalManager,
|
|
91
|
+
config,
|
|
92
|
+
engines,
|
|
93
|
+
memoryService,
|
|
94
|
+
updateConfig: (patch) => botName
|
|
95
|
+
? updateBotConfig(botName, patch)
|
|
96
|
+
: updateTelegramConfig(patch)
|
|
97
|
+
};
|
|
98
|
+
registerBuiltInTelegramHandlers();
|
|
99
|
+
installTelegramHandlers(bot, deps);
|
|
100
|
+
await safeSetDefaultCommandMenu(bot);
|
|
101
|
+
await refreshStoredChatCommandMenus(bot, sessionManager);
|
|
102
|
+
return {
|
|
103
|
+
bot,
|
|
104
|
+
config,
|
|
105
|
+
store,
|
|
106
|
+
sessionManager,
|
|
107
|
+
memoryService,
|
|
108
|
+
engines
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function registerBuiltInTelegramHandlers() {
|
|
112
|
+
registerTelegramHandler("commands", registerCommands);
|
|
113
|
+
registerTelegramHandler("callbacks", registerCallbackHandler);
|
|
114
|
+
registerTelegramHandler("documents", registerDocumentHandler);
|
|
115
|
+
registerTelegramHandler("messages", registerMessageHandler);
|
|
116
|
+
}
|
|
117
|
+
async function refreshStoredChatCommandMenus(bot, sessionManager) {
|
|
118
|
+
const sessions = sessionManager.getActiveSessions();
|
|
119
|
+
for (const session of sessions) {
|
|
120
|
+
if (session.setupStep === "ready") {
|
|
121
|
+
const customCommands = sessionManager.listCustomCommands(session.chatId, session.cwd);
|
|
122
|
+
await safeSetChatCommandMenu(bot, session.chatId, session.engine, customCommands);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
await safeResetChatCommandMenu(bot, session.chatId);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|