@tealbrick/vision 0.1.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/LICENSE +21 -0
- package/NOTICE +3 -0
- package/README.md +71 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +44 -0
- package/dist/eve.d.ts +11 -0
- package/dist/eve.js +43 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +159 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tealbrick contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @tealbrick/vision
|
|
2
|
+
|
|
3
|
+
Configurable image analysis for Teal Brick. Node 24+, with optional native Eve 0.55.0 adapters. The core is host-independent; Codex/Claude MCP mounting remains separate adapter work.
|
|
4
|
+
|
|
5
|
+
Supports OpenAI-compatible **Chat Completions** and **Responses** endpoints using supplied PNG, JPEG or WebP images. Configure the full URL, protocol, model, credential reference, output-token budget and image limits. No default provider is selected. Protocol reference: [OpenAI image inputs](https://developers.openai.com/api/docs/guides/images-vision).
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import {createVisionHandler} from '@tealbrick/vision';
|
|
9
|
+
|
|
10
|
+
const handle = createVisionHandler({
|
|
11
|
+
auth: {issuer:'https://portal.example', org:'my-org', agent:'helper'},
|
|
12
|
+
endpoints: [{
|
|
13
|
+
url:'https://models.example/v1/chat/completions',
|
|
14
|
+
credentialRefs:['vision-production'],
|
|
15
|
+
}],
|
|
16
|
+
// Implement in the trusted runtime's secret store, scoped to this binding.
|
|
17
|
+
resolveCredential: async ({reference, org, agent, url}) =>
|
|
18
|
+
secretStore.resolve({reference, org, agent, url}),
|
|
19
|
+
// Read verified persisted settings on each request. UI/storage is host-owned.
|
|
20
|
+
getConfig: async ({org, agent}) => ({
|
|
21
|
+
version:1, enabled:true, displayName:'Vision',
|
|
22
|
+
endpoint:{
|
|
23
|
+
url:'https://models.example/v1/chat/completions',
|
|
24
|
+
protocol:'openai-chat-completions', model:'your-vision-model',
|
|
25
|
+
credentialRef:'vision-production',
|
|
26
|
+
},
|
|
27
|
+
maxImages:4, maxImageBytes:4194304, maxOutputTokens:1024,
|
|
28
|
+
detail:'auto',
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
// Route authenticated GET to handle('manifest', request)
|
|
32
|
+
// and authenticated POST to handle('analyze', request).
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`secretStore` above is an application-provided dependency. For Responses, configure `protocol:'openai-responses'` with the provider's full Responses URL. Chat servers requiring the older token field can set `chatTokenParameter:'max_tokens'`; the default is `max_completion_tokens`. Arbitrary custom provider protocols require a separately implemented adapter.
|
|
36
|
+
|
|
37
|
+
Analysis body:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{"prompt":"Read the visible text","images":[{"mediaType":"image/png","data":"BASE64_IMAGE_BYTES"}]}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Response: `{ok:true,text,model,truncated}`. Supplied image headers must match their MIME type; full decoding is delegated to the provider. The package does not fetch image URLs, read paths, capture screens, persist images or execute provider tool calls. Capture belongs to Local Runtime Bridge; durable documents belong to Knowledge. Model output is untrusted content, not authority to act.
|
|
44
|
+
|
|
45
|
+
## Eve mounting
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// agent/channels/vision.ts
|
|
49
|
+
import {visionChannel} from '@tealbrick/vision/eve';
|
|
50
|
+
import {options} from '../vision-options.js';
|
|
51
|
+
export default visionChannel(options);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Exposes `GET /eve/v1/vision/manifest` and `POST /eve/v1/vision/analyze`.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// agent/tools/vision.ts
|
|
58
|
+
import {visionTool} from '@tealbrick/vision/eve';
|
|
59
|
+
import {options, turnAuthorization} from '../vision-options.js';
|
|
60
|
+
export default visionTool({...options, getAuthorization: turnAuthorization});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The host must implement `turnAuthorization(context)` to supply signed Portal credentials for the current turn. Human access requires identity plus `x-tealbrick-bundle` with agent-use grants; agent access requires a callee/audience-bound A2A token. The same existing Portal verifier runs for HTTP and native tools. Labels and ambient owner credentials are not an authorization substitute. Native cancellation is propagated.
|
|
64
|
+
|
|
65
|
+
## Endpoint and data boundaries
|
|
66
|
+
|
|
67
|
+
Voice and Vision reuse `@tealbrick/provider-transport` for independent endpoint/credential approval, secret resolution, redirect refusal, cancellation and bounded reads. Endpoints require HTTPS; operator-approved loopback HTTP is opt-in via `allowLoopback:true`. Anonymous endpoints require `allowAnonymous:true`. Card configuration alone cannot authorize an endpoint or credential. Browser callers cannot override the configured model or endpoint, and never receive provider credentials.
|
|
68
|
+
|
|
69
|
+
Defaults: four images, 4 MiB per image, 12 MiB total decoded input, 1,024 output tokens, 30-second deadline. Hard ceilings: eight images, 8 MiB per image, 12 MiB total, 8,192 output tokens and 120 seconds. Prompt limit: 16,000 characters. Provider response limit: 256 KiB and 64,000 text characters. Responses requests set `store:false`; a third-party provider's retention behavior remains its own contract.
|
|
70
|
+
|
|
71
|
+
Tests use disposable signed tokens and synthetic provider responses. Package discovery/build is separate from real-provider acceptance. Portal card UI/persistence and shipped Codex/Claude adapters are not included here.
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type VisionProtocol = "openai-chat-completions" | "openai-responses";
|
|
2
|
+
export interface VisionConfig {
|
|
3
|
+
version: 1;
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
displayName: string;
|
|
6
|
+
endpoint?: {
|
|
7
|
+
url: string;
|
|
8
|
+
protocol: VisionProtocol;
|
|
9
|
+
model: string;
|
|
10
|
+
credentialRef?: string;
|
|
11
|
+
};
|
|
12
|
+
maxImages?: number;
|
|
13
|
+
maxImageBytes?: number;
|
|
14
|
+
maxTotalImageBytes?: number;
|
|
15
|
+
maxOutputTokens?: number;
|
|
16
|
+
detail?: "auto" | "low" | "high";
|
|
17
|
+
/** Legacy compatible servers can explicitly use max_tokens instead. */
|
|
18
|
+
chatTokenParameter?: "max_completion_tokens" | "max_tokens";
|
|
19
|
+
}
|
|
20
|
+
export type ResolvedVisionConfig = VisionConfig & Required<Pick<VisionConfig, "maxImages" | "maxImageBytes" | "maxTotalImageBytes" | "maxOutputTokens" | "detail" | "chatTokenParameter">>;
|
|
21
|
+
export declare function isRecord(v: unknown): v is Record<string, unknown>;
|
|
22
|
+
/** Detached settings snapshot with no raw credentials or arbitrary provider parameters. */
|
|
23
|
+
export declare function validateVisionConfig(value: unknown): ResolvedVisionConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { endpointUrl } from "@tealbrick/provider-transport";
|
|
2
|
+
export function isRecord(v) { return !!v && typeof v === "object" && !Array.isArray(v); }
|
|
3
|
+
function validString(v) { return typeof v === "string" && !!v.trim() && v.length <= 200 && !/[\u0000-\u001f\u007f]/.test(v); }
|
|
4
|
+
function exact(v, keys) { if (Object.keys(v).some(k => !keys.includes(k)))
|
|
5
|
+
throw new Error("invalid_vision_config"); }
|
|
6
|
+
function integer(value, fallback, max) {
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
return fallback;
|
|
9
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > max)
|
|
10
|
+
throw new Error("invalid_vision_limit");
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
/** Detached settings snapshot with no raw credentials or arbitrary provider parameters. */
|
|
14
|
+
export function validateVisionConfig(value) {
|
|
15
|
+
if (!isRecord(value))
|
|
16
|
+
throw new Error("invalid_vision_config");
|
|
17
|
+
exact(value, ["version", "enabled", "displayName", "endpoint", "maxImages", "maxImageBytes", "maxTotalImageBytes", "maxOutputTokens", "detail", "chatTokenParameter"]);
|
|
18
|
+
if (value.version !== 1 || typeof value.enabled !== "boolean" || !validString(value.displayName))
|
|
19
|
+
throw new Error("invalid_vision_config");
|
|
20
|
+
if (value.endpoint !== undefined) {
|
|
21
|
+
const e = value.endpoint;
|
|
22
|
+
if (!isRecord(e))
|
|
23
|
+
throw new Error("invalid_vision_config");
|
|
24
|
+
exact(e, ["url", "protocol", "model", "credentialRef"]);
|
|
25
|
+
endpointUrl(e.url, true);
|
|
26
|
+
if (!["openai-chat-completions", "openai-responses"].includes(String(e.protocol)) || !validString(e.model) || (e.credentialRef !== undefined && !validString(e.credentialRef)))
|
|
27
|
+
throw new Error("invalid_vision_config");
|
|
28
|
+
}
|
|
29
|
+
if (value.enabled && value.endpoint === undefined)
|
|
30
|
+
throw new Error("vision_endpoint_required");
|
|
31
|
+
if (value.detail !== undefined && !["auto", "low", "high"].includes(String(value.detail)))
|
|
32
|
+
throw new Error("invalid_vision_config");
|
|
33
|
+
if (value.chatTokenParameter !== undefined && !["max_completion_tokens", "max_tokens"].includes(String(value.chatTokenParameter)))
|
|
34
|
+
throw new Error("invalid_vision_config");
|
|
35
|
+
return {
|
|
36
|
+
...structuredClone(value),
|
|
37
|
+
maxImages: integer(value.maxImages, 4, 8),
|
|
38
|
+
maxImageBytes: integer(value.maxImageBytes, 4 * 1024 * 1024, 8 * 1024 * 1024),
|
|
39
|
+
maxTotalImageBytes: integer(value.maxTotalImageBytes, 12 * 1024 * 1024, 12 * 1024 * 1024),
|
|
40
|
+
maxOutputTokens: integer(value.maxOutputTokens, 1024, 8192),
|
|
41
|
+
detail: value.detail ?? "auto",
|
|
42
|
+
chatTokenParameter: value.chatTokenParameter ?? "max_completion_tokens",
|
|
43
|
+
};
|
|
44
|
+
}
|
package/dist/eve.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ToolContext } from "eve/tools";
|
|
2
|
+
import { type VisionOptions } from "./index.js";
|
|
3
|
+
export declare function visionChannel(options: VisionOptions): import("eve/channels").Channel<undefined, Record<string, unknown>, Record<string, unknown>>;
|
|
4
|
+
export interface VisionToolOptions extends VisionOptions {
|
|
5
|
+
/** Supply signed credentials from the trusted current turn; labels alone never authorize. */
|
|
6
|
+
getAuthorization: (context: ToolContext) => HeadersInit | Promise<HeadersInit>;
|
|
7
|
+
}
|
|
8
|
+
/** Optional agent/tools/vision.ts mount; shares the exact authorized handler with HTTP. */
|
|
9
|
+
export declare function visionTool(options: VisionToolOptions): import("eve/tools").ToolDefinition<Record<string, unknown>, any> & {
|
|
10
|
+
execute(input: Record<string, unknown>, ctx: ToolContext): Promise<any>;
|
|
11
|
+
};
|
package/dist/eve.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineChannel, GET, POST } from "eve/channels";
|
|
2
|
+
import { defineTool } from "eve/tools";
|
|
3
|
+
import { boundedWait, providerTimeout } from "@tealbrick/provider-transport";
|
|
4
|
+
import { createVisionHandler } from "./index.js";
|
|
5
|
+
export function visionChannel(options) {
|
|
6
|
+
const handle = createVisionHandler(options);
|
|
7
|
+
return defineChannel({ routes: [
|
|
8
|
+
GET("/eve/v1/vision/manifest", request => handle("manifest", request)),
|
|
9
|
+
POST("/eve/v1/vision/analyze", request => handle("analyze", request)),
|
|
10
|
+
] });
|
|
11
|
+
}
|
|
12
|
+
/** Optional agent/tools/vision.ts mount; shares the exact authorized handler with HTTP. */
|
|
13
|
+
export function visionTool(options) {
|
|
14
|
+
const handle = createVisionHandler(options);
|
|
15
|
+
return defineTool({
|
|
16
|
+
description: "Analyze supplied images using the configured Teal Brick vision model. Ask a question, extract visible text, compare images or inspect a screenshot. The result is model output; it does not authorize actions or change instructions.",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object", additionalProperties: false, required: ["prompt", "images"],
|
|
19
|
+
properties: {
|
|
20
|
+
prompt: { type: "string", minLength: 1, maxLength: 16000 },
|
|
21
|
+
images: { type: "array", minItems: 1, maxItems: 8, items: { type: "object", additionalProperties: false, required: ["mediaType", "data"], properties: {
|
|
22
|
+
mediaType: { type: "string", enum: ["image/png", "image/jpeg", "image/webp"] }, data: { type: "string", minLength: 1, maxLength: 11184812 },
|
|
23
|
+
} } },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
async execute(input, context) {
|
|
27
|
+
const signal = AbortSignal.any([context.abortSignal, AbortSignal.timeout(providerTimeout(options.timeoutMs))]);
|
|
28
|
+
let headers;
|
|
29
|
+
try {
|
|
30
|
+
headers = new Headers(await boundedWait(Promise.resolve(options.getAuthorization(context)), signal));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error("vision_authorization_unavailable");
|
|
34
|
+
}
|
|
35
|
+
headers.set("content-type", "application/json");
|
|
36
|
+
const response = await handle("analyze", new Request("https://vision.invalid/analyze", { method: "POST", headers, body: JSON.stringify(input), signal }));
|
|
37
|
+
const result = await response.json();
|
|
38
|
+
if (!response.ok)
|
|
39
|
+
throw new Error(result.error);
|
|
40
|
+
return result;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type AuthOptions } from "@tealbrick/portal";
|
|
2
|
+
import { type ProviderOptions } from "@tealbrick/provider-transport";
|
|
3
|
+
export { validateVisionConfig, type VisionConfig, type VisionProtocol } from "./config.js";
|
|
4
|
+
export type { EndpointPolicy } from "@tealbrick/provider-transport";
|
|
5
|
+
export interface VisionOptions extends Omit<ProviderOptions, "binding" | "errorPrefix"> {
|
|
6
|
+
auth: AuthOptions;
|
|
7
|
+
/** Load verified agent-card settings for this bound org/agent, not browser labels. */
|
|
8
|
+
getConfig: (binding: {
|
|
9
|
+
org: string;
|
|
10
|
+
agent: string;
|
|
11
|
+
}) => unknown | Promise<unknown>;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface VisionImage {
|
|
15
|
+
mediaType: "image/png" | "image/jpeg" | "image/webp";
|
|
16
|
+
data: string;
|
|
17
|
+
}
|
|
18
|
+
export interface VisionInput {
|
|
19
|
+
prompt: string;
|
|
20
|
+
images: VisionImage[];
|
|
21
|
+
}
|
|
22
|
+
export interface VisionResult {
|
|
23
|
+
ok: true;
|
|
24
|
+
text: string;
|
|
25
|
+
model: string;
|
|
26
|
+
truncated: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** No default model/endpoint and no remote image fetching, tool execution or image persistence. */
|
|
29
|
+
export declare function createVisionHandler(options: VisionOptions): (operation: "manifest" | "analyze", request: Request) => Promise<Response>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createPortalVerifier } from "@tealbrick/portal";
|
|
3
|
+
import { boundedWait, bytes, createProviderTransport, ProviderFailure, providerTimeout } from "@tealbrick/provider-transport";
|
|
4
|
+
import { isRecord, validateVisionConfig } from "./config.js";
|
|
5
|
+
export { validateVisionConfig } from "./config.js";
|
|
6
|
+
const MAX_JSON = 17 * 1024 * 1024;
|
|
7
|
+
const MAX_RESPONSE = 256 * 1024;
|
|
8
|
+
const fail = (status, code) => { throw new ProviderFailure(status, code); };
|
|
9
|
+
const json = (value, status = 200) => Response.json(value, { status, headers: { "cache-control": "no-store" } });
|
|
10
|
+
function parse(data) {
|
|
11
|
+
try {
|
|
12
|
+
const v = JSON.parse(new TextDecoder().decode(data));
|
|
13
|
+
if (isRecord(v))
|
|
14
|
+
return v;
|
|
15
|
+
}
|
|
16
|
+
catch { /* redacted below */ }
|
|
17
|
+
return fail(400, "invalid_vision_request");
|
|
18
|
+
}
|
|
19
|
+
function validImageType(mediaType, image) {
|
|
20
|
+
if (mediaType === "image/png")
|
|
21
|
+
return image.length >= 24 && image.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && image.toString('ascii', 12, 16) === 'IHDR';
|
|
22
|
+
if (mediaType === "image/jpeg")
|
|
23
|
+
return image.length >= 4 && image[0] === 255 && image[1] === 216 && image[2] === 255;
|
|
24
|
+
if (mediaType === "image/webp")
|
|
25
|
+
return image.length >= 16 && image.toString('ascii', 0, 4) === 'RIFF' && image.toString('ascii', 8, 12) === 'WEBP';
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
function inputImages(body, config) {
|
|
29
|
+
if (Object.keys(body).some(k => !["prompt", "images"].includes(k)))
|
|
30
|
+
fail(400, "unsupported_vision_parameter");
|
|
31
|
+
if (typeof body.prompt !== "string" || !body.prompt.trim() || body.prompt.length > 16000 || body.prompt.includes("\0"))
|
|
32
|
+
fail(400, "invalid_vision_prompt");
|
|
33
|
+
if (!Array.isArray(body.images) || body.images.length < 1 || body.images.length > config.maxImages)
|
|
34
|
+
fail(400, "invalid_vision_image_count");
|
|
35
|
+
let total = 0;
|
|
36
|
+
const urls = [];
|
|
37
|
+
for (const item of body.images) {
|
|
38
|
+
if (!isRecord(item) || Object.keys(item).some(k => !["mediaType", "data"].includes(k)) || typeof item.data !== "string" || !item.data.length)
|
|
39
|
+
fail(400, "invalid_vision_image");
|
|
40
|
+
const image = item;
|
|
41
|
+
if (image.data.length > 4 * Math.ceil(config.maxImageBytes / 3))
|
|
42
|
+
fail(413, "vision_image_too_large");
|
|
43
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(image.data))
|
|
44
|
+
fail(400, "invalid_vision_base64");
|
|
45
|
+
const decoded = Buffer.from(image.data, "base64");
|
|
46
|
+
if (decoded.toString("base64") !== image.data)
|
|
47
|
+
fail(400, "invalid_vision_base64");
|
|
48
|
+
if (decoded.length > config.maxImageBytes || (total += decoded.length) > config.maxTotalImageBytes)
|
|
49
|
+
fail(413, "vision_image_too_large");
|
|
50
|
+
if (!validImageType(image.mediaType, decoded))
|
|
51
|
+
fail(415, "unsupported_or_mismatched_vision_image");
|
|
52
|
+
urls.push(`data:${image.mediaType};base64,${image.data}`);
|
|
53
|
+
}
|
|
54
|
+
return { prompt: body.prompt, urls };
|
|
55
|
+
}
|
|
56
|
+
function providerResult(data, config) {
|
|
57
|
+
let parts = [], truncated = false;
|
|
58
|
+
if (config.endpoint.protocol === "openai-chat-completions") {
|
|
59
|
+
const first = Array.isArray(data.choices) ? data.choices[0] : undefined;
|
|
60
|
+
if (!isRecord(first) || !isRecord(first.message))
|
|
61
|
+
fail(502, "invalid_vision_provider_response");
|
|
62
|
+
const choice = first;
|
|
63
|
+
if (choice.message.refusal || choice.finish_reason === "content_filter")
|
|
64
|
+
fail(422, "vision_provider_refused");
|
|
65
|
+
if (choice.message.tool_calls || choice.message.function_call)
|
|
66
|
+
fail(502, "unexpected_vision_tool_call");
|
|
67
|
+
if (typeof choice.message.content !== "string")
|
|
68
|
+
fail(502, "invalid_vision_provider_response");
|
|
69
|
+
parts = [choice.message.content];
|
|
70
|
+
truncated = choice.finish_reason === "length";
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
if (!["completed", "incomplete"].includes(String(data.status)) || !Array.isArray(data.output))
|
|
74
|
+
fail(502, "invalid_vision_provider_response");
|
|
75
|
+
for (const item of data.output) {
|
|
76
|
+
if (!isRecord(item))
|
|
77
|
+
fail(502, "invalid_vision_provider_response");
|
|
78
|
+
const entry = item;
|
|
79
|
+
if (entry.type === "reasoning")
|
|
80
|
+
continue;
|
|
81
|
+
if (entry.type !== "message" || entry.role !== "assistant" || !Array.isArray(entry.content))
|
|
82
|
+
fail(502, "unexpected_vision_provider_output");
|
|
83
|
+
for (const value of entry.content) {
|
|
84
|
+
if (!isRecord(value))
|
|
85
|
+
fail(502, "invalid_vision_provider_response");
|
|
86
|
+
const part = value;
|
|
87
|
+
if (part.type === "refusal")
|
|
88
|
+
fail(422, "vision_provider_refused");
|
|
89
|
+
if (part.type !== "output_text" || typeof part.text !== "string")
|
|
90
|
+
fail(502, "invalid_vision_provider_response");
|
|
91
|
+
parts.push(part.text);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
truncated = data.status === "incomplete";
|
|
95
|
+
}
|
|
96
|
+
const answer = parts.join("\n");
|
|
97
|
+
if (!answer.trim() || answer.length > 64000 || answer.includes("\0"))
|
|
98
|
+
fail(502, "invalid_vision_provider_response");
|
|
99
|
+
return { ok: true, text: answer, model: config.endpoint.model, truncated };
|
|
100
|
+
}
|
|
101
|
+
/** No default model/endpoint and no remote image fetching, tool execution or image persistence. */
|
|
102
|
+
export function createVisionHandler(options) {
|
|
103
|
+
const verifier = createPortalVerifier(options.auth);
|
|
104
|
+
const binding = Object.freeze({ org: options.auth.org, agent: options.auth.agent });
|
|
105
|
+
const transport = createProviderTransport({ ...options, binding, errorPrefix: "vision" });
|
|
106
|
+
const timeoutMs = providerTimeout(options.timeoutMs);
|
|
107
|
+
return async function handle(operation, request) {
|
|
108
|
+
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(timeoutMs)]);
|
|
109
|
+
try {
|
|
110
|
+
if (!["manifest", "analyze"].includes(operation))
|
|
111
|
+
return json({ ok: false, error: "vision_route_not_found" }, 404);
|
|
112
|
+
if (request.method !== (operation === "manifest" ? "GET" : "POST"))
|
|
113
|
+
return json({ ok: false, error: "method_not_allowed" }, 405);
|
|
114
|
+
const auth = await boundedWait(verifier.authenticate(request), signal);
|
|
115
|
+
if (!auth.ok)
|
|
116
|
+
return json({ ok: false, error: auth.code }, auth.status);
|
|
117
|
+
let config;
|
|
118
|
+
try {
|
|
119
|
+
config = validateVisionConfig(await boundedWait(Promise.resolve(options.getConfig(binding)), signal));
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (signal.aborted)
|
|
123
|
+
throw error;
|
|
124
|
+
return json({ ok: false, error: "vision_config_unavailable" }, 503);
|
|
125
|
+
}
|
|
126
|
+
if (config.enabled && config.endpoint)
|
|
127
|
+
transport.policy(config.endpoint);
|
|
128
|
+
if (operation === "manifest")
|
|
129
|
+
return json({ ok: true, enabled: config.enabled, displayName: config.displayName, mediaTypes: ["image/png", "image/jpeg", "image/webp"], maxImages: config.maxImages, maxImageBytes: config.maxImageBytes, maxTotalImageBytes: config.maxTotalImageBytes });
|
|
130
|
+
if (!config.enabled || !config.endpoint)
|
|
131
|
+
fail(409, "vision_disabled");
|
|
132
|
+
if (request.headers.get("content-type")?.split(";")[0].trim().toLowerCase() !== "application/json")
|
|
133
|
+
fail(415, "vision_json_required");
|
|
134
|
+
const input = inputImages(parse(await bytes(request.body, MAX_JSON, signal, "vision_payload_too_large")), config);
|
|
135
|
+
const endpoint = config.endpoint;
|
|
136
|
+
const body = endpoint.protocol === "openai-chat-completions"
|
|
137
|
+
? { model: endpoint.model, stream: false, [config.chatTokenParameter]: config.maxOutputTokens, messages: [{ role: "user", content: [{ type: "text", text: input.prompt }, ...input.urls.map(url => ({ type: "image_url", image_url: { url, detail: config.detail } }))] }] }
|
|
138
|
+
: { model: endpoint.model, stream: false, store: false, max_output_tokens: config.maxOutputTokens, input: [{ role: "user", content: [{ type: "input_text", text: input.prompt }, ...input.urls.map(image_url => ({ type: "input_image", image_url, detail: config.detail }))] }] };
|
|
139
|
+
const response = await transport.upstream(endpoint, JSON.stringify(body), signal, "application/json");
|
|
140
|
+
let data;
|
|
141
|
+
try {
|
|
142
|
+
data = parse(await bytes(response.body, MAX_RESPONSE, signal));
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (signal.aborted)
|
|
146
|
+
throw error;
|
|
147
|
+
return json({ ok: false, error: "invalid_vision_provider_response" }, 502);
|
|
148
|
+
}
|
|
149
|
+
return json(providerResult(data, config));
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (signal.aborted)
|
|
153
|
+
return json({ ok: false, error: "vision_timeout_or_cancelled" }, 504);
|
|
154
|
+
if (error instanceof ProviderFailure)
|
|
155
|
+
return json({ ok: false, error: error.code }, error.status);
|
|
156
|
+
return json({ ok: false, error: "vision_unavailable" }, 503);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tealbrick/vision",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Portal-authorized configurable image analysis with native Eve adapters",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"NOTICE"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=24"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./eve": {
|
|
22
|
+
"types": "./dist/eve.d.ts",
|
|
23
|
+
"import": "./dist/eve.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"test": "node --test test/*.test.mjs",
|
|
29
|
+
"check": "npm run build && npm test"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@tealbrick/portal": "0.1.0",
|
|
33
|
+
"@tealbrick/provider-transport": "0.1.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"eve": ">=0.55.0 <0.56.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"eve": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"typescript": "^5.9.3",
|
|
45
|
+
"@types/node": "^24.0.0"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public",
|
|
49
|
+
"registry": "https://registry.npmjs.org"
|
|
50
|
+
},
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/Doppelabs/tealbrick-packages.git",
|
|
54
|
+
"directory": "packages/vision"
|
|
55
|
+
}
|
|
56
|
+
}
|