@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.
@@ -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(provider.baseUrl.trim().replace(/\/+$/, ""))}`,
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
- (options.spawnDetached ?? defaultSpawnDetached)(process.execPath, ["-e", helper]);
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 (suffix === "/v1") {
21
- suffix = "";
22
- }
23
- else if (suffix.startsWith("/v1/")) {
24
- suffix = suffix.slice(3);
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
- next.authorization = `Bearer ${apiKey}`;
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
- if (capturedBytes < MAX_USAGE_CAPTURE_BYTES) {
64
- const remaining = MAX_USAGE_CAPTURE_BYTES - capturedBytes;
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
- return Buffer.concat(chunks);
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 = [];
@@ -1 +1,2 @@
1
1
  export { startOpenAiUsageProxy } from "./openai-usage-server.js";
2
+ export { sumKnownProxyCosts, sumProxyUsage } from "./openai-usage-summary.js";
@@ -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
- let best = null;
62
+ const candidates = [];
63
63
  for (const value of values) {
64
- for (const candidate of collectUsageCandidates(value, fallbackModel)) {
65
- if (!best || candidate.usage.totalTokens >= best.usage.totalTokens) {
66
- best = candidate;
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 best;
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 upstreamBaseUrl = getToolBaseUrl("codex", input.provider);
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
+ }