@offerpilot/axiomruntime 0.0.3 → 0.0.5
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 +230 -117
- package/dist/cli/commands/image.js +75 -0
- package/dist/cli/index.js +11 -2
- package/dist/core/context/context-service.js +3 -3
- package/dist/core/images/image-generation-service.js +258 -0
- package/dist/core/integrations/codex-provider-config.js +10 -4
- package/dist/core/runner/openai-usage-http.js +55 -12
- package/dist/core/runner/openai-usage-proxy.js +1 -0
- package/dist/core/runner/openai-usage-recording.js +38 -6
- package/dist/core/runner/openai-usage-server.js +14 -6
- package/dist/core/runner/openai-usage-summary.js +18 -0
- package/dist/core/runner/tool-runner.js +88 -41
- package/dist/telegram/engine/claude-engine.js +36 -34
- package/dist/telegram/engine/codex-engine.js +18 -44
- package/dist/telegram/engine/engine-utils.js +33 -17
- package/docs/USAGE.html +24 -2
- package/package.json +2 -2
- package/docs/README.md +0 -98
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isProviderCircuitOpen, readCache, recordProviderRuntimeFailure, recordProviderRuntimeSuccess } from "../config/cache-store.js";
|
|
5
|
+
import { readProviders } from "../config/providers-store.js";
|
|
6
|
+
import { writeLog } from "../logs/log-service.js";
|
|
7
|
+
import { startOpenAiUsageProxy } from "../runner/openai-usage-proxy.js";
|
|
8
|
+
import { isRecord } from "../utils/is-record.js";
|
|
9
|
+
const IMAGE_REQUEST_TIMEOUT_MS = 120_000;
|
|
10
|
+
const MAX_IMAGE_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
11
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
12
|
+
export async function resolveImageProviderCandidates(selection) {
|
|
13
|
+
const target = typeof selection === "string" ? selection.trim() : "";
|
|
14
|
+
let providerName = typeof selection === "string" ? "" : selection.providerName?.trim() ?? "";
|
|
15
|
+
let requestedModel = typeof selection === "string" ? "" : selection.model?.trim() ?? "";
|
|
16
|
+
if (!target && !providerName && !requestedModel)
|
|
17
|
+
throw new Error("Image provider or model is required.");
|
|
18
|
+
const [providers, cache] = await Promise.all([readProviders(), readCache()]);
|
|
19
|
+
if (!providers.length) {
|
|
20
|
+
throw new Error("No providers configured. Run `ai add` first.");
|
|
21
|
+
}
|
|
22
|
+
if (target) {
|
|
23
|
+
const targetProvider = providers.find((provider) => provider.name === target);
|
|
24
|
+
if (targetProvider)
|
|
25
|
+
providerName = targetProvider.name;
|
|
26
|
+
else
|
|
27
|
+
requestedModel = target;
|
|
28
|
+
}
|
|
29
|
+
const preferredProvider = providerName ? providers.find((provider) => provider.name === providerName) : undefined;
|
|
30
|
+
if (providerName && !preferredProvider) {
|
|
31
|
+
throw new Error(`Provider not found: ${providerName}`);
|
|
32
|
+
}
|
|
33
|
+
const targetModel = requestedModel || preferredProvider?.model.trim() || "";
|
|
34
|
+
if (!targetModel) {
|
|
35
|
+
throw new Error(`Provider ${providerName} has no default image model. Configure its model first.`);
|
|
36
|
+
}
|
|
37
|
+
const candidates = providers.flatMap((provider) => {
|
|
38
|
+
const cached = cache.providers[provider.name];
|
|
39
|
+
if (isProviderCircuitOpen(cached))
|
|
40
|
+
return [];
|
|
41
|
+
if (cached?.status === "error" && !cached.circuitOpenUntil)
|
|
42
|
+
return [];
|
|
43
|
+
const knownModels = cached?.models ?? [];
|
|
44
|
+
const offersModel = knownModels.includes(targetModel)
|
|
45
|
+
|| (!knownModels.length && provider.model === targetModel);
|
|
46
|
+
return offersModel ? [{ provider, model: targetModel }] : [];
|
|
47
|
+
});
|
|
48
|
+
if (!preferredProvider)
|
|
49
|
+
return candidates;
|
|
50
|
+
return [
|
|
51
|
+
...candidates.filter((candidate) => candidate.provider.name === preferredProvider.name),
|
|
52
|
+
...candidates.filter((candidate) => candidate.provider.name !== preferredProvider.name)
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
export async function generateImage(input) {
|
|
56
|
+
const prompt = input.prompt.trim();
|
|
57
|
+
if (!prompt)
|
|
58
|
+
throw new Error("Image prompt is required.");
|
|
59
|
+
const selection = input.target ?? { providerName: input.provider, model: input.model };
|
|
60
|
+
const candidates = await resolveImageProviderCandidates(selection);
|
|
61
|
+
if (!candidates.length) {
|
|
62
|
+
const selectionName = input.target ?? input.provider ?? input.model ?? "(unknown)";
|
|
63
|
+
throw new Error(`No usable provider offers image model for ${selectionName}. Run \`ai status\` to refresh model lists.`);
|
|
64
|
+
}
|
|
65
|
+
const failures = [];
|
|
66
|
+
for (const candidate of candidates) {
|
|
67
|
+
try {
|
|
68
|
+
const result = await generateWithProvider(candidate, prompt, input.outputDir ?? process.cwd());
|
|
69
|
+
await recordProviderRuntimeSuccess(candidate.provider.name);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
const message = summarizeError(error);
|
|
74
|
+
failures.push(`${candidate.provider.name}: ${message}`);
|
|
75
|
+
await recordProviderRuntimeFailure(candidate.provider.name, message);
|
|
76
|
+
await writeLog({
|
|
77
|
+
level: "warn",
|
|
78
|
+
category: "provider",
|
|
79
|
+
action: "image_generation_provider_failed",
|
|
80
|
+
message: `Image generation failed for ${candidate.provider.name} / ${candidate.model}: ${message}`,
|
|
81
|
+
metadata: { provider: candidate.provider.name, model: candidate.model }
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`Image generation failed. ${failures.join("; ")}`);
|
|
86
|
+
}
|
|
87
|
+
async function generateWithProvider(candidate, prompt, outputDir) {
|
|
88
|
+
const usageProxy = await startOpenAiUsageProxy({
|
|
89
|
+
provider: candidate.provider,
|
|
90
|
+
model: candidate.model,
|
|
91
|
+
tool: "image",
|
|
92
|
+
engine: "codex",
|
|
93
|
+
upstreamHeadersTimeoutMs: IMAGE_REQUEST_TIMEOUT_MS
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetchWithTimeout(`${usageProxy.baseUrl}/images/generations`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
authorization: `Bearer ${candidate.provider.apiKey}`,
|
|
100
|
+
"content-type": "application/json"
|
|
101
|
+
},
|
|
102
|
+
body: JSON.stringify({ model: candidate.model, prompt, n: 1 })
|
|
103
|
+
});
|
|
104
|
+
const text = await readBoundedResponseText(response, MAX_IMAGE_RESPONSE_BYTES);
|
|
105
|
+
const payload = parseJsonResponse(text);
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(readProviderError(payload) ?? `Provider returned HTTP ${response.status}.`);
|
|
108
|
+
}
|
|
109
|
+
const image = readFirstImage(payload);
|
|
110
|
+
if (!image)
|
|
111
|
+
throw new Error("Provider response did not contain an image.");
|
|
112
|
+
const baseResult = {
|
|
113
|
+
provider: candidate.provider.name,
|
|
114
|
+
model: candidate.model,
|
|
115
|
+
...(image.revisedPrompt ? { revisedPrompt: image.revisedPrompt } : {})
|
|
116
|
+
};
|
|
117
|
+
if (image.base64) {
|
|
118
|
+
const buffer = decodeImage(image.base64);
|
|
119
|
+
const extension = detectImageExtension(buffer);
|
|
120
|
+
const filePath = await writeImageFile(outputDir, buffer, extension);
|
|
121
|
+
await writeLog({
|
|
122
|
+
category: "usage",
|
|
123
|
+
action: "image_generated",
|
|
124
|
+
message: `Generated image with ${candidate.provider.name} / ${candidate.model}.`,
|
|
125
|
+
metadata: { provider: candidate.provider.name, model: candidate.model, filePath, bytes: buffer.length }
|
|
126
|
+
});
|
|
127
|
+
return { ...baseResult, filePath };
|
|
128
|
+
}
|
|
129
|
+
await writeLog({
|
|
130
|
+
category: "usage",
|
|
131
|
+
action: "image_generated_url",
|
|
132
|
+
message: `Generated image URL with ${candidate.provider.name} / ${candidate.model}.`,
|
|
133
|
+
metadata: { provider: candidate.provider.name, model: candidate.model, urlHost: new URL(image.url).host }
|
|
134
|
+
});
|
|
135
|
+
return { ...baseResult, url: image.url };
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await usageProxy.close().catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function fetchWithTimeout(url, init) {
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
const timeout = setTimeout(() => controller.abort(), IMAGE_REQUEST_TIMEOUT_MS);
|
|
144
|
+
try {
|
|
145
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
clearTimeout(timeout);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function readBoundedResponseText(response, maxBytes) {
|
|
152
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
153
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
154
|
+
throw new Error(`Provider image response exceeds ${maxBytes} bytes.`);
|
|
155
|
+
}
|
|
156
|
+
if (!response.body)
|
|
157
|
+
return "";
|
|
158
|
+
const chunks = [];
|
|
159
|
+
let bytes = 0;
|
|
160
|
+
for await (const chunk of response.body) {
|
|
161
|
+
const buffer = Buffer.from(chunk);
|
|
162
|
+
bytes += buffer.length;
|
|
163
|
+
if (bytes > maxBytes)
|
|
164
|
+
throw new Error(`Provider image response exceeds ${maxBytes} bytes.`);
|
|
165
|
+
chunks.push(buffer);
|
|
166
|
+
}
|
|
167
|
+
return Buffer.concat(chunks, bytes).toString("utf8");
|
|
168
|
+
}
|
|
169
|
+
function parseJsonResponse(text) {
|
|
170
|
+
try {
|
|
171
|
+
return JSON.parse(text);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new Error("Provider returned an invalid image response.");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function readFirstImage(payload) {
|
|
178
|
+
if (!isRecord(payload) || !Array.isArray(payload.data) || !isRecord(payload.data[0]))
|
|
179
|
+
return null;
|
|
180
|
+
const item = payload.data[0];
|
|
181
|
+
const base64 = typeof item.b64_json === "string" && item.b64_json.trim() ? item.b64_json.trim() : undefined;
|
|
182
|
+
const url = typeof item.url === "string" && isSafeImageUrl(item.url) ? item.url : undefined;
|
|
183
|
+
const revisedPrompt = typeof item.revised_prompt === "string" && item.revised_prompt.trim()
|
|
184
|
+
? item.revised_prompt.trim()
|
|
185
|
+
: undefined;
|
|
186
|
+
if (!base64 && !url)
|
|
187
|
+
return null;
|
|
188
|
+
return { base64, url, revisedPrompt };
|
|
189
|
+
}
|
|
190
|
+
function isSafeImageUrl(value) {
|
|
191
|
+
try {
|
|
192
|
+
const url = new URL(value);
|
|
193
|
+
return url.protocol === "https:" && !url.username && !url.password;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function decodeImage(value) {
|
|
200
|
+
const normalized = value.replace(/\s+/g, "");
|
|
201
|
+
if (!normalized || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
|
|
202
|
+
throw new Error("Provider returned invalid base64 image data.");
|
|
203
|
+
}
|
|
204
|
+
const buffer = Buffer.from(normalized, "base64");
|
|
205
|
+
if (!buffer.length)
|
|
206
|
+
throw new Error("Provider returned an empty image.");
|
|
207
|
+
if (buffer.length > MAX_IMAGE_BYTES)
|
|
208
|
+
throw new Error(`Generated image exceeds ${MAX_IMAGE_BYTES} bytes.`);
|
|
209
|
+
return buffer;
|
|
210
|
+
}
|
|
211
|
+
function detectImageExtension(buffer) {
|
|
212
|
+
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
213
|
+
return "png";
|
|
214
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
|
|
215
|
+
return "jpg";
|
|
216
|
+
if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP")
|
|
217
|
+
return "webp";
|
|
218
|
+
if (["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii")))
|
|
219
|
+
return "gif";
|
|
220
|
+
throw new Error("Provider returned an unsupported image format.");
|
|
221
|
+
}
|
|
222
|
+
async function writeImageFile(outputDir, buffer, extension) {
|
|
223
|
+
const resolvedDir = path.resolve(outputDir);
|
|
224
|
+
await fs.mkdir(resolvedDir, { recursive: true });
|
|
225
|
+
const fileName = `axiom-image-${formatTimestamp(new Date())}-${randomUUID().slice(0, 8)}.${extension}`;
|
|
226
|
+
const filePath = path.join(resolvedDir, fileName);
|
|
227
|
+
const temporaryPath = path.join(resolvedDir, `.${fileName}.${process.pid}.tmp`);
|
|
228
|
+
try {
|
|
229
|
+
await fs.writeFile(temporaryPath, buffer, { mode: 0o600, flag: "wx" });
|
|
230
|
+
await fs.rename(temporaryPath, filePath);
|
|
231
|
+
return filePath;
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function formatTimestamp(date) {
|
|
239
|
+
return date.toISOString().replace(/[-:]/g, "").replace(/\..+$/, "").replace("T", "-");
|
|
240
|
+
}
|
|
241
|
+
function readProviderError(payload) {
|
|
242
|
+
if (!isRecord(payload))
|
|
243
|
+
return null;
|
|
244
|
+
const error = payload.error;
|
|
245
|
+
if (typeof error === "string" && error.trim())
|
|
246
|
+
return error.trim().slice(0, 500);
|
|
247
|
+
if (isRecord(error) && typeof error.message === "string" && error.message.trim()) {
|
|
248
|
+
return error.message.trim().slice(0, 500);
|
|
249
|
+
}
|
|
250
|
+
return typeof payload.message === "string" && payload.message.trim()
|
|
251
|
+
? payload.message.trim().slice(0, 500)
|
|
252
|
+
: null;
|
|
253
|
+
}
|
|
254
|
+
function summarizeError(error) {
|
|
255
|
+
if (error instanceof Error && error.name === "AbortError")
|
|
256
|
+
return "Image request timed out.";
|
|
257
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
|
258
|
+
}
|
|
@@ -5,6 +5,7 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import lockfile from "proper-lockfile";
|
|
7
7
|
import { resolveToolCommand } from "../runner/command-resolver.js";
|
|
8
|
+
import { getRunnerEngine } from "../runner/engine-registry.js";
|
|
8
9
|
const LOCK_OPTIONS = {
|
|
9
10
|
realpath: false,
|
|
10
11
|
stale: 10_000,
|
|
@@ -128,6 +129,7 @@ export function buildCodexProviderConfig(current, provider, providerId, model =
|
|
|
128
129
|
if (!provider.apiKey.trim())
|
|
129
130
|
throw new Error(`Provider ${provider.name} has no API key.`);
|
|
130
131
|
const selectedModel = assertCodexGptModel(model);
|
|
132
|
+
const baseUrl = getRunnerEngine("codex").normalizeBaseUrl(provider.baseUrl);
|
|
131
133
|
const eol = current.includes("\r\n") ? "\r\n" : "\n";
|
|
132
134
|
let next = removeProviderTables(current, providerId);
|
|
133
135
|
next = setTopLevelTomlString(next, "cli_auth_credentials_store", "file");
|
|
@@ -136,7 +138,7 @@ export function buildCodexProviderConfig(current, provider, providerId, model =
|
|
|
136
138
|
const table = [
|
|
137
139
|
`[model_providers.${providerId}]`,
|
|
138
140
|
`name = ${tomlString(provider.name)}`,
|
|
139
|
-
`base_url = ${tomlString(
|
|
141
|
+
`base_url = ${tomlString(baseUrl)}`,
|
|
140
142
|
`env_key = ${tomlString("OPENAI_API_KEY")}`,
|
|
141
143
|
`wire_api = ${tomlString("responses")}`
|
|
142
144
|
].join(eol);
|
|
@@ -253,7 +255,11 @@ export async function scheduleCodexAppRestart(options = {}) {
|
|
|
253
255
|
" setTimeout(() => spawnSync('/usr/bin/open', [appPath], { stdio: 'ignore' }), 1000);",
|
|
254
256
|
"}, 750);"
|
|
255
257
|
].join("\n");
|
|
256
|
-
|
|
258
|
+
const restartEnvironment = { ...process.env };
|
|
259
|
+
delete restartEnvironment.OPENAI_API_KEY;
|
|
260
|
+
(options.spawnDetached ?? defaultSpawnDetached)(process.execPath, ["-e", helper], {
|
|
261
|
+
env: restartEnvironment
|
|
262
|
+
});
|
|
257
263
|
return { scheduled: true, appPath };
|
|
258
264
|
}
|
|
259
265
|
async function configureChatGpt(configPath, authPath, codexHome, runner) {
|
|
@@ -515,8 +521,8 @@ async function fileExists(filePath) {
|
|
|
515
521
|
return false;
|
|
516
522
|
}
|
|
517
523
|
}
|
|
518
|
-
function defaultSpawnDetached(command, args) {
|
|
519
|
-
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
524
|
+
function defaultSpawnDetached(command, args, options = {}) {
|
|
525
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", ...options });
|
|
520
526
|
child.unref();
|
|
521
527
|
}
|
|
522
528
|
function runProcess(command, args) {
|
|
@@ -14,20 +14,22 @@ const RESPONSE_HEADERS_TO_DROP = new Set([
|
|
|
14
14
|
"content-encoding",
|
|
15
15
|
"content-length"
|
|
16
16
|
]);
|
|
17
|
-
export function buildUpstreamUrl(upstreamBaseUrl, incomingUrl) {
|
|
17
|
+
export function buildUpstreamUrl(upstreamBaseUrl, incomingUrl, options = {}) {
|
|
18
18
|
const local = new URL(incomingUrl, "http://127.0.0.1");
|
|
19
19
|
let suffix = local.pathname;
|
|
20
|
-
if (
|
|
21
|
-
suffix
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
if (options.stripV1Prefix !== false) {
|
|
21
|
+
if (suffix === "/v1") {
|
|
22
|
+
suffix = "";
|
|
23
|
+
}
|
|
24
|
+
else if (suffix.startsWith("/v1/")) {
|
|
25
|
+
suffix = suffix.slice(3);
|
|
26
|
+
}
|
|
25
27
|
}
|
|
26
28
|
const upstream = new URL(`${upstreamBaseUrl.replace(/\/+$/, "")}${suffix}`);
|
|
27
29
|
upstream.search = local.search;
|
|
28
30
|
return upstream.toString();
|
|
29
31
|
}
|
|
30
|
-
export function buildUpstreamHeaders(headers, apiKey) {
|
|
32
|
+
export function buildUpstreamHeaders(headers, apiKey, options = {}) {
|
|
31
33
|
const next = {};
|
|
32
34
|
for (const [key, value] of Object.entries(headers)) {
|
|
33
35
|
const normalized = key.toLowerCase();
|
|
@@ -40,7 +42,12 @@ export function buildUpstreamHeaders(headers, apiKey) {
|
|
|
40
42
|
next[key] = value;
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
|
-
|
|
45
|
+
if (options.includeAuthorization !== false) {
|
|
46
|
+
next.authorization = `Bearer ${apiKey}`;
|
|
47
|
+
}
|
|
48
|
+
if (options.includeAnthropicApiKey) {
|
|
49
|
+
next["x-api-key"] = apiKey;
|
|
50
|
+
}
|
|
44
51
|
next["accept-encoding"] = "identity";
|
|
45
52
|
return next;
|
|
46
53
|
}
|
|
@@ -53,21 +60,57 @@ export function writeResponseHead(res, response) {
|
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
}
|
|
56
|
-
export async function streamResponse(response, res) {
|
|
63
|
+
export async function streamResponse(response, res, options = {}) {
|
|
64
|
+
const maxCaptureBytes = normalizeCaptureLimit(options.maxCaptureBytes);
|
|
57
65
|
const chunks = [];
|
|
58
66
|
let capturedBytes = 0;
|
|
67
|
+
const tailChunks = [];
|
|
68
|
+
let tailBytes = 0;
|
|
69
|
+
let responseBytes = 0;
|
|
70
|
+
const tailLimit = Math.floor(maxCaptureBytes / 2);
|
|
59
71
|
if (!response.body)
|
|
60
72
|
return Buffer.alloc(0);
|
|
61
73
|
for await (const chunk of response.body) {
|
|
62
74
|
const buffer = Buffer.from(chunk);
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
responseBytes += buffer.length;
|
|
76
|
+
if (capturedBytes < maxCaptureBytes) {
|
|
77
|
+
const remaining = maxCaptureBytes - capturedBytes;
|
|
65
78
|
chunks.push(buffer.length > remaining ? buffer.subarray(0, remaining) : buffer);
|
|
66
79
|
capturedBytes += Math.min(buffer.length, remaining);
|
|
67
80
|
}
|
|
81
|
+
tailBytes = appendTailChunk(tailChunks, buffer, tailLimit, tailBytes);
|
|
68
82
|
res.write(buffer);
|
|
69
83
|
}
|
|
70
|
-
|
|
84
|
+
const captured = Buffer.concat(chunks, capturedBytes);
|
|
85
|
+
if (responseBytes <= maxCaptureBytes)
|
|
86
|
+
return captured;
|
|
87
|
+
return Buffer.concat([
|
|
88
|
+
captured.subarray(0, tailLimit),
|
|
89
|
+
Buffer.from("\n"),
|
|
90
|
+
Buffer.concat(tailChunks, tailBytes)
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
function normalizeCaptureLimit(value) {
|
|
94
|
+
if (!Number.isFinite(value) || value === undefined || value <= 0)
|
|
95
|
+
return MAX_USAGE_CAPTURE_BYTES;
|
|
96
|
+
return Math.max(1024, Math.floor(value));
|
|
97
|
+
}
|
|
98
|
+
function appendTailChunk(chunks, buffer, limit, currentBytes) {
|
|
99
|
+
if (buffer.length >= limit) {
|
|
100
|
+
chunks.splice(0, chunks.length, buffer.subarray(buffer.length - limit));
|
|
101
|
+
return limit;
|
|
102
|
+
}
|
|
103
|
+
chunks.push(buffer);
|
|
104
|
+
let bytes = currentBytes + buffer.length;
|
|
105
|
+
while (chunks.length && bytes - chunks[0].length >= limit) {
|
|
106
|
+
bytes -= chunks.shift().length;
|
|
107
|
+
}
|
|
108
|
+
if (bytes > limit && chunks.length) {
|
|
109
|
+
const excess = bytes - limit;
|
|
110
|
+
chunks[0] = chunks[0].subarray(excess);
|
|
111
|
+
bytes = limit;
|
|
112
|
+
}
|
|
113
|
+
return bytes;
|
|
71
114
|
}
|
|
72
115
|
export async function readRequestBody(req) {
|
|
73
116
|
const chunks = [];
|
|
@@ -59,15 +59,47 @@ export async function recordUsageFromResponse(input) {
|
|
|
59
59
|
}
|
|
60
60
|
function extractBestUsageCandidate(text, contentType, fallbackModel) {
|
|
61
61
|
const values = contentType.includes("text/event-stream") ? parseSseValues(text) : parseJsonValues(text);
|
|
62
|
-
|
|
62
|
+
const candidates = [];
|
|
63
63
|
for (const value of values) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
candidates.push(...collectUsageCandidates(value, fallbackModel));
|
|
65
|
+
}
|
|
66
|
+
return mergeUsageCandidates(candidates, fallbackModel);
|
|
67
|
+
}
|
|
68
|
+
function mergeUsageCandidates(candidates, fallbackModel) {
|
|
69
|
+
if (!candidates.length)
|
|
70
|
+
return null;
|
|
71
|
+
let inputTokens = 0;
|
|
72
|
+
let outputTokens = 0;
|
|
73
|
+
let totalTokens = 0;
|
|
74
|
+
let cacheReadInputTokens;
|
|
75
|
+
let model = fallbackModel;
|
|
76
|
+
let modelCandidateTokens = -1;
|
|
77
|
+
let costUsd;
|
|
78
|
+
for (const candidate of candidates) {
|
|
79
|
+
inputTokens = Math.max(inputTokens, candidate.usage.inputTokens);
|
|
80
|
+
outputTokens = Math.max(outputTokens, candidate.usage.outputTokens);
|
|
81
|
+
totalTokens = Math.max(totalTokens, candidate.usage.totalTokens);
|
|
82
|
+
if (candidate.usage.cacheReadInputTokens !== undefined) {
|
|
83
|
+
cacheReadInputTokens = Math.max(cacheReadInputTokens ?? 0, candidate.usage.cacheReadInputTokens);
|
|
84
|
+
}
|
|
85
|
+
if (candidate.usage.totalTokens >= modelCandidateTokens) {
|
|
86
|
+
model = candidate.model;
|
|
87
|
+
modelCandidateTokens = candidate.usage.totalTokens;
|
|
88
|
+
}
|
|
89
|
+
if (candidate.costUsd !== undefined) {
|
|
90
|
+
costUsd = Math.max(costUsd ?? 0, candidate.costUsd);
|
|
68
91
|
}
|
|
69
92
|
}
|
|
70
|
-
return
|
|
93
|
+
return {
|
|
94
|
+
model,
|
|
95
|
+
usage: {
|
|
96
|
+
inputTokens,
|
|
97
|
+
outputTokens,
|
|
98
|
+
totalTokens: Math.max(totalTokens, inputTokens + outputTokens),
|
|
99
|
+
...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {})
|
|
100
|
+
},
|
|
101
|
+
...(costUsd !== undefined ? { costUsd } : {})
|
|
102
|
+
};
|
|
71
103
|
}
|
|
72
104
|
function parseJsonValues(text) {
|
|
73
105
|
const trimmed = text.trim();
|
|
@@ -8,7 +8,8 @@ import { getToolBaseUrl } from "./tool-runner.js";
|
|
|
8
8
|
const DEFAULT_UPSTREAM_HEADERS_TIMEOUT_MS = 45_000;
|
|
9
9
|
const MAX_CONSECUTIVE_HEADERS_TIMEOUTS = 2;
|
|
10
10
|
export async function startOpenAiUsageProxy(input) {
|
|
11
|
-
const
|
|
11
|
+
const engine = input.engine ?? (input.tool === "claude" ? "claude" : "codex");
|
|
12
|
+
const upstreamBaseUrl = getToolBaseUrl(engine, input.provider);
|
|
12
13
|
const upstreamHeadersTimeoutMs = normalizePositiveTimeout(input.upstreamHeadersTimeoutMs, DEFAULT_UPSTREAM_HEADERS_TIMEOUT_MS);
|
|
13
14
|
// Codex already retries failed Responses requests. Give those retries fresh
|
|
14
15
|
// upstream connections so a stale gateway keep-alive socket cannot poison an
|
|
@@ -50,7 +51,7 @@ export async function startOpenAiUsageProxy(input) {
|
|
|
50
51
|
requestModel = extractRequestModel(requestBody) ?? input.model;
|
|
51
52
|
requestBytes = requestBody.length;
|
|
52
53
|
requestShape = summarizeRequestShape(requestBody);
|
|
53
|
-
const responsesStream = isResponsesStreamRequest(req.url ?? "/", requestBody);
|
|
54
|
+
const responsesStream = engine === "codex" && isResponsesStreamRequest(req.url ?? "/", requestBody);
|
|
54
55
|
void writeLog({
|
|
55
56
|
category: "usage",
|
|
56
57
|
action: "openai_usage_proxy_request",
|
|
@@ -62,7 +63,9 @@ export async function startOpenAiUsageProxy(input) {
|
|
|
62
63
|
path: new URL(req.url ?? "/", "http://127.0.0.1").pathname
|
|
63
64
|
}
|
|
64
65
|
});
|
|
65
|
-
const upstreamUrl = buildUpstreamUrl(upstreamBaseUrl, req.url ?? "/"
|
|
66
|
+
const upstreamUrl = buildUpstreamUrl(upstreamBaseUrl, req.url ?? "/", {
|
|
67
|
+
stripV1Prefix: engine === "codex"
|
|
68
|
+
});
|
|
66
69
|
upstreamPath = new URL(upstreamUrl).pathname;
|
|
67
70
|
if (consecutiveHeadersTimeouts >= MAX_CONSECUTIVE_HEADERS_TIMEOUTS) {
|
|
68
71
|
await writeLog({
|
|
@@ -87,7 +90,10 @@ export async function startOpenAiUsageProxy(input) {
|
|
|
87
90
|
}
|
|
88
91
|
const upstreamResponse = await fetch(upstreamUrl, {
|
|
89
92
|
method: req.method,
|
|
90
|
-
headers: buildUpstreamHeaders(req.headers, input.provider.apiKey
|
|
93
|
+
headers: buildUpstreamHeaders(req.headers, input.provider.apiKey, {
|
|
94
|
+
includeAnthropicApiKey: engine === "claude",
|
|
95
|
+
includeAuthorization: engine !== "claude"
|
|
96
|
+
}),
|
|
91
97
|
body: requestBody.length && methodMayHaveBody(req.method) ? requestBody : undefined,
|
|
92
98
|
dispatcher: upstreamDispatcher
|
|
93
99
|
});
|
|
@@ -150,7 +156,9 @@ export async function startOpenAiUsageProxy(input) {
|
|
|
150
156
|
return;
|
|
151
157
|
}
|
|
152
158
|
writeResponseHead(res, upstreamResponse);
|
|
153
|
-
const captured = await streamResponse(upstreamResponse, res
|
|
159
|
+
const captured = await streamResponse(upstreamResponse, res, {
|
|
160
|
+
maxCaptureBytes: input.tool === "image" ? 32 * 1024 * 1024 : undefined
|
|
161
|
+
});
|
|
154
162
|
const recordedUsage = await recordUsageFromResponse({
|
|
155
163
|
text: captured.toString("utf8"),
|
|
156
164
|
contentType: upstreamResponse.headers.get("content-type") ?? "",
|
|
@@ -208,7 +216,7 @@ export async function startOpenAiUsageProxy(input) {
|
|
|
208
216
|
throw new Error("Failed to start AI Gateway OpenAI proxy.");
|
|
209
217
|
}
|
|
210
218
|
return {
|
|
211
|
-
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
|
219
|
+
baseUrl: `http://127.0.0.1:${address.port}${engine === "codex" ? "/v1" : ""}`,
|
|
212
220
|
async close() {
|
|
213
221
|
const closeServer = server.listening
|
|
214
222
|
? new Promise((resolve, reject) => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function sumKnownProxyCosts(records) {
|
|
2
|
+
const knownCosts = records
|
|
3
|
+
.map((record) => record.costUsd)
|
|
4
|
+
.filter((cost) => typeof cost === "number" && Number.isFinite(cost) && cost >= 0);
|
|
5
|
+
if (!knownCosts.length)
|
|
6
|
+
return undefined;
|
|
7
|
+
return Number(knownCosts.reduce((sum, cost) => sum + cost, 0).toFixed(8));
|
|
8
|
+
}
|
|
9
|
+
export function sumProxyUsage(records) {
|
|
10
|
+
if (!records.length)
|
|
11
|
+
return undefined;
|
|
12
|
+
return records.reduce((sum, record) => ({
|
|
13
|
+
inputTokens: sum.inputTokens + record.usage.inputTokens,
|
|
14
|
+
outputTokens: sum.outputTokens + record.usage.outputTokens,
|
|
15
|
+
totalTokens: sum.totalTokens + record.usage.totalTokens,
|
|
16
|
+
cacheReadInputTokens: (sum.cacheReadInputTokens ?? 0) + (record.usage.cacheReadInputTokens ?? 0)
|
|
17
|
+
}), { inputTokens: 0, outputTokens: 0, totalTokens: 0, cacheReadInputTokens: 0 });
|
|
18
|
+
}
|