@offerpilot/axiomruntime 0.0.2 → 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 +20 -29
- package/dist/cli/commands/image.js +75 -0
- package/dist/cli/commands/telegram.js +4 -3
- 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 +3 -2
- package/dist/core/runner/engine-registry.js +0 -2
- 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 -46
- package/dist/telegram/engine/engine-utils.js +33 -17
- package/docs/USAGE.html +24 -2
- package/package.json +1 -1
- package/docs/README.md +0 -98
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AxiomRuntime(当前阶段:Provider Gateway)
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
##
|
|
94
|
+
## 本机 Runtime 服务
|
|
89
95
|
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -179,7 +169,7 @@ Telegram Bot 集成说明:
|
|
|
179
169
|
|
|
180
170
|
- 多 Bot 使用 `ai telegram add <name>` 创建,并以统一的 `--bot <name>` 选择目标;例如 `ai telegram edit --bot ops`、`start|stop|status --bot ops`、`delete --bot ops`。裸 `ai telegram` 会先选操作再选 Bot,删除有二次确认;批量生命周期操作使用 `--all`。
|
|
181
171
|
- `ai telegram config set <key> <value> --bot <name>` 只修改指定 Bot;旧位置参数写法继续兼容。
|
|
182
|
-
- 如果本机不能直连 Telegram API,可设置 `ai telegram config set proxyUrl http://127.0.0.1:7890 --bot <name
|
|
172
|
+
- 如果本机不能直连 Telegram API,可设置 `ai telegram config set proxyUrl http://127.0.0.1:7890 --bot <name>`;代理地址需包含协议,交互式新增或编辑 Bot 时也会显示该完整示例。
|
|
183
173
|
- Bot 首次 `/start` 或 `/reset` 会先选择工作路径,再选择 Claude Code/Codex,并展示当前会话、provider 和模型卡片。
|
|
184
174
|
- Bot 会记录选择过的工作路径;自定义路径会按 `~/输入名称` 创建或复用。
|
|
185
175
|
- Telegram 默认命令菜单只保留 `/claude`、`/codex`、`/reset`、`/cwd` 和 `/help`;选择 CLI 后,会在当前 chat 的 `/` 菜单追加该 CLI 的 slash 命令映射。
|
|
@@ -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
|
+
}
|
|
@@ -8,6 +8,7 @@ import { botExists, isBotRunning, isValidBotName, listBots, migrateLegacyConfig,
|
|
|
8
8
|
import { ensureTelegramLogFile, readRecentTelegramLogLines } from "../../telegram/log.js";
|
|
9
9
|
import { checkTelegramApi, getTelegramProxyUrl } from "../../telegram/network.js";
|
|
10
10
|
import { promptConfirm, promptSelect, promptText } from "../prompts/prompt.js";
|
|
11
|
+
export const TELEGRAM_PROXY_URL_PROMPT = "Proxy URL (include protocol, e.g. http://127.0.0.1:7890)";
|
|
11
12
|
export async function runTelegramCommand(args) {
|
|
12
13
|
const target = parseTelegramTargetArgs(args);
|
|
13
14
|
const [subcommand, ...rest] = target.args;
|
|
@@ -202,7 +203,7 @@ async function addBot(nameArg) {
|
|
|
202
203
|
if (!token.trim())
|
|
203
204
|
throw new Error("Token is required.");
|
|
204
205
|
const allowedUserIds = await promptText("Allowed user IDs (comma-separated, empty for allowAll)");
|
|
205
|
-
const proxyUrl = await promptText(
|
|
206
|
+
const proxyUrl = await promptText(`${TELEGRAM_PROXY_URL_PROMPT} (empty to skip)`, { defaultValue: "" });
|
|
206
207
|
const config = {
|
|
207
208
|
token: token.trim(),
|
|
208
209
|
...(proxyUrl.trim() ? { proxyUrl: proxyUrl.trim() } : {})
|
|
@@ -494,7 +495,7 @@ async function editBotConfigInteractive(name) {
|
|
|
494
495
|
const current = String(config[field] ?? "");
|
|
495
496
|
const value = field === "token"
|
|
496
497
|
? await promptText("token (leave empty to keep current)") || current
|
|
497
|
-
: await promptText(field, { defaultValue: current });
|
|
498
|
+
: await promptText(field === "proxyUrl" ? TELEGRAM_PROXY_URL_PROMPT : field, { defaultValue: current });
|
|
498
499
|
config = await setBotConfigValue(name, field, value);
|
|
499
500
|
}
|
|
500
501
|
}
|
|
@@ -539,7 +540,7 @@ async function editTelegramConfigInteractive() {
|
|
|
539
540
|
const current = String(config[field] ?? "");
|
|
540
541
|
const value = field === "token"
|
|
541
542
|
? await promptText("token (leave empty to keep current)") || current
|
|
542
|
-
: await promptText(field, { defaultValue: current });
|
|
543
|
+
: await promptText(field === "proxyUrl" ? TELEGRAM_PROXY_URL_PROMPT : field, { defaultValue: current });
|
|
543
544
|
config = await setTelegramConfigValue(field, value);
|
|
544
545
|
}
|
|
545
546
|
}
|
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/
|
|
32
|
-
"docs/
|
|
33
|
-
"docs/
|
|
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,9 +138,8 @@ 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
|
-
"requires_openai_auth = true",
|
|
142
143
|
`wire_api = ${tomlString("responses")}`
|
|
143
144
|
].join(eol);
|
|
144
145
|
return `${next.trimEnd()}${eol}${eol}${table}${eol}`;
|
|
@@ -78,8 +78,6 @@ registerRunnerEngine({
|
|
|
78
78
|
"-c",
|
|
79
79
|
`model_providers.${providerId}.env_key=${tomlString("OPENAI_API_KEY")}`,
|
|
80
80
|
"-c",
|
|
81
|
-
`model_providers.${providerId}.requires_openai_auth=true`,
|
|
82
|
-
"-c",
|
|
83
81
|
`model_providers.${providerId}.wire_api=${tomlString("responses")}`,
|
|
84
82
|
"--model",
|
|
85
83
|
model
|
|
@@ -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 = [];
|