@offerpilot/axiomruntime 0.0.3 → 0.0.4

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 CHANGED
@@ -1,6 +1,6 @@
1
- # AI Runtime(当前阶段:AI Gateway)
1
+ # AxiomRuntime(当前阶段:Provider Gateway)
2
2
 
3
- 本项目的长期目标是构建一套企业级 AI Runtime:让模型、Agent、记忆、知识、技能、工作流和工具在统一的运行、治理与学习体系中协作,并持续把个人经验沉淀为组织能力。
3
+ 本项目的长期目标是构建一套 AI Runtime:让模型、Agent、记忆、知识、技能、工作流和工具在统一的运行、治理与学习体系中协作,并持续把个人经验沉淀为可复用能力。
4
4
 
5
5
  Runtime 才是产品。Memory、Agent 和 LLM 都是可以替换和组合的能力模块。
6
6
 
@@ -10,19 +10,25 @@ Runtime 才是产品。Memory、Agent 和 LLM 都是可以替换和组合的能
10
10
 
11
11
  ## 文档导航
12
12
 
13
- - [中文文档总览](./docs/README.md)
14
- - [doc_2.0 文档层总览](./docs/README.md)
15
- - [中文使用指南](./docs/usage.md)
16
- - [HTML 命令手册](./docs/USAGE.html)
17
- - [整体架构与当前实现快照](./docs/00-overview.md)
13
+ 文档共三份,均标注实现状态(🟢 已实现 / 🟡 部分实现 / 🔴 规划中):
18
14
 
19
- AI 与维护者使用的英文权威文档位于 `AGENTS.md`、`docs/AI_RUNTIME_ARCHITECTURE.md` `docs/CONVENTIONS.md`;产品规范、宪法摘要、记忆/学习与 Telegram 设计等构成 `doc_2.0` 文档层(现已展开在 `docs/` 与 `docs/modules/` 下,索引见 `docs/README.md`)。
15
+ | 文档 | 读者 | 内容 |
16
+ |---|---|---|
17
+ | [使用指南](./docs/usage.md) | 使用者 | 安装、上手、每个命令怎么用 |
18
+ | [技术架构](./docs/architecture.md) | 开发者 | 分层设计、执行主链、所有权边界、Mermaid 图 |
19
+ | [产品需求文档](./docs/prd.md) | 开发者 + 产品 | 定位、能力清单、发布计划、交付风险、验收标准 |
20
+
21
+ 另有 [HTML 命令手册](./docs/USAGE.html),`ai help` 会直接打开。
22
+
23
+ 维护者与 AI 协作规则见 `AGENTS.md` 和 `CLAUDE.md`(英文)。
20
24
 
21
25
  ## 当前边界
22
26
 
23
27
  - 已交付:本地 Provider Gateway、Claude/Codex 启动与 fallback、Telegram、会话、用量、诊断和早期 Memory。
24
28
  - 演进中:统一 Runtime 执行上下文、自动学习与记忆治理、Skill/Capability 生命周期、插件体系。
25
- - 长期方向:组织与租户隔离、能力注册与共享、企业集成、Web/Desktop/API 治理界面。
29
+ - 长期方向:组织与租户隔离、能力注册与共享、外部系统集成、Web/Desktop/API 治理界面。
30
+
31
+ 完整的已实现与规划对照见 [产品需求文档 §3](./docs/prd.md)。
26
32
 
27
33
  ## 通过 npm 安装
28
34
 
@@ -85,9 +91,9 @@ npm run build
85
91
  npm test
86
92
  ```
87
93
 
88
- ## 内网 Runtime 服务
94
+ ## 本机 Runtime 服务
89
95
 
90
- 常驻服务为其他应用提供两类受鉴权接口,同时保留现有 `ai` CLI 和 Telegram 行为:
96
+ 从源码进行本地开发或集成时,可以启动受鉴权的 Runtime HTTP 服务,同时保留现有 `ai` CLI 和 Telegram 行为:
91
97
 
92
98
  - `POST /v1/chat/completions`、`/v1/responses`、`/v1/embeddings`:OpenAI 兼容模型调用,按 provider 优先级、模型目录、健康度和熔断状态自动切换。
93
99
  - `POST /v1/agent/runs`:复用 Telegram 背后的 Claude/Codex Engine。只接受受控 `workspace_id`,默认 `read_only`,服务端限制并发和超时。
@@ -98,23 +104,7 @@ npm run build
98
104
  AI_GATEWAY_SERVER_TOKEN='replace-me' npm run server
99
105
  ```
100
106
 
101
- 生产 Compose 位于 `deploy/docker-compose.prod.yml`,默认仅映射宿主机 `127.0.0.1:8020`,并加入外部 Docker 网络 `ai-runtime`。Provider 配置挂载到 `/var/lib/ai-gateway`,服务 Token 通过只读文件挂载,不写入镜像或仓库。
102
-
103
- ### GitLab 自动部署
104
-
105
- 默认分支提交通过测试后,GitLab CI 会构建以 commit SHA 标记的镜像,并由锁定当前项目的受保护 Runner(标签 `axiomruntime-prod`)部署到 `154.201.73.41:/opt/AxiomRuntime`。部署只操作 `axiomruntime` Compose 项目;健康检查失败时恢复上一镜像,不清理服务器上的其他容器或镜像。
106
-
107
- 镜像构建会在一次性 build stage 中编译 `better-sqlite3` 原生 addon,再把裁掉开发依赖后的 production dependency tree 复制到相同 Node.js 版本的最终镜像;最终镜像不包含编译器。
108
-
109
- 首次部署前,服务器必须已有 `/opt/AxiomRuntime/config/providers.json` 和 `/opt/AxiomRuntime/secrets/server-token`,且容器用户 `10001:10001` 可以读写配置目录。CI 只校验这些文件,不生成或覆盖凭据。
110
-
111
- Telegram 容器默认不启动,以免与现有 long polling 进程争抢消息。完成迁移并停止旧 Bot 后,在服务器 `/opt/AxiomRuntime/.env` 中设置:
112
-
113
- ```dotenv
114
- COMPOSE_PROFILES=telegram
115
- ```
116
-
117
- 启用前还要把各 Bot 的 `defaultCwd` 改为容器内的 `/workspace/...`,对应宿主机 `/opt/AxiomRuntime/workspaces/...`。之后的自动部署会同时更新 Runtime API 和所有位于 `config/telegram/bots/*.json` 的 Telegram Bot。资源上限和工作目录可参考 `.env.server.example` 调整。
107
+ 项目不通过 GitLab CI 构建镜像或部署服务器。正式分发统一使用 npm:普通分支和合并请求运行测试,版本 tag 通过校验后发布 `@offerpilot/axiomruntime`,用户通过 `npm install --global @offerpilot/axiomruntime` 安装或升级。
118
108
 
119
109
  ## 本机注册 `ai` 命令
120
110
 
@@ -190,3 +180,4 @@ Telegram Bot 集成说明:
190
180
 
191
181
  - [docs/usage.md](./docs/usage.md)
192
182
  - [docs/USAGE.html](./docs/USAGE.html)
183
+ - [docs/prd.md](./docs/prd.md) 的「已知限制」一节
@@ -0,0 +1,75 @@
1
+ import { readCache } from "../../core/config/cache-store.js";
2
+ import { generateImage } from "../../core/images/image-generation-service.js";
3
+ import { listProviders, refreshAllProviders } from "../../core/providers/provider-service.js";
4
+ import { promptSelect, promptText } from "../prompts/prompt.js";
5
+ export async function runImageCommand(args) {
6
+ await refreshAllProviders();
7
+ const [providers, cache] = await Promise.all([listProviders(), readCache()]);
8
+ const input = await resolveImageCommandInput(args, providers, cache.providers);
9
+ const result = await generateImage(input);
10
+ console.log(`Generated with ${result.provider} / ${result.model}`);
11
+ if (result.filePath)
12
+ console.log(`Image saved: ${result.filePath}`);
13
+ if (result.url)
14
+ console.log(`Image URL: ${result.url}`);
15
+ if (result.revisedPrompt)
16
+ console.log(`Revised prompt: ${result.revisedPrompt}`);
17
+ }
18
+ export async function resolveImageCommandInput(args, providers, providerCache, select = promptSelect, text = promptText) {
19
+ if (!providers.length) {
20
+ throw new Error("No providers configured. Run `ai add` first.");
21
+ }
22
+ const target = args[0]?.trim();
23
+ const namedProvider = target ? providers.find((provider) => provider.name === target) : undefined;
24
+ if (target && !namedProvider) {
25
+ const prompt = args.slice(1).join(" ").trim() || await text("Image prompt");
26
+ return { target, prompt: requirePrompt(prompt) };
27
+ }
28
+ const provider = namedProvider ?? await selectImageProvider(providers, providerCache, select);
29
+ const models = getProviderImageModels(provider, providerCache);
30
+ const secondArg = args[1]?.trim();
31
+ const hasExplicitModel = Boolean(secondArg && models.includes(secondArg));
32
+ const model = hasExplicitModel
33
+ ? secondArg
34
+ : target
35
+ ? getDefaultImageModel(provider, models)
36
+ : await selectImageModel(provider, models, select);
37
+ const promptArgs = hasExplicitModel ? args.slice(2) : args.slice(1);
38
+ const prompt = promptArgs.join(" ").trim() || await text("Image prompt");
39
+ return { provider: provider.name, model, prompt: requirePrompt(prompt) };
40
+ }
41
+ async function selectImageProvider(providers, providerCache, select) {
42
+ const availableProviders = providers.filter((provider) => getProviderImageModels(provider, providerCache).length);
43
+ if (!availableProviders.length) {
44
+ throw new Error("No configured provider has available image models. Run `ai status` to refresh model lists.");
45
+ }
46
+ const providerName = await select("Select image provider", availableProviders.map((provider) => ({
47
+ label: `${provider.name} / ${getProviderImageModels(provider, providerCache).length} models`,
48
+ value: provider.name
49
+ })));
50
+ return availableProviders.find((provider) => provider.name === providerName);
51
+ }
52
+ async function selectImageModel(provider, models, select) {
53
+ if (!models.length) {
54
+ throw new Error(`Provider ${provider.name} has no available image models. Run \`ai status\` first.`);
55
+ }
56
+ return select(`Select image model for ${provider.name}`, models.map((model) => ({ label: model, value: model })));
57
+ }
58
+ function getProviderImageModels(provider, providerCache) {
59
+ const cachedModels = providerCache[provider.name]?.models ?? [];
60
+ const models = cachedModels.length ? [...new Set(cachedModels)] : provider.model ? [provider.model] : [];
61
+ return provider.model && models.includes(provider.model)
62
+ ? [provider.model, ...models.filter((model) => model !== provider.model)]
63
+ : models;
64
+ }
65
+ function getDefaultImageModel(provider, models) {
66
+ if (provider.model && models.includes(provider.model))
67
+ return provider.model;
68
+ throw new Error(`Provider ${provider.name} has no usable default image model. Specify one of: ${models.join(", ") || "(none)"}.`);
69
+ }
70
+ function requirePrompt(value) {
71
+ const prompt = value.trim();
72
+ if (!prompt)
73
+ throw new Error("Image prompt is required.");
74
+ return prompt;
75
+ }
package/dist/cli/index.js CHANGED
@@ -4,6 +4,7 @@ import { runContextCommand } from "./commands/context.js";
4
4
  import { runDoctorCommand } from "./commands/doctor.js";
5
5
  import { runProviderDeleteCommand, runProviderEditCommand } from "./commands/edit.js";
6
6
  import { runHelpCommand } from "./commands/help.js";
7
+ import { runImageCommand } from "./commands/image.js";
7
8
  import { runListCommand } from "./commands/list.js";
8
9
  import { runLogCommand } from "./commands/log.js";
9
10
  import { runMemoryCommand } from "./commands/memory.js";
@@ -53,7 +54,7 @@ async function main() {
53
54
  console.error("Run `ai help` for usage.");
54
55
  process.exitCode = 1;
55
56
  }
56
- function sanitizeArgs(command, args) {
57
+ export function sanitizeArgs(command, args) {
57
58
  const redactedProxyArgs = redactCodexProxyCommandArgs(command, args);
58
59
  if (redactedProxyArgs)
59
60
  return redactedProxyArgs;
@@ -69,6 +70,9 @@ function sanitizeArgs(command, args) {
69
70
  if (command === "telegram") {
70
71
  return maskTelegramConfigSecretArgs(args);
71
72
  }
73
+ if (command === "image" && args.length > 1) {
74
+ return [args[0], `[prompt ${args.slice(1).join(" ").length} chars]`];
75
+ }
72
76
  return args.map((arg, index) => {
73
77
  const previous = args[index - 1]?.toLowerCase();
74
78
  if (previous === "--api-key" || previous === "--apikey" || previous === "--key") {
@@ -136,7 +140,7 @@ function maskSecretArg(value) {
136
140
  return `${value.slice(0, 4)}****${value.slice(-4)}`;
137
141
  }
138
142
  async function runInteractive() {
139
- const interactiveCommandNames = ["setup", "use", "set", "add", "memory", "telegram", "status", "session", "report", "doctor", "log", "context", "help", "last"];
143
+ const interactiveCommandNames = ["setup", "use", "image", "set", "add", "memory", "telegram", "status", "session", "report", "doctor", "log", "context", "help", "last"];
140
144
  const command = await promptSelect("AI Gateway", [
141
145
  ...interactiveCommandNames
142
146
  .flatMap((name) => {
@@ -255,6 +259,11 @@ function registerBuiltInCommands() {
255
259
  description: "use",
256
260
  run: (args) => runUseCommand(args)
257
261
  });
262
+ registerCliCommand({
263
+ name: "image",
264
+ description: "generate image",
265
+ run: (args) => runImageCommand(args)
266
+ });
258
267
  registerCliCommand({
259
268
  name: "set",
260
269
  description: "set Codex provider or proxy",
@@ -28,9 +28,9 @@ const DOC_CANDIDATES = [
28
28
  "README.md",
29
29
  "AGENTS.md",
30
30
  "CLAUDE.md",
31
- "docs/00-overview.md",
32
- "docs/CONVENTIONS.md",
33
- "docs/usage.md"
31
+ "docs/usage.md",
32
+ "docs/architecture.md",
33
+ "docs/prd.md"
34
34
  ];
35
35
  export async function generateProjectContext(root = getProjectRoot()) {
36
36
  const content = [
@@ -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);
@@ -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();