@corbits/system-one 0.3.1 → 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 CHANGED
@@ -18,6 +18,16 @@ bun add @corbits/system-one @intx/inference@^0.4.0 @intx/types@^0.4.0
18
18
 
19
19
  Runs on Bun >= 1.2 or Node >= 24.
20
20
 
21
+ To consume an exact Git commit before it is published, enable the package's
22
+ source condition because Git dependencies do not run `prepack`:
23
+
24
+ ```bash
25
+ bun add github:corbitsdev/corbits-system-one#<commit>
26
+ bun --conditions=corbits-system-one-src your-script.ts
27
+ ```
28
+
29
+ Published consumers continue to use the built `dist` entry by default.
30
+
21
31
  ## Quickstart
22
32
 
23
33
  Needs `TYPESAFE_API_KEY` set.
@@ -60,6 +70,10 @@ Boolean questions answer as `noul`, a probability from 0 to 1 that the answer is
60
70
  | Export | Description |
61
71
  | --------------------------------- | ----------------------------------------------------------------------------------- |
62
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`. |
63
77
  | `createSystemOneAdapter(config?)` | Returns a `ProviderAdapter` for `runInference`. `config` is an `EvaluateConfig`. |
64
78
  | `createSystemOneAdapterFactory` | Interchange `AdapterFactory` for `SIDECAR_ADAPTER_MANIFEST`; takes offering quirks. |
65
79
  | `SYSTEM_ONE_PROVIDER` | The `"corbits-system-one"` provider id. |
@@ -72,24 +86,59 @@ Boolean questions answer as `noul`, a probability from 0 to 1 that the answer is
72
86
 
73
87
  ### Questions
74
88
 
75
- | `type` | Fields | Decision fields |
76
- | --------- | ------------------------------------------------------------------------------- | ------------------------------------------------ |
77
- | `choice` | `instructions`, `criteria` (option name → description or `null`, 1–255 options) | `choice`, `probabilities`, `confidence` |
78
- | `score` | `instructions`, `criteria` (2–10 descriptions) | `score`, `legend`, `probabilities`, `confidence` |
79
- | `boolean` | `instructions`, optional `criteria: { true?, false? }` | `noul` |
80
- | `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` |
96
+
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
+ ```
81
130
 
82
- Every question has an `id`, unique within the list.
131
+ `providerOptions.systemOne.questions` accepts this keyed record as well as a question list.
83
132
 
84
133
  ### Config
85
134
 
86
135
  `input.config` for `evaluate`, or the argument to `createSystemOneAdapter`:
87
136
 
88
- | Field | Type | Default | Description |
89
- | ----------- | ---------------- | ---------------------- | -------------------------------------------------------------------------------- |
90
- | `endpoint` | `EndpointConfig` | `{ kind: "official" }` | `official`, `gateway`, or `custom` with a `url`. Each takes an optional `model`. |
91
- | `timeoutMs` | `number` | `1500` | Bound for the whole round trip. |
92
- | `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. |
93
142
 
94
143
  ### Endpoints and keys
95
144
 
@@ -97,19 +146,20 @@ Every question has an `id`, unique within the list.
97
146
  | ---------- | ---------------------------------------------------- | ----------------- | ---------------------------------------------- |
98
147
  | `official` | `https://api.typesafe.ai/v1/systemone` | `jev-latest` | `TYPESAFE_API_KEY` |
99
148
  | `gateway` | `https://ai-gateway.vercel.sh/typesafe/v1/systemone` | `typesafe-ai/jev` | `AI_GATEWAY_API_KEY`, then `VERCEL_OIDC_TOKEN` |
100
- | `custom` | `url` | `typesafe-ai/jev` | `SYSTEM_ONE_API_KEY` |
149
+ | `custom` | `url` | `typesafe-ai/jev` | `SYSTEM_ONE_API_KEY`, unless `auth: "none"` |
101
150
 
102
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.
103
153
 
104
154
  ### Fallback reasons
105
155
 
106
- | `reason` | When |
107
- | ------------- | ------------------------------------------------------------- |
108
- | `no-key` | No key in `apiKey` or the environment. No request is sent. |
109
- | `timeout` | The round trip, including the body read, exceeds `timeoutMs`. |
110
- | `network` | The request fails before a response. |
111
- | `http-error` | Non-2xx response. `httpStatus` holds the status. |
112
- | `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. |
113
163
 
114
164
  `detail` carries the underlying message.
115
165
 
@@ -198,7 +248,7 @@ The hub offering's `quirks` are validated and all optional:
198
248
  }
199
249
  ```
200
250
 
201
- 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.
202
252
 
203
253
  | Provider | Provider `baseURL` | `model` quirk |
204
254
  | ----------------- | ------------------------------------------ | ----------------------------- |
@@ -211,7 +261,7 @@ Without `endpoint`, the adapter posts to the catalog provider's `baseURL` + `/sy
211
261
 
212
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.
213
263
  - A `model` quirk now overrides the model the harness passes (the catalog canonical name).
214
- - `createSystemOneAdapter` and `evaluate` are unchanged.
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.
215
265
 
216
266
  ## Upgrading from 0.1
217
267
 
package/dist/adapter.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type AdapterFactory, type ProviderAdapter } from "@intx/inference";
2
- import { type EvaluateConfig } from "./schemas.js";
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 { Decision, toDecision, toWireQuestions, WireResponseBody, EndpointConfig, QuestionList, State, } from "./schemas.js";
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?": QuestionList,
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
- return createSystemOneProviderAdapter(config);
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`
@@ -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
- serialized = JSON.stringify(body);
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": "application/json",
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;