@corbits/system-one 0.3.2 → 0.4.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 +62 -22
- package/dist/adapter.d.ts +1 -1
- package/dist/adapter.js +41 -7
- package/dist/assets.d.ts +36 -0
- package/dist/assets.js +232 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +67 -2
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -0
- package/dist/decisions.d.ts +99 -0
- package/dist/decisions.js +166 -0
- package/dist/evaluate.d.ts +15 -0
- package/dist/evaluate.js +28 -5
- package/dist/extraction.d.ts +104 -0
- package/dist/extraction.js +332 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/schemas.d.ts +117 -7
- package/dist/schemas.js +96 -6
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -70,6 +70,10 @@ Boolean questions answer as `noul`, a probability from 0 to 1 that the answer is
|
|
|
70
70
|
| Export | Description |
|
|
71
71
|
| --------------------------------- | ----------------------------------------------------------------------------------- |
|
|
72
72
|
| `evaluate(input, options?)` | Runs one evaluation. Returns `EvaluateResult`: decisions or a `FallbackResult`. |
|
|
73
|
+
| `systemOne(input, options?)` | Keyed decisions. Returns `answers` by question name, or the same fallback. |
|
|
74
|
+
| `choice`, `noul`, `score`, `box` | Build one keyed question. `box` locates a region and needs model `jev-preview`. |
|
|
75
|
+
| `image`, `Image.fromBase64` | PNG or JPEG bytes, a `Blob`, or base64. Other formats are rejected before send. |
|
|
76
|
+
| `withAssets` | Tag a template so interpolated images are sent. Also `withAssets.join`. |
|
|
73
77
|
| `createSystemOneAdapter(config?)` | Returns a `ProviderAdapter` for `runInference`. `config` is an `EvaluateConfig`. |
|
|
74
78
|
| `createSystemOneAdapterFactory` | Interchange `AdapterFactory` for `SIDECAR_ADAPTER_MANIFEST`; takes offering quirks. |
|
|
75
79
|
| `SYSTEM_ONE_PROVIDER` | The `"corbits-system-one"` provider id. |
|
|
@@ -82,24 +86,59 @@ Boolean questions answer as `noul`, a probability from 0 to 1 that the answer is
|
|
|
82
86
|
|
|
83
87
|
### Questions
|
|
84
88
|
|
|
85
|
-
| `type` | Fields | Decision fields
|
|
86
|
-
| --------- | ------------------------------------------------------------------------------- |
|
|
87
|
-
| `choice` | `instructions`, `criteria` (option name → description or `null`, 1–255 options) | `choice`, `probabilities`, `confidence`
|
|
88
|
-
| `score` | `instructions`, `criteria` (2–10 descriptions) | `score`, `legend`, `probabilities`, `confidence`
|
|
89
|
-
| `boolean` | `instructions`, optional `criteria: { true?, false? }` | `noul`
|
|
90
|
-
| `noul` | Same as `boolean` | `noul`
|
|
89
|
+
| `type` | Fields | Decision fields |
|
|
90
|
+
| --------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
91
|
+
| `choice` | `instructions`, `criteria` (option name → description or `null`, 1–255 options) | `choice`, `probabilities`, `confidence` |
|
|
92
|
+
| `score` | `instructions`, `criteria` (2–10 descriptions) | `score`, `legend`, `probabilities`, `confidence` |
|
|
93
|
+
| `boolean` | `instructions`, optional `criteria: { true?, false? }` | `noul` |
|
|
94
|
+
| `noul` | Same as `boolean` | `noul` |
|
|
95
|
+
| `box` | `instructions` describing what to locate. Use model `jev-preview`. | `box` (`top`, `bottom`, `left`, `right` as fractions), `confidence` |
|
|
91
96
|
|
|
92
|
-
Every question has an `id`, unique within the list.
|
|
97
|
+
Every list question has an `id`, unique within the list. `box` edges outside `[0, 1]` are rejected.
|
|
98
|
+
|
|
99
|
+
### Keyed questions
|
|
100
|
+
|
|
101
|
+
`systemOne` takes the same state and a record of questions built with `choice`, `noul`, `score`, and `box`. The names are the answer keys. Those helpers compile to the list `evaluate` already sends. The same fallback reasons apply, and those failures do not throw. The helpers are also exported from `@corbits/system-one/decisions`.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { choice, noul, score, systemOne } from "@corbits/system-one";
|
|
105
|
+
|
|
106
|
+
const result = await systemOne({
|
|
107
|
+
state: { document: "I was charged twice." },
|
|
108
|
+
questions: {
|
|
109
|
+
category: choice("What is this?", { billing: null, technical: null }),
|
|
110
|
+
escalate: noul("Must this be escalated?"),
|
|
111
|
+
severity: score("How severe?", ["none", "cosmetic", "unusable"]),
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
On success, read `result.answers.category.choice` (plus `probabilities` and `confidence`), `result.answers.escalate.noul`, and `result.answers.severity.score`.
|
|
117
|
+
|
|
118
|
+
`image()` accepts a `Blob` or `Uint8Array`. `Image.fromBase64()` accepts base64 file bytes. Only PNG and JPEG are sent. Place an image in `state`, instructions, or criteria, or interpolate it with `withAssets` (and `withAssets.join` for a list). An image in an ordinary template string is rejected before the request. `assets` maps a name to an image for plain strings; every name must appear as its own token in the request. A request with any image is `multipart/form-data` and sends `TypeSafe-Danger-Zone-Images: true`. Otherwise the body stays JSON.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { box, image, systemOne, withAssets } from "@corbits/system-one";
|
|
122
|
+
|
|
123
|
+
const photo = image(pngBytes);
|
|
124
|
+
const result = await systemOne({
|
|
125
|
+
state: withAssets`Returned item: ${photo}`,
|
|
126
|
+
questions: { damage: box("The damaged area") },
|
|
127
|
+
model: "jev-preview",
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`providerOptions.systemOne.questions` accepts this keyed record as well as a question list.
|
|
93
132
|
|
|
94
133
|
### Config
|
|
95
134
|
|
|
96
135
|
`input.config` for `evaluate`, or the argument to `createSystemOneAdapter`:
|
|
97
136
|
|
|
98
|
-
| Field | Type | Default | Description
|
|
99
|
-
| ----------- | ---------------- | ---------------------- |
|
|
100
|
-
| `endpoint` | `EndpointConfig` | `{ kind: "official" }` | `official`, `gateway`, or `custom` with a `url`. Each takes an optional `model`. |
|
|
101
|
-
| `timeoutMs` | `number` | `1500` | Bound for the whole round trip.
|
|
102
|
-
| `apiKey` | `string` | from the environment | Overrides the environment key.
|
|
137
|
+
| Field | Type | Default | Description |
|
|
138
|
+
| ----------- | ---------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
139
|
+
| `endpoint` | `EndpointConfig` | `{ kind: "official" }` | `official`, `gateway`, or `custom` with a `url`. Each takes an optional `model`. `custom` may set `auth: "none"`. |
|
|
140
|
+
| `timeoutMs` | `number` | `1500` | Bound for the whole round trip. |
|
|
141
|
+
| `apiKey` | `string` | from the environment | Overrides the environment key. |
|
|
103
142
|
|
|
104
143
|
### Endpoints and keys
|
|
105
144
|
|
|
@@ -107,19 +146,20 @@ Every question has an `id`, unique within the list.
|
|
|
107
146
|
| ---------- | ---------------------------------------------------- | ----------------- | ---------------------------------------------- |
|
|
108
147
|
| `official` | `https://api.typesafe.ai/v1/systemone` | `jev-latest` | `TYPESAFE_API_KEY` |
|
|
109
148
|
| `gateway` | `https://ai-gateway.vercel.sh/typesafe/v1/systemone` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY`, then `VERCEL_OIDC_TOKEN` |
|
|
110
|
-
| `custom` | `url` | `typesafe-ai/jev` | `SYSTEM_ONE_API_KEY`
|
|
149
|
+
| `custom` | `url` | `typesafe-ai/jev` | `SYSTEM_ONE_API_KEY`, unless `auth: "none"` |
|
|
111
150
|
|
|
112
151
|
`SYSTEM_ONE_API_KEY` is also a supported alias on every endpoint, read after the endpoint's own key.
|
|
152
|
+
A custom endpoint with `auth: "none"` does not require a key and does not send `Authorization`. Official and gateway always require a key. Any other `auth` value is invalid input and throws.
|
|
113
153
|
|
|
114
154
|
### Fallback reasons
|
|
115
155
|
|
|
116
|
-
| `reason` | When
|
|
117
|
-
| ------------- |
|
|
118
|
-
| `no-key` | No key in `apiKey` or the environment
|
|
119
|
-
| `timeout` | The round trip, including the body read, exceeds `timeoutMs`.
|
|
120
|
-
| `network` | The request fails before a response.
|
|
121
|
-
| `http-error` | Non-2xx response. `httpStatus` holds the status.
|
|
122
|
-
| `parse-error` | The body is not JSON or fails validation.
|
|
156
|
+
| `reason` | When |
|
|
157
|
+
| ------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
158
|
+
| `no-key` | No key in `apiKey` or the environment, and the endpoint is not custom with `auth: "none"`. No request is sent. |
|
|
159
|
+
| `timeout` | The round trip, including the body read, exceeds `timeoutMs`. |
|
|
160
|
+
| `network` | The request fails before a response. |
|
|
161
|
+
| `http-error` | Non-2xx response. `httpStatus` holds the status. |
|
|
162
|
+
| `parse-error` | The body is not JSON or fails validation. |
|
|
123
163
|
|
|
124
164
|
`detail` carries the underlying message.
|
|
125
165
|
|
|
@@ -208,7 +248,7 @@ The hub offering's `quirks` are validated and all optional:
|
|
|
208
248
|
}
|
|
209
249
|
```
|
|
210
250
|
|
|
211
|
-
Without `endpoint`, the adapter posts to the catalog provider's `baseURL` + `/systemone`, the host the offering's credential was issued for. A `model` quirk (top level or on `endpoint`) is the model sent on the wire, so one catalog model can be served by providers that name it differently. `questions` and `state` are per-call defaults; `providerOptions.systemOne` on a call wins field by field. Unknown keys throw. The adapter sends the bearer sentinel and the harness injects the offering's credential.
|
|
251
|
+
Without `endpoint`, the adapter posts to the catalog provider's `baseURL` + `/systemone`, the host the offering's credential was issued for. A `model` quirk (top level or on `endpoint`) is the model sent on the wire, so one catalog model can be served by providers that name it differently. `questions` and `state` are per-call defaults; `providerOptions.systemOne` on a call wins field by field. Unknown keys throw. The adapter sends the bearer sentinel and the harness injects the offering's credential, except a custom endpoint with `auth: "none"`, which omits `Authorization` so the host does not attach a key.
|
|
212
252
|
|
|
213
253
|
| Provider | Provider `baseURL` | `model` quirk |
|
|
214
254
|
| ----------------- | ------------------------------------------ | ----------------------------- |
|
|
@@ -221,7 +261,7 @@ Without `endpoint`, the adapter posts to the catalog provider's `baseURL` + `/sy
|
|
|
221
261
|
|
|
222
262
|
- `createSystemOneAdapterFactory` without an `endpoint` quirk now posts to the catalog provider's `baseURL` + `/systemone` instead of the official TypeSafe URL. Set the provider `baseURL` to the API root (for example `https://api.typesafe.ai/v1`), or keep the old behavior with `{"endpoint":{"kind":"official"}}`. The harness sends the offering's credential to whatever URL the adapter returns, so only set an explicit `endpoint` on a host that credential belongs to.
|
|
223
263
|
- A `model` quirk now overrides the model the harness passes (the catalog canonical name).
|
|
224
|
-
- `createSystemOneAdapter` and `evaluate`
|
|
264
|
+
- `createSystemOneAdapter` and `evaluate` accept `auth: "none"` on a custom endpoint and omit `Authorization`. Any other `auth` value throws `SystemOneError` before a request is built.
|
|
225
265
|
|
|
226
266
|
## Upgrading from 0.1
|
|
227
267
|
|
package/dist/adapter.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type AdapterFactory, type ProviderAdapter } from "@intx/inference";
|
|
2
|
-
import {
|
|
2
|
+
import { EvaluateConfig } from "./schemas.js";
|
|
3
3
|
/** Provider id this package serves. */
|
|
4
4
|
export declare const SYSTEM_ONE_PROVIDER = "corbits-system-one";
|
|
5
5
|
export declare function createSystemOneAdapter(config?: EvaluateConfig): ProviderAdapter;
|
package/dist/adapter.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type } from "arktype";
|
|
2
2
|
import { BEARER_CREDENTIAL_SENTINEL, ProtocolMismatchError, } from "@intx/inference";
|
|
3
3
|
import { resolveEndpoint } from "./config.js";
|
|
4
|
-
import {
|
|
4
|
+
import { SystemOneError } from "./errors.js";
|
|
5
|
+
import { Decision, DecisionQuestionRecord, toDecision, toWireQuestions, WireResponseBody, boxEdgesOutOfRange, EndpointConfig, EvaluateConfig, QuestionList, State, } from "./schemas.js";
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Adapter
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
@@ -22,9 +23,10 @@ export const SYSTEM_ONE_PROVIDER = "corbits-system-one";
|
|
|
22
23
|
// `state` replaces the transcript-derived default; `questions` replaces the
|
|
23
24
|
// single-question default. Both are validated, never cast — `QuestionList`
|
|
24
25
|
// carries the same non-empty/unique-id contract as `EvaluateInput`.
|
|
26
|
+
const QuestionsInput = QuestionList.or(DecisionQuestionRecord);
|
|
25
27
|
const SystemOneProviderOptions = type({
|
|
26
28
|
"state?": State,
|
|
27
|
-
"questions?":
|
|
29
|
+
"questions?": QuestionsInput,
|
|
28
30
|
"+": "reject",
|
|
29
31
|
});
|
|
30
32
|
// The Jev evaluate response body is the shared `WireResponseBody` envelope
|
|
@@ -51,6 +53,19 @@ const DEFAULT_QUESTIONS = [
|
|
|
51
53
|
instructions: "Does this turn respond to the conversation?",
|
|
52
54
|
},
|
|
53
55
|
];
|
|
56
|
+
function asQuestionList(questions) {
|
|
57
|
+
if (Array.isArray(questions))
|
|
58
|
+
return questions;
|
|
59
|
+
const compiled = Object.entries(questions).map(([id, question]) => ({
|
|
60
|
+
id,
|
|
61
|
+
...question,
|
|
62
|
+
}));
|
|
63
|
+
const parsed = QuestionList(compiled);
|
|
64
|
+
if (parsed instanceof type.errors) {
|
|
65
|
+
throw new ProtocolMismatchError(`${SYSTEM_ONE_PROVIDER} buildRequest: invalid providerOptions.systemOne questions: ${parsed.summary}`, questions);
|
|
66
|
+
}
|
|
67
|
+
return parsed;
|
|
68
|
+
}
|
|
54
69
|
function transcriptText(turns) {
|
|
55
70
|
const texts = [];
|
|
56
71
|
for (const turn of turns) {
|
|
@@ -94,6 +109,14 @@ function parseDecisions(body, provider) {
|
|
|
94
109
|
throw new ProtocolMismatchError(`${provider} parseJSONResponse: answer id "${id}" vanished during parsing`, parsed);
|
|
95
110
|
}
|
|
96
111
|
try {
|
|
112
|
+
if (answer.type === "box") {
|
|
113
|
+
if (answer.box === undefined) {
|
|
114
|
+
throw new Error(`answer "${id}" is missing box`);
|
|
115
|
+
}
|
|
116
|
+
const violation = boxEdgesOutOfRange(id, answer.box);
|
|
117
|
+
if (violation !== undefined)
|
|
118
|
+
throw new Error(violation);
|
|
119
|
+
}
|
|
97
120
|
return toDecision(id, answer);
|
|
98
121
|
}
|
|
99
122
|
catch (cause) {
|
|
@@ -107,7 +130,9 @@ function parseDecisions(body, provider) {
|
|
|
107
130
|
* host's single copy of `@intx/inference` (peer dependency, never vendored).
|
|
108
131
|
* The optional `config` pins the endpoint (official by default, gateway, or
|
|
109
132
|
* a custom URL) and the per-call model default; the inference call's own
|
|
110
|
-
* `model` argument wins for the request body.
|
|
133
|
+
* `model` argument wins for the request body. Official and gateway requests
|
|
134
|
+
* carry `authorization: BEARER_CREDENTIAL_SENTINEL` so the host injects a
|
|
135
|
+
* key. A custom endpoint with `auth: "none"` omits that header.
|
|
111
136
|
*/
|
|
112
137
|
function createSystemOneProviderAdapter(config, source) {
|
|
113
138
|
const endpoint = resolveEndpoint(config?.endpoint);
|
|
@@ -127,15 +152,18 @@ function createSystemOneProviderAdapter(config, source) {
|
|
|
127
152
|
}
|
|
128
153
|
if (overrides.state !== undefined)
|
|
129
154
|
state = overrides.state;
|
|
130
|
-
if (overrides.questions !== undefined)
|
|
131
|
-
questions = overrides.questions;
|
|
155
|
+
if (overrides.questions !== undefined) {
|
|
156
|
+
questions = asQuestionList(overrides.questions);
|
|
157
|
+
}
|
|
132
158
|
}
|
|
133
159
|
const requestModel = model || endpoint.model;
|
|
134
160
|
const headers = {
|
|
135
161
|
"content-type": "application/json",
|
|
136
162
|
accept: "application/json",
|
|
137
|
-
authorization: BEARER_CREDENTIAL_SENTINEL,
|
|
138
163
|
};
|
|
164
|
+
if (endpoint.auth !== "none") {
|
|
165
|
+
headers["authorization"] = BEARER_CREDENTIAL_SENTINEL;
|
|
166
|
+
}
|
|
139
167
|
return {
|
|
140
168
|
url: endpoint.url,
|
|
141
169
|
headers,
|
|
@@ -197,7 +225,13 @@ function createSystemOneProviderAdapter(config, source) {
|
|
|
197
225
|
};
|
|
198
226
|
}
|
|
199
227
|
export function createSystemOneAdapter(config) {
|
|
200
|
-
|
|
228
|
+
if (config === undefined)
|
|
229
|
+
return createSystemOneProviderAdapter();
|
|
230
|
+
const parsed = EvaluateConfig(config);
|
|
231
|
+
if (parsed instanceof type.errors) {
|
|
232
|
+
throw new SystemOneError("parse-error", `createSystemOneAdapter: invalid config: ${parsed.summary}`);
|
|
233
|
+
}
|
|
234
|
+
return createSystemOneProviderAdapter(parsed);
|
|
201
235
|
}
|
|
202
236
|
// Deployment quirks a hub offering carries for this provider. `questions`
|
|
203
237
|
// and `state` are per-call defaults: a call's own `providerOptions.systemOne`
|
package/dist/assets.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const contentTypeOf: (bytes: Uint8Array) => string;
|
|
2
|
+
/** A PNG or JPEG image. Create one with `image` or `Image.fromBase64`. */
|
|
3
|
+
export declare class Image {
|
|
4
|
+
#private;
|
|
5
|
+
readonly type = "image";
|
|
6
|
+
readonly data: Uint8Array | Blob;
|
|
7
|
+
constructor(data: Blob | ArrayBuffer | ArrayBufferView);
|
|
8
|
+
/** Decode base64 file bytes. Do not include a `data:` URL prefix. */
|
|
9
|
+
static fromBase64(text: string): Image;
|
|
10
|
+
toString(): string;
|
|
11
|
+
toJSON(): string;
|
|
12
|
+
}
|
|
13
|
+
declare class TextWithAssets {
|
|
14
|
+
readonly text: string;
|
|
15
|
+
readonly assets: Image[];
|
|
16
|
+
constructor(text: string, assets: Image[]);
|
|
17
|
+
toString(): string;
|
|
18
|
+
toJSON(): string;
|
|
19
|
+
}
|
|
20
|
+
export type TextWithAssetsValue = TextWithAssets;
|
|
21
|
+
/** Create an image from file bytes or a Blob. PNG and JPEG only, checked before send. */
|
|
22
|
+
export declare const image: (data: Blob | ArrayBuffer | ArrayBufferView) => Image;
|
|
23
|
+
/**
|
|
24
|
+
* Tag a template so interpolated images are sent with the text.
|
|
25
|
+
* An image in an untagged template is rejected before the request is sent.
|
|
26
|
+
*/
|
|
27
|
+
export declare const withAssets: ((strings: TemplateStringsArray, ...values: unknown[]) => TextWithAssets) & {
|
|
28
|
+
join: (values: readonly unknown[], separator: string) => TextWithAssets;
|
|
29
|
+
};
|
|
30
|
+
export type SerializedRequest = {
|
|
31
|
+
json: string;
|
|
32
|
+
assets: Record<string, Image> | undefined;
|
|
33
|
+
};
|
|
34
|
+
/** Replace images with references and reject dangling or unused asset names. */
|
|
35
|
+
export declare function serializeRequest(payload: unknown, assets?: Record<string, Image>): SerializedRequest;
|
|
36
|
+
export {};
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { SystemOneError } from "./errors.js";
|
|
2
|
+
const CONTENT_TYPES = [
|
|
3
|
+
["image/png", [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
|
|
4
|
+
["image/jpeg", [0xff, 0xd8, 0xff]],
|
|
5
|
+
];
|
|
6
|
+
const REFERENCE = /<image:[0-9a-f]{32}>/g;
|
|
7
|
+
const IMAGE = Symbol.for("@typesafe-ai/sdk/image");
|
|
8
|
+
const TEXT_WITH_ASSETS = Symbol.for("@typesafe-ai/sdk/text-with-assets");
|
|
9
|
+
const randomHex = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
10
|
+
const isImage = (value) => IMAGE in value;
|
|
11
|
+
const isTextWithAssets = (value) => TEXT_WITH_ASSETS in value;
|
|
12
|
+
function fail(message) {
|
|
13
|
+
throw new SystemOneError("parse-error", message);
|
|
14
|
+
}
|
|
15
|
+
const toBytes = (data) => {
|
|
16
|
+
if (!ArrayBuffer.isView(data) &&
|
|
17
|
+
Object.prototype.toString.call(data) !== "[object ArrayBuffer]") {
|
|
18
|
+
fail("Image data must be file bytes or a Blob.");
|
|
19
|
+
}
|
|
20
|
+
return ArrayBuffer.isView(data)
|
|
21
|
+
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
22
|
+
: new Uint8Array(data);
|
|
23
|
+
};
|
|
24
|
+
export const contentTypeOf = (bytes) => {
|
|
25
|
+
for (const [contentType, signature] of CONTENT_TYPES) {
|
|
26
|
+
if (signature.every((byte, index) => bytes[index] === byte)) {
|
|
27
|
+
return contentType;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return "application/octet-stream";
|
|
31
|
+
};
|
|
32
|
+
/** A PNG or JPEG image. Create one with `image` or `Image.fromBase64`. */
|
|
33
|
+
export class Image {
|
|
34
|
+
type = "image";
|
|
35
|
+
data;
|
|
36
|
+
#reference = `<image:${randomHex()}>`;
|
|
37
|
+
constructor(data) {
|
|
38
|
+
this.data = data instanceof Blob ? data : toBytes(data);
|
|
39
|
+
const size = this.data instanceof Blob ? this.data.size : this.data.byteLength;
|
|
40
|
+
if (size === 0)
|
|
41
|
+
fail("Image data is empty.");
|
|
42
|
+
}
|
|
43
|
+
/** Decode base64 file bytes. Do not include a `data:` URL prefix. */
|
|
44
|
+
static fromBase64(text) {
|
|
45
|
+
let binary;
|
|
46
|
+
try {
|
|
47
|
+
binary = atob(text);
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
const error = new SystemOneError("parse-error", "Image data must contain valid base64 text.");
|
|
51
|
+
error.cause = cause;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
return new Image(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
|
|
55
|
+
}
|
|
56
|
+
toString() {
|
|
57
|
+
return this.#reference;
|
|
58
|
+
}
|
|
59
|
+
toJSON() {
|
|
60
|
+
return this.#reference;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
Object.defineProperty(Image.prototype, IMAGE, { value: true });
|
|
64
|
+
class TextWithAssets {
|
|
65
|
+
text;
|
|
66
|
+
assets;
|
|
67
|
+
constructor(text, assets) {
|
|
68
|
+
this.text = text;
|
|
69
|
+
this.assets = assets;
|
|
70
|
+
}
|
|
71
|
+
toString() {
|
|
72
|
+
return this.text;
|
|
73
|
+
}
|
|
74
|
+
toJSON() {
|
|
75
|
+
return this.text;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
Object.defineProperty(TextWithAssets.prototype, TEXT_WITH_ASSETS, {
|
|
79
|
+
value: true,
|
|
80
|
+
});
|
|
81
|
+
/** Create an image from file bytes or a Blob. PNG and JPEG only, checked before send. */
|
|
82
|
+
export const image = (data) => new Image(data);
|
|
83
|
+
const collectAssets = (value, found, visited) => {
|
|
84
|
+
if (typeof value !== "object" || value === null || visited.has(value))
|
|
85
|
+
return;
|
|
86
|
+
visited.add(value);
|
|
87
|
+
if (isImage(value))
|
|
88
|
+
found.set(String(value), value);
|
|
89
|
+
else if (isTextWithAssets(value)) {
|
|
90
|
+
for (const asset of value.assets)
|
|
91
|
+
found.set(String(asset), asset);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
for (const item of Object.values(value))
|
|
95
|
+
collectAssets(item, found, visited);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const textWithAssets = (text, values) => {
|
|
99
|
+
const interpolated = new Map();
|
|
100
|
+
collectAssets(values, interpolated, new Set());
|
|
101
|
+
const assets = new Map();
|
|
102
|
+
for (const [reference] of text.matchAll(REFERENCE)) {
|
|
103
|
+
const asset = interpolated.get(reference);
|
|
104
|
+
if (asset !== undefined)
|
|
105
|
+
assets.set(reference, asset);
|
|
106
|
+
}
|
|
107
|
+
return new TextWithAssets(text, [...assets.values()]);
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Tag a template so interpolated images are sent with the text.
|
|
111
|
+
* An image in an untagged template is rejected before the request is sent.
|
|
112
|
+
*/
|
|
113
|
+
export const withAssets = Object.assign((strings, ...values) => textWithAssets(strings.reduce((joined, piece, index) => joined + String(values[index - 1]) + piece), values), {
|
|
114
|
+
join: (values, separator) => textWithAssets(values.join(separator), values),
|
|
115
|
+
});
|
|
116
|
+
/** Replace images with references and reject dangling or unused asset names. */
|
|
117
|
+
export function serializeRequest(payload, assets = {}) {
|
|
118
|
+
const names = new Map(Object.entries(assets).map(([name, asset]) => [asset, name]));
|
|
119
|
+
const known = new Map(Object.values(assets).map((asset) => [String(asset), asset]));
|
|
120
|
+
const json = JSON.stringify(payload, function (key, value) {
|
|
121
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
122
|
+
return String(value);
|
|
123
|
+
const holder = this;
|
|
124
|
+
const original = holder[key];
|
|
125
|
+
if (typeof original !== "object" || original === null)
|
|
126
|
+
return value;
|
|
127
|
+
if (isImage(original)) {
|
|
128
|
+
const name = names.get(original);
|
|
129
|
+
if (name !== undefined)
|
|
130
|
+
return name;
|
|
131
|
+
known.set(String(original), original);
|
|
132
|
+
}
|
|
133
|
+
else if (isTextWithAssets(original)) {
|
|
134
|
+
let text = original.text;
|
|
135
|
+
for (const asset of original.assets) {
|
|
136
|
+
const name = names.get(asset);
|
|
137
|
+
if (name === undefined)
|
|
138
|
+
known.set(String(asset), asset);
|
|
139
|
+
else
|
|
140
|
+
text = text.replaceAll(String(asset), name);
|
|
141
|
+
}
|
|
142
|
+
return text;
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
});
|
|
146
|
+
const unreferenced = Object.keys(assets).filter((name) => !jsonValueHasToken(json, name));
|
|
147
|
+
if (unreferenced.length > 0) {
|
|
148
|
+
fail(`Assets not referenced in the request: ${unreferenced.join(", ")}. Write each name in \`assets\` into state, question instructions, or criteria.`);
|
|
149
|
+
}
|
|
150
|
+
const sent = new Map(Object.entries(assets));
|
|
151
|
+
const missing = new Set();
|
|
152
|
+
for (const [reference] of json.matchAll(REFERENCE)) {
|
|
153
|
+
if (sent.has(reference))
|
|
154
|
+
continue;
|
|
155
|
+
const asset = known.get(reference);
|
|
156
|
+
if (asset === undefined)
|
|
157
|
+
missing.add(reference);
|
|
158
|
+
else
|
|
159
|
+
sent.set(reference, asset);
|
|
160
|
+
}
|
|
161
|
+
if (missing.size > 0) {
|
|
162
|
+
fail(`Image references without an attached image: ${[...missing].join(", ")}. Tag template literals that interpolate images with \`withAssets\`.`);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
json,
|
|
166
|
+
assets: sent.size === 0 ? undefined : Object.fromEntries(sent),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const IDENT = /[0-9A-Za-z_]/;
|
|
170
|
+
/** True when `name` is its own token in state, instructions, or criteria. */
|
|
171
|
+
function jsonValueHasToken(json, name) {
|
|
172
|
+
let parsed;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(json);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
return requestReferencesName(parsed, name);
|
|
180
|
+
}
|
|
181
|
+
function own(value, key) {
|
|
182
|
+
if (!Object.hasOwn(value, key))
|
|
183
|
+
return undefined;
|
|
184
|
+
return Object.getOwnPropertyDescriptor(value, key)?.value;
|
|
185
|
+
}
|
|
186
|
+
/** Question `type` and other fields are not references. Keys are not either. */
|
|
187
|
+
function requestReferencesName(payload, name) {
|
|
188
|
+
if (typeof payload !== "object" || payload === null)
|
|
189
|
+
return false;
|
|
190
|
+
if (valueHasToken(own(payload, "state"), name))
|
|
191
|
+
return true;
|
|
192
|
+
const questions = own(payload, "questions");
|
|
193
|
+
if (typeof questions !== "object" || questions === null)
|
|
194
|
+
return false;
|
|
195
|
+
for (const question of Object.values(questions)) {
|
|
196
|
+
if (typeof question !== "object" || question === null)
|
|
197
|
+
continue;
|
|
198
|
+
if (valueHasToken(own(question, "instructions"), name))
|
|
199
|
+
return true;
|
|
200
|
+
if (valueHasToken(own(question, "criteria"), name))
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
function valueHasToken(value, name) {
|
|
206
|
+
if (typeof value === "string")
|
|
207
|
+
return hasToken(value, name);
|
|
208
|
+
if (Array.isArray(value))
|
|
209
|
+
return value.some((item) => valueHasToken(item, name));
|
|
210
|
+
if (typeof value === "object" && value !== null) {
|
|
211
|
+
return Object.values(value).some((item) => valueHasToken(item, name));
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
function hasToken(text, name) {
|
|
216
|
+
if (name.length === 0)
|
|
217
|
+
return false;
|
|
218
|
+
// `<image:hex>` is one generated reference, not a use of an asset named `image`.
|
|
219
|
+
const visible = text.replace(/<image:[0-9a-f]{32}>/g, (reference) => " ".repeat(reference.length));
|
|
220
|
+
let from = 0;
|
|
221
|
+
while (from <= visible.length) {
|
|
222
|
+
const at = visible.indexOf(name, from);
|
|
223
|
+
if (at < 0)
|
|
224
|
+
return false;
|
|
225
|
+
const before = at === 0 ? "" : (visible[at - 1] ?? "");
|
|
226
|
+
const after = visible[at + name.length] ?? "";
|
|
227
|
+
if (!IDENT.test(before) && !IDENT.test(after))
|
|
228
|
+
return true;
|
|
229
|
+
from = at + 1;
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ import { type Dependencies } from "@intx/inference";
|
|
|
3
3
|
export type PostEvaluateOptions = {
|
|
4
4
|
timeoutMs?: number;
|
|
5
5
|
apiKey?: string;
|
|
6
|
+
imageParts?: {
|
|
7
|
+
name: string;
|
|
8
|
+
bytes: Uint8Array;
|
|
9
|
+
contentType: "image/png" | "image/jpeg";
|
|
10
|
+
}[];
|
|
6
11
|
};
|
|
7
12
|
/** Transport dependencies: the `fetch` to POST with and the timeout scheduler. */
|
|
8
13
|
export type EvaluateDeps = Pick<Dependencies, "fetch" | "scheduler">;
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
import { classifyHTTPError, classifyNetworkError, classifyProtocolMismatch, } from "@intx/inference";
|
|
2
2
|
import { DEFAULT_TIMEOUT_MS } from "./config.js";
|
|
3
3
|
import { SystemOneError, TransportError } from "./errors.js";
|
|
4
|
+
function bodyHasBoxQuestion(body) {
|
|
5
|
+
if (typeof body !== "object" || body === null)
|
|
6
|
+
return false;
|
|
7
|
+
if (!Object.hasOwn(body, "questions"))
|
|
8
|
+
return false;
|
|
9
|
+
const questions = Object.getOwnPropertyDescriptor(body, "questions")?.value;
|
|
10
|
+
if (typeof questions !== "object" || questions === null)
|
|
11
|
+
return false;
|
|
12
|
+
return Object.values(questions).some((question) => {
|
|
13
|
+
if (typeof question !== "object" || question === null)
|
|
14
|
+
return false;
|
|
15
|
+
if (!Object.hasOwn(question, "type"))
|
|
16
|
+
return false;
|
|
17
|
+
return Object.getOwnPropertyDescriptor(question, "type")?.value === "box";
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function encodeMultipart(payload, parts) {
|
|
21
|
+
const boundary = Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
const chunks = [];
|
|
24
|
+
const fields = [
|
|
25
|
+
{
|
|
26
|
+
name: "request",
|
|
27
|
+
contentType: "application/json",
|
|
28
|
+
content: encoder.encode(JSON.stringify(payload)),
|
|
29
|
+
},
|
|
30
|
+
...parts.map((part) => ({
|
|
31
|
+
name: part.name,
|
|
32
|
+
contentType: part.contentType,
|
|
33
|
+
content: part.bytes,
|
|
34
|
+
filename: part.name,
|
|
35
|
+
})),
|
|
36
|
+
];
|
|
37
|
+
for (const field of fields) {
|
|
38
|
+
const disposition = field.filename === undefined
|
|
39
|
+
? `name="${field.name}"`
|
|
40
|
+
: `name="${field.name}"; filename="${field.filename}"`;
|
|
41
|
+
chunks.push(encoder.encode(`--${boundary}\r\nContent-Disposition: form-data; ${disposition}\r\nContent-Type: ${field.contentType}\r\n\r\n`), field.content, encoder.encode("\r\n"));
|
|
42
|
+
}
|
|
43
|
+
chunks.push(encoder.encode(`--${boundary}--\r\n`));
|
|
44
|
+
const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
45
|
+
const body = new Uint8Array(size);
|
|
46
|
+
let offset = 0;
|
|
47
|
+
for (const chunk of chunks) {
|
|
48
|
+
body.set(chunk, offset);
|
|
49
|
+
offset += chunk.byteLength;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
body,
|
|
53
|
+
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
4
56
|
/**
|
|
5
57
|
* POSTs an evaluation payload and returns the decoded JSON body. Sends
|
|
6
58
|
* `Authorization: Bearer <apiKey>` only when a key is present; aborts the
|
|
@@ -14,8 +66,17 @@ import { SystemOneError, TransportError } from "./errors.js";
|
|
|
14
66
|
*/
|
|
15
67
|
export async function postEvaluate(url, body, options, deps) {
|
|
16
68
|
let serialized;
|
|
69
|
+
const imageParts = options.imageParts;
|
|
70
|
+
let contentType = "application/json";
|
|
17
71
|
try {
|
|
18
|
-
|
|
72
|
+
if (imageParts !== undefined && imageParts.length > 0) {
|
|
73
|
+
const encoded = encodeMultipart(body, imageParts);
|
|
74
|
+
serialized = encoded.body;
|
|
75
|
+
contentType = encoded.contentType;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
serialized = JSON.stringify(body);
|
|
79
|
+
}
|
|
19
80
|
}
|
|
20
81
|
catch {
|
|
21
82
|
throw new SystemOneError("parse-error", "postEvaluate: request body is not JSON-serializable");
|
|
@@ -39,9 +100,13 @@ export async function postEvaluate(url, body, options, deps) {
|
|
|
39
100
|
};
|
|
40
101
|
try {
|
|
41
102
|
const headers = {
|
|
42
|
-
"content-type":
|
|
103
|
+
"content-type": contentType,
|
|
43
104
|
accept: "application/json",
|
|
44
105
|
};
|
|
106
|
+
if ((imageParts !== undefined && imageParts.length > 0) ||
|
|
107
|
+
bodyHasBoxQuestion(body)) {
|
|
108
|
+
headers["TypeSafe-Danger-Zone-Images"] = "true";
|
|
109
|
+
}
|
|
45
110
|
if (options.apiKey !== undefined && options.apiKey !== "") {
|
|
46
111
|
headers["authorization"] = `Bearer ${options.apiKey}`;
|
|
47
112
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export type ResolvedEndpoint = {
|
|
|
30
30
|
url: string;
|
|
31
31
|
backend: "corbits-system-one" | "gateway" | "custom";
|
|
32
32
|
model: string;
|
|
33
|
+
/** `none` only when a custom endpoint explicitly opts out of a key. */
|
|
34
|
+
auth: "key" | "none";
|
|
33
35
|
};
|
|
34
36
|
/**
|
|
35
37
|
* Resolves an evaluation endpoint to a concrete POST target. A per-call
|
|
@@ -39,5 +41,7 @@ export type ResolvedEndpoint = {
|
|
|
39
41
|
* distinguishes caller-routed traffic from the official endpoint —
|
|
40
42
|
* `'gateway'` is reserved for the proxy quirks path. The model always
|
|
41
43
|
* resolves here: a `custom` target without one sends `typesafe-ai/jev`.
|
|
44
|
+
* `auth` is `"none"` only when the caller set `auth: "none"` on a custom
|
|
45
|
+
* endpoint; every other target still requires a key.
|
|
42
46
|
*/
|
|
43
47
|
export declare function resolveEndpoint(endpoint?: EndpointConfig): ResolvedEndpoint;
|
package/dist/config.js
CHANGED
|
@@ -61,6 +61,8 @@ export const DEFAULT_TIMEOUT_MS = 1500;
|
|
|
61
61
|
* distinguishes caller-routed traffic from the official endpoint —
|
|
62
62
|
* `'gateway'` is reserved for the proxy quirks path. The model always
|
|
63
63
|
* resolves here: a `custom` target without one sends `typesafe-ai/jev`.
|
|
64
|
+
* `auth` is `"none"` only when the caller set `auth: "none"` on a custom
|
|
65
|
+
* endpoint; every other target still requires a key.
|
|
64
66
|
*/
|
|
65
67
|
export function resolveEndpoint(endpoint) {
|
|
66
68
|
if (endpoint?.kind === "custom") {
|
|
@@ -71,6 +73,7 @@ export function resolveEndpoint(endpoint) {
|
|
|
71
73
|
url: endpoint.url,
|
|
72
74
|
backend: "custom",
|
|
73
75
|
model: endpoint.model ?? GATEWAY_QUIRKS.model,
|
|
76
|
+
auth: endpoint.auth === "none" ? "none" : "key",
|
|
74
77
|
};
|
|
75
78
|
}
|
|
76
79
|
const kind = endpoint?.kind ?? "official";
|
|
@@ -83,5 +86,6 @@ export function resolveEndpoint(endpoint) {
|
|
|
83
86
|
url,
|
|
84
87
|
backend: kind === "gateway" ? "gateway" : "corbits-system-one",
|
|
85
88
|
model: endpoint?.model ?? quirks.model,
|
|
89
|
+
auth: "key",
|
|
86
90
|
};
|
|
87
91
|
}
|