@kairyou/agent-tools 0.1.0 → 0.3.0
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 +147 -69
- package/README.zh-CN.md +143 -67
- package/dist/vision/cli.mjs +1972 -0
- package/dist/vision/mcp-server.mjs +32858 -0
- package/{statusline/claude/statusline.mjs → integrations/statusline/claude-statusline.mjs} +4 -2
- package/integrations/usage/cli.mjs +27 -0
- package/{hooks/codex/usage-hook.mjs → integrations/usage/codex-hook.mjs} +1 -1
- package/{lib/usage.mjs → integrations/usage/core.mjs} +5 -1
- package/{plugins/opencode/usage-plugin.mjs → integrations/usage/opencode-plugin.mjs} +1 -1
- package/integrations/usage/skills/at-usage/SKILL.md +16 -0
- package/integrations/vision/lib/cli.mjs +137 -0
- package/integrations/vision/lib/config.mjs +159 -0
- package/integrations/vision/lib/errors.mjs +35 -0
- package/integrations/vision/lib/image-source.mjs +273 -0
- package/integrations/vision/lib/inspect.mjs +108 -0
- package/integrations/vision/lib/providers/anthropic-compatible.mjs +59 -0
- package/integrations/vision/lib/providers/openai-compatible.mjs +56 -0
- package/integrations/vision/lib/providers/shared.mjs +251 -0
- package/integrations/vision/lib/rate-limit.mjs +188 -0
- package/integrations/vision/lib/redact.mjs +30 -0
- package/integrations/vision/mcp-server.mjs +96 -0
- package/integrations/vision/skills/at-vision/SKILL.md +66 -0
- package/package.json +15 -10
- package/scripts/build-vision.mjs +35 -0
- package/scripts/capture-codex-tools.mjs +48 -0
- package/scripts/install.mjs +511 -29
- package/scripts/release.mjs +126 -0
- package/skills/integrations/at-zentao/SKILL.md +148 -0
- package/skills/workflow/at-commit/SKILL.md +3 -8
- package/skills/workflow/at-review/SKILL.md +9 -4
- package/skills/workflow/at-simplify/SKILL.md +1 -0
- package/hooks/claude/.gitkeep +0 -1
- package/hooks/codex/.gitkeep +0 -1
- package/hooks/common/.gitkeep +0 -1
- package/hooks/opencode/.gitkeep +0 -1
- package/statusline/.gitkeep +0 -1
- package/statusline/codex/.gitkeep +0 -1
- /package/{plugins/opencode/usage-tui.mjs → integrations/usage/opencode-tui.mjs} +0 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Image acquisition: explicit local file paths and http(s) URLs only. No
|
|
2
|
+
// directory enumeration, globbing, or implicit search — the caller must name
|
|
3
|
+
// one concrete image. URLs are unrestricted by host/IP (personal local tool);
|
|
4
|
+
// protection is resource-based: timeout, redirect cap, size cap, and image
|
|
5
|
+
// signature validation.
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { ERROR_CODES, VisionError } from "./errors.mjs";
|
|
11
|
+
|
|
12
|
+
export const SUPPORTED_MEDIA_TYPES = Object.freeze([
|
|
13
|
+
"image/png",
|
|
14
|
+
"image/jpeg",
|
|
15
|
+
"image/webp",
|
|
16
|
+
"image/gif",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const MAX_REDIRECTS = 5;
|
|
20
|
+
|
|
21
|
+
// Identify the image type from magic bytes; extensions and Content-Type
|
|
22
|
+
// headers are hints only and are never trusted.
|
|
23
|
+
export function sniffMediaType(bytes) {
|
|
24
|
+
if (!bytes || bytes.length < 12) return null;
|
|
25
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
|
26
|
+
return "image/png";
|
|
27
|
+
}
|
|
28
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
29
|
+
return "image/jpeg";
|
|
30
|
+
}
|
|
31
|
+
if (
|
|
32
|
+
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
|
33
|
+
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
|
34
|
+
) {
|
|
35
|
+
return "image/webp";
|
|
36
|
+
}
|
|
37
|
+
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
|
|
38
|
+
return "image/gif";
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateImageHeader(header, byteLength, origin, maxImageBytes) {
|
|
44
|
+
if (byteLength === 0) {
|
|
45
|
+
throw new VisionError(ERROR_CODES.INPUT, `${origin} is empty.`);
|
|
46
|
+
}
|
|
47
|
+
if (byteLength > maxImageBytes) {
|
|
48
|
+
throw new VisionError(
|
|
49
|
+
ERROR_CODES.INPUT,
|
|
50
|
+
`${origin} is ${byteLength} bytes, over the ${maxImageBytes}-byte limit (vision.maxImageBytes).`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const mediaType = sniffMediaType(header);
|
|
54
|
+
if (!mediaType) {
|
|
55
|
+
throw new VisionError(
|
|
56
|
+
ERROR_CODES.INPUT,
|
|
57
|
+
`${origin} is not a supported image (expected PNG, JPEG, WebP, or GIF).`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return mediaType;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadFile(value, maxImageBytes) {
|
|
64
|
+
const resolved = path.resolve(value);
|
|
65
|
+
let stat;
|
|
66
|
+
try {
|
|
67
|
+
stat = fs.statSync(resolved);
|
|
68
|
+
} catch {
|
|
69
|
+
throw new VisionError(ERROR_CODES.INPUT, `Image file not found: ${resolved}`);
|
|
70
|
+
}
|
|
71
|
+
if (!stat.isFile()) {
|
|
72
|
+
throw new VisionError(ERROR_CODES.INPUT, `Not a file: ${resolved} (directories are not accepted).`);
|
|
73
|
+
}
|
|
74
|
+
if (stat.size > maxImageBytes) {
|
|
75
|
+
throw new VisionError(
|
|
76
|
+
ERROR_CODES.INPUT,
|
|
77
|
+
`${resolved} is ${stat.size} bytes, over the ${maxImageBytes}-byte limit (vision.maxImageBytes).`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
let handle;
|
|
81
|
+
try {
|
|
82
|
+
handle = fs.openSync(resolved, "r");
|
|
83
|
+
const header = Buffer.alloc(12);
|
|
84
|
+
const headerLength = fs.readSync(handle, header, 0, header.length, 0);
|
|
85
|
+
const mediaType = validateImageHeader(
|
|
86
|
+
header.subarray(0, headerLength),
|
|
87
|
+
stat.size,
|
|
88
|
+
resolved,
|
|
89
|
+
maxImageBytes
|
|
90
|
+
);
|
|
91
|
+
let disposed = false;
|
|
92
|
+
return {
|
|
93
|
+
mediaType,
|
|
94
|
+
byteLength: stat.size,
|
|
95
|
+
createReadStream: () => fs.createReadStream(resolved, { fd: handle, autoClose: false, start: 0 }),
|
|
96
|
+
dispose() {
|
|
97
|
+
if (disposed) return;
|
|
98
|
+
disposed = true;
|
|
99
|
+
fs.closeSync(handle);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (handle !== undefined) fs.closeSync(handle);
|
|
104
|
+
if (err instanceof VisionError) throw err;
|
|
105
|
+
throw new VisionError(ERROR_CODES.INPUT, `Cannot read ${resolved}: ${err.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function downloadCapped(response, url, maxImageBytes) {
|
|
110
|
+
const declared = Number(response.headers.get("content-length"));
|
|
111
|
+
if (Number.isFinite(declared) && declared > maxImageBytes) {
|
|
112
|
+
throw new VisionError(
|
|
113
|
+
ERROR_CODES.INPUT,
|
|
114
|
+
`${url} declares ${declared} bytes, over the ${maxImageBytes}-byte limit (vision.maxImageBytes).`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-tools-vision-"));
|
|
118
|
+
const tempFile = path.join(tempDir, "image");
|
|
119
|
+
const handle = await fs.promises.open(tempFile, "wx", 0o600);
|
|
120
|
+
const header = Buffer.alloc(12);
|
|
121
|
+
let headerLength = 0;
|
|
122
|
+
let total = 0;
|
|
123
|
+
try {
|
|
124
|
+
for await (const value of response.body) {
|
|
125
|
+
const chunk = Buffer.from(value);
|
|
126
|
+
total += chunk.length;
|
|
127
|
+
if (total > maxImageBytes) {
|
|
128
|
+
throw new VisionError(
|
|
129
|
+
ERROR_CODES.INPUT,
|
|
130
|
+
`${url} exceeded the ${maxImageBytes}-byte limit (vision.maxImageBytes) while downloading.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (headerLength < header.length) {
|
|
134
|
+
const copied = chunk.copy(header, headerLength, 0, header.length - headerLength);
|
|
135
|
+
headerLength += copied;
|
|
136
|
+
}
|
|
137
|
+
let offset = 0;
|
|
138
|
+
while (offset < chunk.length) {
|
|
139
|
+
const { bytesWritten } = await handle.write(chunk, offset, chunk.length - offset, null);
|
|
140
|
+
if (bytesWritten === 0) throw new Error(`Could not write downloaded image data for ${url}.`);
|
|
141
|
+
offset += bytesWritten;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
await handle.close().catch(() => {});
|
|
146
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
await handle.close();
|
|
150
|
+
let mediaType;
|
|
151
|
+
try {
|
|
152
|
+
mediaType = validateImageHeader(header.subarray(0, headerLength), total, url, maxImageBytes);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
let disposed = false;
|
|
158
|
+
return {
|
|
159
|
+
mediaType,
|
|
160
|
+
byteLength: total,
|
|
161
|
+
tempFile,
|
|
162
|
+
createReadStream: () => fs.createReadStream(tempFile),
|
|
163
|
+
dispose() {
|
|
164
|
+
if (disposed) return;
|
|
165
|
+
disposed = true;
|
|
166
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function requireHttpUrl(value, code, message) {
|
|
172
|
+
let parsed;
|
|
173
|
+
try {
|
|
174
|
+
parsed = new URL(value);
|
|
175
|
+
} catch {
|
|
176
|
+
throw new VisionError(code, message);
|
|
177
|
+
}
|
|
178
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
179
|
+
throw new VisionError(code, message);
|
|
180
|
+
}
|
|
181
|
+
return parsed;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function discardRedirectBody(response) {
|
|
185
|
+
if (response.body && typeof response.body.cancel === "function") {
|
|
186
|
+
try {
|
|
187
|
+
await response.body.cancel();
|
|
188
|
+
} catch {
|
|
189
|
+
// Redirect metadata remains usable even if connection cleanup fails.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function loadUrl(value, { maxImageBytes, timeoutMs, fetchImpl }) {
|
|
195
|
+
const invalidInitial = `Only valid http(s) URLs are supported: ${value}`;
|
|
196
|
+
let current = requireHttpUrl(value, ERROR_CODES.INPUT, invalidInitial);
|
|
197
|
+
|
|
198
|
+
const doFetch = fetchImpl || fetch;
|
|
199
|
+
const controller = new AbortController();
|
|
200
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
201
|
+
try {
|
|
202
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
203
|
+
let response;
|
|
204
|
+
try {
|
|
205
|
+
response = await doFetch(current.href, { redirect: "manual", signal: controller.signal });
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (controller.signal.aborted) {
|
|
208
|
+
throw new VisionError(ERROR_CODES.FETCH, `Timed out fetching ${current.href} after ${timeoutMs}ms.`);
|
|
209
|
+
}
|
|
210
|
+
throw new VisionError(ERROR_CODES.FETCH, `Cannot fetch ${current.href}: ${err.message}`, { cause: err });
|
|
211
|
+
}
|
|
212
|
+
if (response.status >= 300 && response.status < 400) {
|
|
213
|
+
const location = response.headers.get("location");
|
|
214
|
+
if (!location) {
|
|
215
|
+
await discardRedirectBody(response);
|
|
216
|
+
throw new VisionError(ERROR_CODES.FETCH, `${current.href} redirected without a Location header.`);
|
|
217
|
+
}
|
|
218
|
+
if (hop === MAX_REDIRECTS) {
|
|
219
|
+
await discardRedirectBody(response);
|
|
220
|
+
throw new VisionError(ERROR_CODES.FETCH, `${value} exceeded ${MAX_REDIRECTS} redirects.`);
|
|
221
|
+
}
|
|
222
|
+
let redirected;
|
|
223
|
+
try {
|
|
224
|
+
redirected = new URL(location, current);
|
|
225
|
+
} catch {
|
|
226
|
+
await discardRedirectBody(response);
|
|
227
|
+
throw new VisionError(ERROR_CODES.FETCH, `${current.href} redirected to an invalid URL: ${location}`);
|
|
228
|
+
}
|
|
229
|
+
if (redirected.protocol !== "http:" && redirected.protocol !== "https:") {
|
|
230
|
+
await discardRedirectBody(response);
|
|
231
|
+
throw new VisionError(
|
|
232
|
+
ERROR_CODES.FETCH,
|
|
233
|
+
`${current.href} redirected to unsupported protocol ${redirected.protocol}`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
await discardRedirectBody(response);
|
|
237
|
+
current = redirected;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
throw new VisionError(ERROR_CODES.FETCH, `${current.href} returned HTTP ${response.status}.`);
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
return await downloadCapped(response, current.href, maxImageBytes);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (err instanceof VisionError) throw err;
|
|
247
|
+
if (controller.signal.aborted) {
|
|
248
|
+
throw new VisionError(ERROR_CODES.FETCH, `Timed out fetching ${current.href} after ${timeoutMs}ms.`);
|
|
249
|
+
}
|
|
250
|
+
throw new VisionError(ERROR_CODES.FETCH, `Cannot read ${current.href}: ${err.message}`, { cause: err });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
throw new VisionError(ERROR_CODES.FETCH, `${value} exceeded ${MAX_REDIRECTS} redirects.`);
|
|
254
|
+
} finally {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// source: { type: "file" | "url", value: string }
|
|
260
|
+
// Returns a disposable, repeatable stream source. URL downloads are spooled to
|
|
261
|
+
// a private temporary file so provider serialization never needs the complete
|
|
262
|
+
// image or its base64 representation in memory.
|
|
263
|
+
export async function loadImageSource(source, { maxImageBytes, timeoutMs, fetchImpl } = {}) {
|
|
264
|
+
if (!source || typeof source !== "object" || typeof source.value !== "string" || source.value.trim() === "") {
|
|
265
|
+
throw new VisionError(
|
|
266
|
+
ERROR_CODES.INPUT,
|
|
267
|
+
'image_source must be { "type": "file" | "url", "value": "<path or url>" }.'
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (source.type === "file") return loadFile(source.value, maxImageBytes);
|
|
271
|
+
if (source.type === "url") return loadUrl(source.value, { maxImageBytes, timeoutMs, fetchImpl });
|
|
272
|
+
throw new VisionError(ERROR_CODES.INPUT, `Unsupported image_source.type: ${JSON.stringify(source.type)}`);
|
|
273
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Orchestrator shared by the MCP server and the diagnostic CLI. Owns question
|
|
2
|
+
// validation, the process-local limiter, provider dispatch, and final secret
|
|
3
|
+
// redaction — both entry points stay thin.
|
|
4
|
+
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { agentToolsHome, loadVisionConfig } from "./config.mjs";
|
|
8
|
+
import { ERROR_CODES, VisionError, toVisionError } from "./errors.mjs";
|
|
9
|
+
import { loadImageSource } from "./image-source.mjs";
|
|
10
|
+
import { createLimiter } from "./rate-limit.mjs";
|
|
11
|
+
import { redactSecrets } from "./redact.mjs";
|
|
12
|
+
import { inspectWithAnthropicCompatible } from "./providers/anthropic-compatible.mjs";
|
|
13
|
+
import { inspectWithOpenAICompatible } from "./providers/openai-compatible.mjs";
|
|
14
|
+
|
|
15
|
+
const PROVIDER_IMPL = {
|
|
16
|
+
"openai-compatible": inspectWithOpenAICompatible,
|
|
17
|
+
"anthropic-compatible": inspectWithAnthropicCompatible,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const QUESTION_LIMITS = Object.freeze({
|
|
21
|
+
maxCount: 20,
|
|
22
|
+
maxIdLength: 64,
|
|
23
|
+
maxTextLength: 4000,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export function validateQuestions(questions) {
|
|
27
|
+
if (!Array.isArray(questions) || questions.length === 0) {
|
|
28
|
+
throw new VisionError(
|
|
29
|
+
ERROR_CODES.INPUT,
|
|
30
|
+
'questions must be a non-empty array of { "id": "<id>", "text": "<question>" }.'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (questions.length > QUESTION_LIMITS.maxCount) {
|
|
34
|
+
throw new VisionError(
|
|
35
|
+
ERROR_CODES.INPUT,
|
|
36
|
+
`questions must contain at most ${QUESTION_LIMITS.maxCount} entries.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const q of questions) {
|
|
42
|
+
if (!q || typeof q !== "object" || typeof q.id !== "string" || q.id.trim() === "" ||
|
|
43
|
+
typeof q.text !== "string" || q.text.trim() === "") {
|
|
44
|
+
throw new VisionError(
|
|
45
|
+
ERROR_CODES.INPUT,
|
|
46
|
+
'Each question must be { "id": "<non-empty id>", "text": "<non-empty question>" }.'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (q.id.length > QUESTION_LIMITS.maxIdLength) {
|
|
50
|
+
throw new VisionError(
|
|
51
|
+
ERROR_CODES.INPUT,
|
|
52
|
+
`Question id must be at most ${QUESTION_LIMITS.maxIdLength} characters.`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (q.text.length > QUESTION_LIMITS.maxTextLength) {
|
|
56
|
+
throw new VisionError(
|
|
57
|
+
ERROR_CODES.INPUT,
|
|
58
|
+
`Question text must be at most ${QUESTION_LIMITS.maxTextLength} characters.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (seen.has(q.id)) {
|
|
62
|
+
throw new VisionError(ERROR_CODES.INPUT, `Duplicate question id: ${q.id}`);
|
|
63
|
+
}
|
|
64
|
+
seen.add(q.id);
|
|
65
|
+
out.push({ id: q.id, text: q.text });
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One service per process: the limiter state must span all tool calls handled
|
|
71
|
+
// by this MCP server (or CLI invocation).
|
|
72
|
+
export function createVisionService({ config, fetchImpl, now, limiterStateFile } = {}) {
|
|
73
|
+
const resolved = config || loadVisionConfig();
|
|
74
|
+
const sharedState =
|
|
75
|
+
limiterStateFile === undefined && !config
|
|
76
|
+
? path.join(agentToolsHome(), "cache", "vision-rate-limit.json")
|
|
77
|
+
: limiterStateFile;
|
|
78
|
+
const limiter = createLimiter(resolved, now, { stateFile: sharedState || null });
|
|
79
|
+
const provider = PROVIDER_IMPL[resolved.provider];
|
|
80
|
+
const secrets = resolved.apiKey ? [resolved.apiKey] : [];
|
|
81
|
+
|
|
82
|
+
async function inspect({ image_source, questions }) {
|
|
83
|
+
const requestId = `vision_req_${crypto.randomUUID().slice(0, 8)}`;
|
|
84
|
+
const validQuestions = validateQuestions(questions);
|
|
85
|
+
try {
|
|
86
|
+
return await limiter.run(async () => {
|
|
87
|
+
const image = await loadImageSource(image_source, {
|
|
88
|
+
maxImageBytes: resolved.maxImageBytes,
|
|
89
|
+
timeoutMs: resolved.timeoutMs,
|
|
90
|
+
fetchImpl,
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
const answers = await provider({ config: resolved, image, questions: validQuestions, fetchImpl });
|
|
94
|
+
return { request_id: requestId, answers };
|
|
95
|
+
} finally {
|
|
96
|
+
await image.dispose();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Last-line defense: no output path may carry the resolved key.
|
|
101
|
+
const visionErr = toVisionError(err);
|
|
102
|
+
visionErr.message = redactSecrets(visionErr.message, secrets);
|
|
103
|
+
throw visionErr;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { config: resolved, inspect };
|
|
108
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Anthropic-compatible adapter (Messages API with base64 image blocks).
|
|
2
|
+
// Convention: vision.baseUrl is the gateway root, e.g.
|
|
3
|
+
// https://gateway.example.com — the adapter appends /v1/messages (or just
|
|
4
|
+
// /messages when the base already ends in /v1).
|
|
5
|
+
|
|
6
|
+
import { ERROR_CODES, VisionError } from "../errors.mjs";
|
|
7
|
+
import { buildPrompt, createBase64JsonBody, normalizeAnswers, postJson } from "./shared.mjs";
|
|
8
|
+
|
|
9
|
+
function messagesUrl(baseUrl) {
|
|
10
|
+
return baseUrl.endsWith("/v1") ? `${baseUrl}/messages` : `${baseUrl}/v1/messages`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function replyText(json) {
|
|
14
|
+
if (!Array.isArray(json?.content)) return null;
|
|
15
|
+
const parts = json.content.filter((p) => p?.type === "text" && typeof p.text === "string");
|
|
16
|
+
if (parts.length === 0) return null;
|
|
17
|
+
return parts.map((p) => p.text).join("");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function inspectWithAnthropicCompatible({ config, image, questions, fetchImpl }) {
|
|
21
|
+
const headers = { "anthropic-version": "2023-06-01" };
|
|
22
|
+
if (config.apiKey) headers["x-api-key"] = config.apiKey;
|
|
23
|
+
const body = createBase64JsonBody(image, (imageBase64) => ({
|
|
24
|
+
model: config.model,
|
|
25
|
+
max_tokens: config.maxOutputTokens,
|
|
26
|
+
messages: [
|
|
27
|
+
{
|
|
28
|
+
role: "user",
|
|
29
|
+
content: [
|
|
30
|
+
{
|
|
31
|
+
type: "image",
|
|
32
|
+
source: {
|
|
33
|
+
type: "base64",
|
|
34
|
+
media_type: image.mediaType,
|
|
35
|
+
data: imageBase64,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{ type: "text", text: buildPrompt(questions) },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
}));
|
|
43
|
+
const json = await postJson({
|
|
44
|
+
url: messagesUrl(config.baseUrl),
|
|
45
|
+
headers,
|
|
46
|
+
body,
|
|
47
|
+
timeoutMs: config.timeoutMs,
|
|
48
|
+
fetchImpl,
|
|
49
|
+
providerLabel: "Anthropic-compatible provider",
|
|
50
|
+
});
|
|
51
|
+
const text = replyText(json);
|
|
52
|
+
if (text === null) {
|
|
53
|
+
throw new VisionError(
|
|
54
|
+
ERROR_CODES.PROVIDER_RESPONSE,
|
|
55
|
+
"Anthropic-compatible provider response has no text content blocks."
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return normalizeAnswers(text, questions);
|
|
59
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// OpenAI-compatible adapter (Chat Completions with image_url content parts).
|
|
2
|
+
// Convention: vision.baseUrl includes the version prefix, e.g.
|
|
3
|
+
// https://gateway.example.com/v1 — the adapter appends /chat/completions.
|
|
4
|
+
|
|
5
|
+
import { ERROR_CODES, VisionError } from "../errors.mjs";
|
|
6
|
+
import { buildPrompt, createBase64JsonBody, normalizeAnswers, postJson } from "./shared.mjs";
|
|
7
|
+
|
|
8
|
+
function replyText(json) {
|
|
9
|
+
const message = json?.choices?.[0]?.message;
|
|
10
|
+
if (typeof message?.content === "string") return message.content;
|
|
11
|
+
if (Array.isArray(message?.content)) {
|
|
12
|
+
return message.content
|
|
13
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
14
|
+
.map((part) => part.text)
|
|
15
|
+
.join("");
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function inspectWithOpenAICompatible({ config, image, questions, fetchImpl }) {
|
|
21
|
+
const headers = {};
|
|
22
|
+
if (config.apiKey) headers.authorization = `Bearer ${config.apiKey}`;
|
|
23
|
+
const body = createBase64JsonBody(image, (imageBase64) => ({
|
|
24
|
+
model: config.model,
|
|
25
|
+
// Gateway defaults vary and can silently truncate long transcriptions.
|
|
26
|
+
max_tokens: config.maxOutputTokens,
|
|
27
|
+
messages: [
|
|
28
|
+
{
|
|
29
|
+
role: "user",
|
|
30
|
+
content: [
|
|
31
|
+
{ type: "text", text: buildPrompt(questions) },
|
|
32
|
+
{
|
|
33
|
+
type: "image_url",
|
|
34
|
+
image_url: { url: `data:${image.mediaType};base64,${imageBase64}` },
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}));
|
|
40
|
+
const json = await postJson({
|
|
41
|
+
url: `${config.baseUrl}/chat/completions`,
|
|
42
|
+
headers,
|
|
43
|
+
body,
|
|
44
|
+
timeoutMs: config.timeoutMs,
|
|
45
|
+
fetchImpl,
|
|
46
|
+
providerLabel: "OpenAI-compatible provider",
|
|
47
|
+
});
|
|
48
|
+
const text = replyText(json);
|
|
49
|
+
if (text === null) {
|
|
50
|
+
throw new VisionError(
|
|
51
|
+
ERROR_CODES.PROVIDER_RESPONSE,
|
|
52
|
+
"OpenAI-compatible provider response has no choices[0].message.content."
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return normalizeAnswers(text, questions);
|
|
56
|
+
}
|