@kairyou/agent-tools 0.1.0 → 0.2.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/lib/usage.mjs CHANGED
@@ -14,7 +14,7 @@ const CODEX_HOME = process.env.CODEX_HOME || join(homedir(), ".codex");
14
14
  const AGENT_TOOLS_HOME = process.env.AGENT_TOOLS_HOME || join(homedir(), ".agent-tools");
15
15
  const AUTH_PATH = join(CODEX_HOME, "auth.json");
16
16
  const CODEX_CONFIG_PATH = join(CODEX_HOME, "config.toml");
17
- const AGENT_CONFIG_PATH = process.env.AGENT_TOOLS_CONFIG || join(AGENT_TOOLS_HOME, "config.jsonc");
17
+ const AGENT_CONFIG_PATH = join(AGENT_TOOLS_HOME, "config.jsonc");
18
18
  const DEBUG_PATH = join(AGENT_TOOLS_HOME, "logs", "usage-debug.log");
19
19
  const ROUTE_CACHE_PATH = join(AGENT_TOOLS_HOME, "cache", "usage-routes.json");
20
20
  const SNAPSHOT_PATH = join(AGENT_TOOLS_HOME, "cache", "usage-snapshot.json");
@@ -0,0 +1,137 @@
1
+ // `agent-tools inspect-image` and the installed agent fallback entry for the
2
+ // vision runtime. MCP remains preferred; this direct entry covers hosts or
3
+ // model gateways that cannot invoke MCP namespace tools.
4
+ //
5
+ // Usage:
6
+ // agent-tools inspect-image <path|url> --question "<text>" [--question "..."]
7
+ // agent-tools inspect-image --request-file <request.json> [--json]
8
+ //
9
+ // Options:
10
+ // -q, --question <text> Question about the image (repeatable with a target).
11
+ // --request-file <path> Read MCP-shaped { image_source, questions } JSON.
12
+ // --json Print the raw JSON result only.
13
+ // -h, --help Show this help.
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { createVisionService } from "./inspect.mjs";
19
+ import { isVisionError } from "./errors.mjs";
20
+
21
+ function printHelp() {
22
+ const lines = [];
23
+ for (const line of fs.readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n")) {
24
+ if (line.startsWith("//")) lines.push(line.replace(/^\/\/ ?/, ""));
25
+ else if (lines.length) break;
26
+ }
27
+ console.log(lines.join("\n"));
28
+ }
29
+
30
+ function parseArgs(argv) {
31
+ const opts = { target: null, questions: [], requestFile: null, json: false, help: false };
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const a = argv[i];
34
+ switch (a) {
35
+ case "-q":
36
+ case "--question": {
37
+ const value = argv[++i];
38
+ if (!value) {
39
+ console.error(`Missing value for ${a}`);
40
+ process.exit(2);
41
+ }
42
+ opts.questions.push(value);
43
+ break;
44
+ }
45
+ case "--json": opts.json = true; break;
46
+ case "--request-file": {
47
+ const value = argv[++i];
48
+ if (!value) {
49
+ console.error("Missing value for --request-file");
50
+ process.exit(2);
51
+ }
52
+ opts.requestFile = value;
53
+ break;
54
+ }
55
+ case "-h":
56
+ case "--help": opts.help = true; break;
57
+ default:
58
+ if (a.startsWith("-")) {
59
+ console.error(`Unknown option: ${a}`);
60
+ process.exit(2);
61
+ }
62
+ if (opts.target) {
63
+ console.error(`Unexpected extra argument: ${a} (one image per call)`);
64
+ process.exit(2);
65
+ }
66
+ opts.target = a;
67
+ }
68
+ }
69
+ return opts;
70
+ }
71
+
72
+ export async function runInspectImageCli(argv) {
73
+ const opts = parseArgs(argv);
74
+ if (opts.help || (!opts.target && !opts.requestFile && opts.questions.length === 0)) {
75
+ printHelp();
76
+ return opts.help ? 0 : 2;
77
+ }
78
+ if (opts.requestFile && (opts.target || opts.questions.length > 0)) {
79
+ console.error("--request-file cannot be combined with <path|url> or --question.");
80
+ return 2;
81
+ }
82
+
83
+ try {
84
+ let imageSource;
85
+ let questions;
86
+ if (opts.requestFile) {
87
+ const requestPath = path.resolve(opts.requestFile);
88
+ let request;
89
+ try {
90
+ request = JSON.parse(fs.readFileSync(requestPath, "utf8"));
91
+ } catch (err) {
92
+ console.error(`Cannot read --request-file ${requestPath}: ${err.message}`);
93
+ return 2;
94
+ }
95
+ imageSource = request?.image_source;
96
+ questions = request?.questions;
97
+ } else {
98
+ if (!opts.target) {
99
+ console.error("Missing <path|url> argument.");
100
+ return 2;
101
+ }
102
+ if (opts.questions.length === 0) {
103
+ console.error('Missing --question. Example: --question "What error code is shown?"');
104
+ return 2;
105
+ }
106
+ const type = /^https?:\/\//i.test(opts.target) ? "url" : "file";
107
+ imageSource = { type, value: opts.target };
108
+ questions = opts.questions.map((text, i) => ({ id: `q${i + 1}`, text }));
109
+ }
110
+
111
+ const service = createVisionService();
112
+ const result = await service.inspect({
113
+ image_source: imageSource,
114
+ questions,
115
+ });
116
+ if (opts.json) {
117
+ console.log(JSON.stringify(result, null, 2));
118
+ return 0;
119
+ }
120
+ console.log(`request_id: ${result.request_id}`);
121
+ for (const answer of result.answers) {
122
+ const q = questions.find((x) => x.id === answer.question_id);
123
+ console.log(`\n${answer.question_id}: ${q ? q.text : ""}`);
124
+ console.log(` answer: ${answer.answer === null ? "(none)" : answer.answer}`);
125
+ if (answer.uncertainty) console.log(` uncertainty: ${answer.uncertainty}`);
126
+ }
127
+ return 0;
128
+ } catch (err) {
129
+ const code = isVisionError(err) ? err.code : "internal_error";
130
+ console.error(`[${code}] ${err.message}`);
131
+ return 1;
132
+ }
133
+ }
134
+
135
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
136
+ process.exitCode = await runInspectImageCli(process.argv.slice(2));
137
+ }
@@ -0,0 +1,159 @@
1
+ // Vision configuration loader. The single source of truth is
2
+ // ~/.agent-tools/config.jsonc (override root with AGENT_TOOLS_HOME). No
3
+ // implicit environment fallback: `apiKey` is either a literal string or an
4
+ // explicit `{ "env": "VARIABLE_NAME" }` secret reference declared in the file.
5
+
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { parse as parseJsonc } from "jsonc-parser";
10
+ import { ERROR_CODES, VisionError } from "./errors.mjs";
11
+
12
+ export const PROVIDERS = Object.freeze(["openai-compatible", "anthropic-compatible"]);
13
+
14
+ export const CONFIG_DEFAULTS = Object.freeze({
15
+ timeoutMs: 30000,
16
+ maxImageBytes: 20 * 1024 * 1024,
17
+ maxConcurrentRequests: 2,
18
+ maxRequestsPerMinute: 30,
19
+ // Sent to both providers (Anthropic requires max_tokens; OpenAI gateway
20
+ // defaults are unpredictable). Lower it if your model caps output smaller.
21
+ maxOutputTokens: 8192,
22
+ });
23
+
24
+ const CONFIG_TEMPLATE = `{
25
+ "vision": {
26
+ "provider": "openai-compatible", // or "anthropic-compatible"
27
+ "baseUrl": "https://gateway.example.com/v1",
28
+ "model": "internal-vlm",
29
+ "apiKey": { "env": "OPENAI_API_KEY" } // or a literal string
30
+ }
31
+ }`;
32
+
33
+ export function agentToolsHome(env = process.env) {
34
+ return env.AGENT_TOOLS_HOME || path.join(os.homedir(), ".agent-tools");
35
+ }
36
+
37
+ export function configPath(env = process.env) {
38
+ return path.join(agentToolsHome(env), "config.jsonc");
39
+ }
40
+
41
+ function configError(message) {
42
+ return new VisionError(
43
+ ERROR_CODES.CONFIG,
44
+ `${message}\nAdd a "vision" block to ${configPath()} , for example:\n${CONFIG_TEMPLATE}`
45
+ );
46
+ }
47
+
48
+ // Resolve `apiKey` per the secret reference rules. Returns the secret string,
49
+ // or null when the gateway does not require a key (field omitted).
50
+ export function resolveSecret(apiKey, env = process.env) {
51
+ if (apiKey === undefined || apiKey === null) return null;
52
+ if (typeof apiKey === "string") {
53
+ if (apiKey.trim() === "") {
54
+ throw new VisionError(ERROR_CODES.CONFIG, "vision.apiKey is an empty string; remove it or set a value.");
55
+ }
56
+ return apiKey;
57
+ }
58
+ if (typeof apiKey === "object" && typeof apiKey.env === "string" && apiKey.env.trim() !== "") {
59
+ const name = apiKey.env;
60
+ const value = env[name];
61
+ if (value === undefined || value === "") {
62
+ // Name the variable, never its (missing) value; no silent fallback.
63
+ throw new VisionError(
64
+ ERROR_CODES.CONFIG,
65
+ `vision.apiKey references environment variable "${name}", which is not set or empty.`
66
+ );
67
+ }
68
+ return value;
69
+ }
70
+ throw new VisionError(
71
+ ERROR_CODES.CONFIG,
72
+ 'vision.apiKey must be a string or { "env": "VARIABLE_NAME" }.'
73
+ );
74
+ }
75
+
76
+ function positiveInt(raw, name, fallback, { allowZero = false } = {}) {
77
+ if (raw === undefined || raw === null) return fallback;
78
+ if (!Number.isInteger(raw) || raw < 0 || (!allowZero && raw === 0)) {
79
+ throw new VisionError(
80
+ ERROR_CODES.CONFIG,
81
+ `vision.${name} must be a positive integer${allowZero ? " (0 disables it)" : ""}.`
82
+ );
83
+ }
84
+ return raw;
85
+ }
86
+
87
+ function normalizeBaseUrl(raw) {
88
+ if (typeof raw !== "string") throw configError("vision.baseUrl must be an http(s) URL.");
89
+ let parsed;
90
+ try {
91
+ parsed = new URL(raw);
92
+ } catch {
93
+ throw configError("vision.baseUrl must be an http(s) URL.");
94
+ }
95
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
96
+ throw configError("vision.baseUrl must be an http(s) URL.");
97
+ }
98
+ if (parsed.username || parsed.password) {
99
+ throw configError("vision.baseUrl must not contain credentials.");
100
+ }
101
+ if (parsed.search || parsed.hash) {
102
+ throw configError("vision.baseUrl must not contain a query string or fragment.");
103
+ }
104
+ return parsed.toString().replace(/\/+$/, "");
105
+ }
106
+
107
+ // Load and validate the vision config. `file`/`env` are injectable for tests.
108
+ export function loadVisionConfig({ file, env = process.env } = {}) {
109
+ const target = file || configPath(env);
110
+ if (!fs.existsSync(target)) {
111
+ throw configError(`Config file not found: ${target}`);
112
+ }
113
+ let raw;
114
+ try {
115
+ raw = fs.readFileSync(target, "utf8").replace(/^/, "");
116
+ } catch (err) {
117
+ throw configError(`Cannot read ${target}: ${err.message}`);
118
+ }
119
+ const errors = [];
120
+ const parsed = parseJsonc(raw, errors, { allowTrailingComma: true });
121
+ if (errors.length > 0 || !parsed || typeof parsed !== "object") {
122
+ throw configError(`Cannot parse ${target} as JSONC.`);
123
+ }
124
+ const vision = parsed.vision;
125
+ if (!vision || typeof vision !== "object") {
126
+ throw configError(`Missing "vision" section in ${target}.`);
127
+ }
128
+ if (!PROVIDERS.includes(vision.provider)) {
129
+ throw configError(
130
+ `vision.provider must be one of: ${PROVIDERS.join(", ")} (got ${JSON.stringify(vision.provider ?? null)}).`
131
+ );
132
+ }
133
+ const baseUrl = normalizeBaseUrl(vision.baseUrl);
134
+ if (typeof vision.model !== "string" || vision.model.trim() === "") {
135
+ throw configError("vision.model must be a non-empty string.");
136
+ }
137
+
138
+ return {
139
+ provider: vision.provider,
140
+ baseUrl,
141
+ model: vision.model,
142
+ apiKey: resolveSecret(vision.apiKey, env),
143
+ timeoutMs: positiveInt(vision.timeoutMs, "timeoutMs", CONFIG_DEFAULTS.timeoutMs),
144
+ maxImageBytes: positiveInt(vision.maxImageBytes, "maxImageBytes", CONFIG_DEFAULTS.maxImageBytes),
145
+ maxConcurrentRequests: positiveInt(
146
+ vision.maxConcurrentRequests,
147
+ "maxConcurrentRequests",
148
+ CONFIG_DEFAULTS.maxConcurrentRequests
149
+ ),
150
+ // 0 disables the rolling-window limit; concurrency has no disable switch.
151
+ maxRequestsPerMinute: positiveInt(
152
+ vision.maxRequestsPerMinute,
153
+ "maxRequestsPerMinute",
154
+ CONFIG_DEFAULTS.maxRequestsPerMinute,
155
+ { allowZero: true }
156
+ ),
157
+ maxOutputTokens: positiveInt(vision.maxOutputTokens, "maxOutputTokens", CONFIG_DEFAULTS.maxOutputTokens),
158
+ };
159
+ }
@@ -0,0 +1,35 @@
1
+ // Normalized error type for the vision runtime. Every failure surfaced to the
2
+ // MCP tool, the diagnostic CLI, or tests carries a stable `code` so callers can
3
+ // branch on category without parsing prose.
4
+
5
+ export const ERROR_CODES = Object.freeze({
6
+ CONFIG: "config_error",
7
+ INPUT: "input_error",
8
+ FETCH: "fetch_error",
9
+ RATE_LIMIT: "rate_limit_error",
10
+ PROVIDER_AUTH: "provider_auth_error",
11
+ PROVIDER_HTTP: "provider_http_error",
12
+ PROVIDER_TIMEOUT: "provider_timeout_error",
13
+ PROVIDER_RESPONSE: "provider_response_error",
14
+ });
15
+
16
+ export class VisionError extends Error {
17
+ constructor(code, message, { cause, detail } = {}) {
18
+ super(message, cause ? { cause } : undefined);
19
+ this.name = "VisionError";
20
+ this.code = code;
21
+ if (detail !== undefined) this.detail = detail;
22
+ }
23
+ }
24
+
25
+ export function isVisionError(err) {
26
+ return err instanceof VisionError;
27
+ }
28
+
29
+ // Wrap unknown failures so callers always see a VisionError. Existing
30
+ // VisionErrors pass through untouched.
31
+ export function toVisionError(err, fallbackCode = ERROR_CODES.PROVIDER_HTTP) {
32
+ if (isVisionError(err)) return err;
33
+ const message = err && typeof err.message === "string" ? err.message : String(err);
34
+ return new VisionError(fallbackCode, message, { cause: err });
35
+ }
@@ -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
+ }