converse-mcp-server 4.7.1 → 4.8.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
@@ -187,7 +187,7 @@ Cancel running asynchronous operations when needed.
187
187
 
188
188
  ### 4. Decide Tool
189
189
 
190
- Ask a decision model (OpenAI's `gpt-6-luna`, TypeSafe's Jev, or Cloudflare's Clef) typed questions about a state and get calibrated answers instead of text: a yes probability (`noul`), a chosen option with per-option probabilities (`choice`), or a rubric position with per-level probabilities (`score`). Batch many questions into one call; they are judged in parallel against the same state. Needs any of `OPENAI_API_KEY`, `TYPESAFE_API_KEY`, `CLOUDFLARE_ACCOUNT_ID` + `CLOUDFLARE_API_TOKEN`, or `OPENROUTER_API_KEY`; each model runs on its native host first and falls back to OpenRouter. `model: "auto"` tries `gpt-6-luna`, then `jev-latest`, then `clef`. Pass `images` to judge pictures with `gpt-6-luna` or Clef.
190
+ Ask a decision model (OpenAI's `gpt-6-luna`, TypeSafe's Jev, or Cloudflare's Clef) typed questions about a state and get calibrated answers instead of text: a yes probability (`noul`), a chosen option with per-option probabilities (`choice`), a rubric position with per-level probabilities (`score`), or where a target is in an image (`box`, Jev 1.14). Batch many questions into one call; they are judged in parallel against the same state. Needs any of `OPENAI_API_KEY`, `TYPESAFE_API_KEY`, `CLOUDFLARE_ACCOUNT_ID` + `CLOUDFLARE_API_TOKEN`, or `OPENROUTER_API_KEY`; each model runs on its native host first and falls back to OpenRouter. `model: "auto"` tries `gpt-6-luna`, then `jev-latest`, then `clef`. Pass `images` to judge pictures with `gpt-6-luna`, Jev, or Clef.
191
191
 
192
192
  ```javascript
193
193
  {
package/docs/API.md CHANGED
@@ -382,7 +382,7 @@ Only jobs in a `queued` or `running` state can be cancelled; already-completed,
382
382
  "additionalProperties": {
383
383
  "type": "object",
384
384
  "properties": {
385
- "type": { "type": "string", "enum": ["noul", "choice", "score"] },
385
+ "type": { "type": "string", "enum": ["noul", "choice", "score", "box"] },
386
386
  "instructions": { "anyOf": [{ "type": "string" }, { "type": "object" }, { "type": "array" }] },
387
387
  "criteria": { "anyOf": [{ "type": "object" }, { "type": "array" }] }
388
388
  },
@@ -401,13 +401,19 @@ Only jobs in a `queued` or `running` state can be cancelled; already-completed,
401
401
  - **`state`**: the material to judge. An object with descriptively named fields works best; use an array for sequences such as chat messages. Optional when `files` is given.
402
402
  - **`questions`**: named questions, each judged in parallel and in isolation against the same state. The name is your own label and becomes the answer key. Batching many questions into one call adds almost no latency or cost.
403
403
  - **`files`**: text files added to the state as `{ "files": { "<path>": "<content>" } }`. When `state` is also given, it moves to `input`. Line ranges (`file.txt{10:50}`) are supported; image files belong in `images`.
404
- - **`images`**: PNG, JPEG or WebP images judged together with the state, as file paths or base64 data URLs; `state` is optional when images are given. Only `gpt-6-luna` (OpenAI) and `clef` / `clef-flash` (Cloudflare) take images, so `auto` skips other models and naming a text-only model is rejected before sending. OpenAI receives the state and images as one user message. Clef takes at most 4 images, and Cloudflare rejects a request whose size estimate (one token per 4 characters, base64 included) exceeds its 65,536-token window, so Clef images must total about 180 KB; `decide` checks this locally and suggests downscaling. OpenRouter routes are text only.
404
+ - **`images`**: PNG, JPEG or WebP images judged together with the state, as file paths or base64 data URLs; `state` is optional when images are given. `gpt-6-luna` (OpenAI), Jev on TypeSafe, and `clef` / `clef-flash` (Cloudflare) take images; `auto` skips routes that cannot, and naming one is rejected before sending. OpenRouter routes are text only: OpenRouter accepts Jev image assets but drops them, and Jev answers as if no image were sent.
405
+ - OpenAI receives the state and images as one user message.
406
+ - TypeSafe receives each image as an asset with a UUID reference, and the state as `{ "input": <state>, "images": [<references>] }` (just `{ "images": [...] }` without a state), with the `TypeSafe-Danger-Zone-Images: true` header TypeSafe requires for images. Jev takes PNG and JPEG only, 256–1568 px per side and at least 78,400 px in total, up to 16 MiB each and 32 MiB per request, and never resizes; `decide` checks these locally, so a WebP image under `auto` moves on to Clef. TypeSafe ignores EXIF orientation, so rotate the pixels upright first. TypeSafe labels images "dreamer-ready": prototype quality whose format and accuracy may change between releases.
407
+ - Clef takes at most 4 images, and Cloudflare rejects a request whose size estimate (one token per 4 characters, base64 included) exceeds its 65,536-token window, so Clef images must total about 180 KB; `decide` checks this locally and suggests downscaling.
405
408
 
406
409
  | Type | `criteria` | Answer |
407
410
  |---|---|---|
408
411
  | `noul` | Optional `{ "true": "...", "false": "..." }` | `noul`: probability 0..1 of yes |
409
412
  | `choice` | Required `{ "<option>": "description" \| null }`, 2–255 options | `choice`, per-option `probabilities`, `confidence` |
410
413
  | `score` | Required ordered array of levels, lowest first, 2–10 levels; each a description or `{ "label": "...", "description": ... }` | `score` (probability-weighted level position), `legend`, per-level `probabilities`, `confidence` |
414
+ | `box` | None; `instructions` names the target ("the coffee mug nearest the sink") | `box`: `{ top, bottom, left, right }` as fractions 0..1 of the image's height and width from the top-left corner, `confidence` |
415
+
416
+ `box` needs an image and `jev-1.14` or `jev-preview` on TypeSafe; any other model, including `auto`, is rejected before sending. A box answer is always a box, even when the target is absent, so ask a `noul` about whether it is there in the same call. Each box question finds one target.
411
417
 
412
418
  `instructions` and criteria descriptions may be objects or arrays that bundle reference data with the question; refer to their fields by `` `name` `` in the text. Questions are validated before any request is sent.
413
419
 
@@ -422,7 +428,10 @@ Any answer from `gpt-6-luna` may be `{ "type": "refusal" }` instead of a value,
422
428
  | `gpt-6-luna` | `luna`, `gpt-6-luna-decisions` | `openai` | `openai/gpt-6-luna-decisions` |
423
429
  | `jev-latest` | `jev` | `typesafe` (`jev-latest`) | `~typesafe/jev-latest` |
424
430
  | `jev-1.13` | `jev-1.13.0` | `typesafe` (`jev-1.13.0`) | `typesafe/jev-1.13` |
431
+ | `jev-1.14` | `jev-1.14.0` | `typesafe` (`jev-1.14.0`) | `typesafe/jev-1.14` |
425
432
  | `jev-preview` | | `typesafe` | not served |
433
+
434
+ TypeSafe moves its aliases on each release: `jev-latest` is the newest stable release (1.13 as of 1.14's launch on 2026-10-14) and `jev-preview` the newest release of any kind (1.14). OpenRouter's `~typesafe/jev-latest` follows its own schedule and already serves 1.14, so a `jev-latest` fallback to OpenRouter can answer with a different version. The response's `model` names the version that answered.
426
435
  | `clef` | `@cf/cloudflare/clef` | `cloudflare` | `cloudflare/clef` |
427
436
  | `clef-flash` | `@cf/cloudflare/clef-flash` | `cloudflare` | `cloudflare/clef-flash` |
428
437
 
@@ -491,7 +500,9 @@ Decision · typesafe/jev-1.13-20260917 via OpenRouter · 394 input tokens · $0.
491
500
 
492
501
  ### Usage Guidance
493
502
 
494
- Decision models read questions literally and are weak at counting, arithmetic, date comparison, and multi-hop reasoning; do those in code and ask narrow, atomic questions. Split compound judgments into separate questions and combine them in code. Treat low `confidence` as a signal to escalate to a generative model or a human. Limits: Jev is text only and takes about 64k tokens per request and 32k for the state plus the longest single question; Clef has a 65,536-token context.
503
+ Decision models read questions literally and are weak at counting, arithmetic, date comparison, and multi-hop reasoning; do those in code and ask narrow, atomic questions. Split compound judgments into separate questions and combine them in code. Treat low `confidence` as a signal to escalate to a generative model or a human. Limits: Jev takes about 64k tokens per request and 32k for the state plus the longest single question; Clef has a 65,536-token context.
504
+
505
+ When TypeSafe rejects a request as longer than Jev's default context (`max_tokens_exceeded`), `decide` sends it once more with `TypeSafe-Danger-Zone-Long-Context: true`, which raises both limits to 262k tokens. TypeSafe documents long context as slower and less accurate, so it is never sent up front; an answer that needed it says `long context` in the summary line and carries `"long_context": true`. OpenRouter routes never retry this way; OpenRouter serves Jev with a 262k context and no header.
495
506
 
496
507
  ## Supported Models
497
508
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "converse-mcp-server",
3
- "version": "4.7.1",
3
+ "version": "4.8.0",
4
4
  "description": "Converse MCP Server - Converse with other LLMs with chat and consensus tools",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,10 +8,16 @@
8
8
  *
9
9
  * Every format returns `{ model, answers, usage, id }` from `parse`.
10
10
  * `request(model, state, questions, images)` receives images as
11
- * `{ mimeType, base64 }`; `images: true` marks formats that can send them.
11
+ * `{ mimeType, base64 }`; `images: true` marks formats that can send them, and
12
+ * `questionTypes` lists the question types the format can express.
12
13
  */
13
14
 
15
+ import { randomUUID } from 'node:crypto';
14
16
  import { DecisionError } from './http.js';
17
+ import { readImageSize } from './imageSize.js';
18
+
19
+ /** The question types every decision model answers. */
20
+ export const BASE_QUESTION_TYPES = ['noul', 'choice', 'score'];
15
21
 
16
22
  function asText(value) {
17
23
  return typeof value === 'string' ? value : JSON.stringify(value);
@@ -47,16 +53,81 @@ function toSystemOneQuestions(questions) {
47
53
  }
48
54
 
49
55
  /**
50
- * TypeSafe and OpenRouter serve System One natively. Jev is text only, and
51
- * OpenRouter's image routing for other decision models is unverified, so this
52
- * format sends no images.
56
+ * OpenRouter serves System One natively but drops TypeSafe `assets` without
57
+ * an error: Jev answers as if no image were sent, with confident-looking
58
+ * guesses. So this format sends text and the base question types only.
53
59
  */
54
60
  export const systemOneFormat = {
55
61
  images: false,
62
+ questionTypes: BASE_QUESTION_TYPES,
56
63
  request: (model, state, questions) => ({ model, state, questions: toSystemOneQuestions(questions) }),
57
64
  parse: (body) => body,
58
65
  };
59
66
 
67
+ // TypeSafe rejects images outside these bounds rather than resizing them, and
68
+ // its error names no limit, so they are checked before sending.
69
+ const JEV_IMAGE_FORMATS = { 'image/png': 'png', 'image/jpeg': 'jpg' };
70
+ const JEV_MIN_SIDE = 256;
71
+ const JEV_MAX_SIDE = 1568;
72
+ const JEV_MIN_PIXELS = 78_400;
73
+ const JEV_MAX_IMAGE_BYTES = 16 * 1024 * 1024;
74
+ const JEV_MAX_BODY_BYTES = 32 * 1024 * 1024;
75
+
76
+ function jevLimitError(message) {
77
+ return new DecisionError(message, { terminal: true, notSent: true });
78
+ }
79
+
80
+ function checkJevImage(image, n) {
81
+ if (!JEV_IMAGE_FORMATS[image.mimeType]) {
82
+ throw jevLimitError(`Jev accepts PNG and JPEG images only; image ${n} is ${image.mimeType}.`);
83
+ }
84
+ const bytes = Buffer.from(image.base64, 'base64');
85
+ if (bytes.length > JEV_MAX_IMAGE_BYTES) {
86
+ throw jevLimitError(`Jev accepts images up to 16 MiB each; image ${n} is ${(bytes.length / 1048576).toFixed(1)} MiB.`);
87
+ }
88
+ const size = readImageSize(bytes);
89
+ if (!size) return;
90
+ if (size.animated) throw jevLimitError(`Jev accepts still images only; image ${n} is an animated PNG.`);
91
+ const { width, height } = size;
92
+ if (width < JEV_MIN_SIDE || height < JEV_MIN_SIDE || width > JEV_MAX_SIDE || height > JEV_MAX_SIDE || width * height < JEV_MIN_PIXELS) {
93
+ throw jevLimitError(
94
+ `Jev accepts images ${JEV_MIN_SIDE}–${JEV_MAX_SIDE} px per side and at least ${JEV_MIN_PIXELS} px in total; image ${n} is ${width}×${height}. ` +
95
+ `Resize it so the longest side is at most ${JEV_MAX_SIDE} px, or upscale or pad a small one.`,
96
+ );
97
+ }
98
+ }
99
+
100
+ /**
101
+ * TypeSafe's own System One endpoint. Images travel in a top-level `assets`
102
+ * map and appear wherever their reference string occurs; the references go
103
+ * into the state, so every question sees them. A reference is matched as
104
+ * plain text anywhere in the request, hence a UUID that cannot occur by
105
+ * accident.
106
+ */
107
+ export const typesafeFormat = {
108
+ images: true,
109
+ questionTypes: [...BASE_QUESTION_TYPES, 'box'],
110
+ request(model, state, questions, images = []) {
111
+ const body = { model, state, questions: toSystemOneQuestions(questions) };
112
+ if (images.length === 0) return body;
113
+ images.forEach((image, i) => checkJevImage(image, i + 1));
114
+ const refs = images.map(() => `image-${randomUUID()}`);
115
+ const request = {
116
+ ...body,
117
+ state: state === undefined ? { images: refs } : { input: state, images: refs },
118
+ assets: Object.fromEntries(refs.map((ref, i) => [
119
+ ref,
120
+ { type: 'image', format: JEV_IMAGE_FORMATS[images[i].mimeType], data_base64: images[i].base64 },
121
+ ])),
122
+ };
123
+ if (Buffer.byteLength(JSON.stringify(request)) > JEV_MAX_BODY_BYTES) {
124
+ throw jevLimitError('Jev accepts requests up to 32 MiB including base64 image data; send fewer or smaller images.');
125
+ }
126
+ return request;
127
+ },
128
+ parse: (body) => body,
129
+ };
130
+
60
131
  // Cloudflare rejects requests outside these bounds; checking first turns a
61
132
  // nested validation dump into a clear message.
62
133
  const CLEF_MAX_QUESTIONS = 64;
@@ -105,6 +176,7 @@ function checkClefImages(images, state, questions) {
105
176
  */
106
177
  export const cloudflareFormat = {
107
178
  images: true,
179
+ questionTypes: BASE_QUESTION_TYPES,
108
180
  request(model, state, questions, images = []) {
109
181
  const ids = Object.keys(questions);
110
182
  if (ids.length > CLEF_MAX_QUESTIONS) {
@@ -201,6 +273,7 @@ function fromOpenAIAnswer(answer) {
201
273
  */
202
274
  export const openaiFormat = {
203
275
  images: true,
276
+ questionTypes: BASE_QUESTION_TYPES,
204
277
  request(model, state, questions, images = []) {
205
278
  const input = images.length === 0
206
279
  ? asText(state)
@@ -23,16 +23,18 @@ const MAX_RETRY_AFTER_MS = 30_000;
23
23
  * failing over (network, timeout, 408/429/5xx, auth); `terminal` marks request
24
24
  * faults that every host of the same model would reject the same way;
25
25
  * `notSent` marks a request refused by a local limit check before any network
26
- * call, so it is not reported as a failed request.
26
+ * call, so it is not reported as a failed request. `code` is the host's
27
+ * machine-readable error type, when it sends one.
27
28
  */
28
29
  export class DecisionError extends Error {
29
- constructor(message, { status = null, retryable = false, terminal = false, notSent = false, requestId = null, retryAfterMs = null } = {}) {
30
+ constructor(message, { status = null, retryable = false, terminal = false, notSent = false, code = null, requestId = null, retryAfterMs = null } = {}) {
30
31
  super(message);
31
32
  this.name = 'DecisionError';
32
33
  this.status = status;
33
34
  this.retryable = retryable;
34
35
  this.terminal = terminal;
35
36
  this.notSent = notSent;
37
+ this.code = code;
36
38
  this.requestId = requestId;
37
39
  this.retryAfterMs = retryAfterMs;
38
40
  }
@@ -94,6 +96,11 @@ export function extractErrorMessage(body, rawText) {
94
96
  const type = body.detail.error_type ? `${body.detail.error_type}: ` : '';
95
97
  return `${type}${body.detail.message}`;
96
98
  }
99
+ if (typeof body?.detail?.error_type === 'string') {
100
+ return body.detail.error_type === 'max_tokens_exceeded'
101
+ ? 'max_tokens_exceeded: the request is longer than the model\'s context length'
102
+ : body.detail.error_type;
103
+ }
97
104
  const nested = body?.error?.message ?? body?.error ?? body?.detail ?? body?.message;
98
105
  if (typeof nested === 'string') {
99
106
  const inner = nested.match(/^HTTP \d+: (\{.*\})$/s);
@@ -221,6 +228,7 @@ async function sendOnce({ url, headers, body, parse, signal, timeoutMs }) {
221
228
  status,
222
229
  retryable: !firewall && (status === 408 || status === 429 || status >= 500 || status === 401 || status === 403),
223
230
  terminal: firewall || status === 400 || status === 422,
231
+ code: typeof parsed?.detail?.error_type === 'string' ? parsed.detail.error_type : null,
224
232
  requestId:
225
233
  response.headers.get('x-typesafe-request-id') ||
226
234
  response.headers.get('x-generation-id') ||
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Image header reading
3
+ *
4
+ * Reads pixel dimensions from PNG and JPEG headers so a host's size limits can
5
+ * be checked before upload, without an image library.
6
+ */
7
+
8
+ const PNG_SIGNATURE = 0x89504e47;
9
+
10
+ // JPEG start-of-frame markers carry the dimensions; C4 (DHT), C8 (JPG), and
11
+ // CC (DAC) share the range but are not frames.
12
+ function isStartOfFrame(marker) {
13
+ return marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
14
+ }
15
+
16
+ // Markers that stand alone, with no length field: TEM, RST0–7, SOI.
17
+ function isStandalone(marker) {
18
+ return marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8);
19
+ }
20
+
21
+ function readPng(buffer) {
22
+ if (buffer.length < 24 || buffer.toString('ascii', 12, 16) !== 'IHDR') return null;
23
+ // An acTL chunk ahead of the first IDAT marks an animated PNG.
24
+ const actl = buffer.indexOf('acTL');
25
+ const idat = buffer.indexOf('IDAT');
26
+ return {
27
+ width: buffer.readUInt32BE(16),
28
+ height: buffer.readUInt32BE(20),
29
+ animated: actl !== -1 && (idat === -1 || actl < idat),
30
+ };
31
+ }
32
+
33
+ function readJpeg(buffer) {
34
+ let offset = 2;
35
+ while (offset + 9 <= buffer.length) {
36
+ if (buffer[offset] !== 0xff) return null;
37
+ const marker = buffer[offset + 1];
38
+ if (marker === 0xff) {
39
+ offset += 1;
40
+ continue;
41
+ }
42
+ if (isStandalone(marker)) {
43
+ offset += 2;
44
+ continue;
45
+ }
46
+ if (isStartOfFrame(marker)) {
47
+ return { width: buffer.readUInt16BE(offset + 7), height: buffer.readUInt16BE(offset + 5), animated: false };
48
+ }
49
+ offset += 2 + buffer.readUInt16BE(offset + 2);
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * @param {Buffer} buffer - Image file bytes
56
+ * @returns {{ width: number, height: number, animated: boolean }|null} null
57
+ * when the bytes are not a PNG or JPEG this reader understands
58
+ */
59
+ export function readImageSize(buffer) {
60
+ if (buffer.length >= 8 && buffer.readUInt32BE(0) === PNG_SIGNATURE) return readPng(buffer);
61
+ if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) return readJpeg(buffer);
62
+ return null;
63
+ }
@@ -17,8 +17,22 @@
17
17
  */
18
18
 
19
19
  import { getCustomHeaders as getOpenRouterAttributionHeaders } from '../providers/openrouter.js';
20
- import { callDecisionApi } from './http.js';
21
- import { cloudflareFormat, openaiFormat, systemOneFormat } from './formats.js';
20
+ import { callDecisionApi, DecisionError } from './http.js';
21
+ import { BASE_QUESTION_TYPES, cloudflareFormat, openaiFormat, systemOneFormat, typesafeFormat } from './formats.js';
22
+
23
+ // TypeSafe gates experimental features behind per-request opt-in headers.
24
+ const TYPESAFE_IMAGES_HEADER = 'TypeSafe-Danger-Zone-Images';
25
+ const TYPESAFE_LONG_CONTEXT_HEADER = 'TypeSafe-Danger-Zone-Long-Context';
26
+ const TYPESAFE_LONG_CONTEXT_TOKENS = 262_144;
27
+
28
+ function typesafeHeaders(config, { images = [], questions = {}, longContext = false } = {}) {
29
+ const usesImages = images.length > 0 || Object.values(questions).some((q) => q?.type === 'box');
30
+ return {
31
+ Authorization: `Bearer ${config.apiKeys.typesafe}`,
32
+ ...(usesImages ? { [TYPESAFE_IMAGES_HEADER]: 'true' } : {}),
33
+ ...(longContext ? { [TYPESAFE_LONG_CONTEXT_HEADER]: 'true' } : {}),
34
+ };
35
+ }
22
36
 
23
37
  export const DECISION_PROVIDERS = {
24
38
  openai: {
@@ -37,9 +51,12 @@ export const DECISION_PROVIDERS = {
37
51
  label: 'TypeSafe',
38
52
  setup: 'set TYPESAFE_API_KEY',
39
53
  defaultModel: 'jev-latest',
40
- format: systemOneFormat,
54
+ format: typesafeFormat,
41
55
  endpoint: () => 'https://api.typesafe.ai/v1/systemone',
42
- headers: (config) => ({ Authorization: `Bearer ${config.apiKeys.typesafe}` }),
56
+ headers: typesafeHeaders,
57
+ // Long context is slower and less accurate, so it is only turned on for a
58
+ // request the default context rejects.
59
+ longContext: true,
43
60
  isAvailable: (config) => Boolean(config?.apiKeys?.typesafe),
44
61
  // TypeSafe accepts every published versioned ID, listed or not.
45
62
  passthrough: (name) => /^jev-\d+\.\d+(\.\d+)?$/i.test(name),
@@ -79,14 +96,17 @@ export const DECISION_PROVIDERS = {
79
96
  /** Failover order: each model's native host first, then OpenRouter. */
80
97
  export const DECISION_PROVIDER_PRIORITY = ['openai', 'typesafe', 'cloudflare', 'openrouter'];
81
98
 
99
+ const WITH_BOX = [...BASE_QUESTION_TYPES, 'box'];
100
+
82
101
  /**
83
102
  * Decision models by canonical name. `hosts` maps each provider that serves
84
103
  * the model to that provider's own ID: OpenRouter only knows
85
- * `~typesafe/jev-latest` and `typesafe/jev-1.13` (it rejects `jev-1.13.0` and
86
- * `jev-preview`), so IDs are translated per host rather than by a blanket
87
- * prefix rewrite. Every host ID also routes to its model. `maxImages` is how
88
- * many images the model takes per call; a host also needs a format that can
89
- * send them.
104
+ * `~typesafe/jev-latest` and `typesafe/jev-1.13` / `typesafe/jev-1.14` (it
105
+ * rejects `jev-1.13.0` and `jev-preview`), so IDs are translated per host
106
+ * rather than by a blanket prefix rewrite. Every host ID also routes to its
107
+ * model. `maxImages` is how many images the model takes per call and
108
+ * `questionTypes` which question types it answers; a host also needs a format
109
+ * that can send them.
90
110
  */
91
111
  export const DECISION_MODELS = {
92
112
  'gpt-6-luna': {
@@ -94,31 +114,44 @@ export const DECISION_MODELS = {
94
114
  hosts: { openai: 'gpt-6-luna', openrouter: 'openai/gpt-6-luna-decisions' },
95
115
  // OpenAI documents no image count limit; the API enforces its own.
96
116
  maxImages: Infinity,
117
+ questionTypes: BASE_QUESTION_TYPES,
97
118
  },
119
+ // Jev's image limits are by size, not count; the format checks them.
98
120
  'jev-latest': {
99
121
  aliases: ['jev'],
100
122
  hosts: { typesafe: 'jev-latest', openrouter: '~typesafe/jev-latest' },
101
- maxImages: 0,
123
+ maxImages: Infinity,
124
+ questionTypes: BASE_QUESTION_TYPES,
102
125
  },
103
126
  'jev-1.13': {
104
127
  aliases: [],
105
128
  hosts: { typesafe: 'jev-1.13.0', openrouter: 'typesafe/jev-1.13' },
106
- maxImages: 0,
129
+ maxImages: Infinity,
130
+ questionTypes: BASE_QUESTION_TYPES,
131
+ },
132
+ 'jev-1.14': {
133
+ aliases: [],
134
+ hosts: { typesafe: 'jev-1.14.0', openrouter: 'typesafe/jev-1.14' },
135
+ maxImages: Infinity,
136
+ questionTypes: WITH_BOX,
107
137
  },
108
138
  'jev-preview': {
109
139
  aliases: [],
110
140
  hosts: { typesafe: 'jev-preview' },
111
- maxImages: 0,
141
+ maxImages: Infinity,
142
+ questionTypes: WITH_BOX,
112
143
  },
113
144
  clef: {
114
145
  aliases: ['@cf/cloudflare/clef'],
115
146
  hosts: { cloudflare: 'clef', openrouter: 'cloudflare/clef' },
116
147
  maxImages: 4,
148
+ questionTypes: BASE_QUESTION_TYPES,
117
149
  },
118
150
  'clef-flash': {
119
151
  aliases: ['@cf/cloudflare/clef-flash'],
120
152
  hosts: { cloudflare: 'clef-flash', openrouter: 'cloudflare/clef-flash' },
121
153
  maxImages: 4,
154
+ questionTypes: BASE_QUESTION_TYPES,
122
155
  },
123
156
  };
124
157
 
@@ -137,9 +170,10 @@ function findCanonicalModel(name) {
137
170
  /**
138
171
  * Resolve a name on one provider: a catalog model it hosts, then its
139
172
  * passthrough rule.
140
- * @returns {{ model: string, family: string, maxImages: number }|null} The ID
141
- * to send to that provider, the model it names (hosts of one family share
142
- * failures), and how many images this host can send that model
173
+ * @returns {{ model: string, family: string, maxImages: number, questionTypes: string[] }|null}
174
+ * The ID to send to that provider, the model it names (hosts of one family
175
+ * share failures), and how many images and which question types this host
176
+ * can send that model
143
177
  */
144
178
  export function findDecisionModel(provider, name) {
145
179
  const trimmed = String(name).trim();
@@ -147,10 +181,17 @@ export function findDecisionModel(provider, name) {
147
181
  const canonical = findCanonicalModel(trimmed);
148
182
  const hosted = canonical ? DECISION_MODELS[canonical].hosts[provider.name] : undefined;
149
183
  if (hosted) {
150
- const maxImages = provider.format.images ? DECISION_MODELS[canonical].maxImages : 0;
151
- return { model: hosted, family: canonical, maxImages };
184
+ const entry = DECISION_MODELS[canonical];
185
+ return {
186
+ model: hosted,
187
+ family: canonical,
188
+ maxImages: provider.format.images ? entry.maxImages : 0,
189
+ questionTypes: entry.questionTypes.filter((type) => provider.format.questionTypes.includes(type)),
190
+ };
152
191
  }
153
- return provider.passthrough(trimmed) ? { model: trimmed, family: trimmed, maxImages: 0 } : null;
192
+ return provider.passthrough(trimmed)
193
+ ? { model: trimmed, family: trimmed, maxImages: 0, questionTypes: BASE_QUESTION_TYPES }
194
+ : null;
154
195
  }
155
196
 
156
197
  function servedBy(providerName) {
@@ -246,7 +287,8 @@ export function resolveDecisionModel(spec, config) {
246
287
 
247
288
  /**
248
289
  * Ask one candidate: translate the System One request into the host's wire
249
- * format, send it, and map the answers back.
290
+ * format, send it, and map the answers back. A host with a long-context mode
291
+ * gets the request again in that mode when its default context rejects it.
250
292
  * @param {object} candidate - From resolveDecisionModel
251
293
  * @param {object} params
252
294
  * @param {string|object|Array} params.state
@@ -254,15 +296,32 @@ export function resolveDecisionModel(spec, config) {
254
296
  * @param {Array<{ mimeType: string, base64: string }>} [params.images]
255
297
  * @param {object} params.config - Server configuration
256
298
  * @param {AbortSignal} [params.signal]
257
- * @returns {Promise<{ model?: string, answers: object, usage?: object, id?: string }>}
299
+ * @returns {Promise<{ model?: string, answers: object, usage?: object, id?: string, longContext?: boolean }>}
258
300
  */
259
301
  export async function askDecisionModel(candidate, { state, questions, images = [], config, signal }) {
260
302
  const { provider, model } = candidate;
261
- return callDecisionApi({
262
- url: provider.endpoint(config, model),
263
- headers: provider.headers(config),
264
- body: provider.format.request(model, state, questions, images),
265
- parse: provider.format.parse,
266
- signal,
267
- });
303
+ const body = provider.format.request(model, state, questions, images);
304
+ const send = (longContext) =>
305
+ callDecisionApi({
306
+ url: provider.endpoint(config, model),
307
+ headers: provider.headers(config, { images, questions, longContext }),
308
+ body,
309
+ parse: provider.format.parse,
310
+ signal,
311
+ });
312
+
313
+ try {
314
+ return await send(false);
315
+ } catch (error) {
316
+ if (!provider.longContext || error.code !== 'max_tokens_exceeded') throw error;
317
+ }
318
+ try {
319
+ return { ...(await send(true)), longContext: true };
320
+ } catch (error) {
321
+ if (error.code !== 'max_tokens_exceeded') throw error;
322
+ throw new DecisionError(
323
+ `${error.message} (over the ${Math.round(TYPESAFE_LONG_CONTEXT_TOKENS / 1000)}k-token long context too)`,
324
+ { status: error.status, terminal: true, code: error.code, requestId: error.requestId },
325
+ );
326
+ }
268
327
  }
@@ -17,7 +17,7 @@ import { createLogger } from '../utils/logger.js';
17
17
 
18
18
  const logger = createLogger('decide');
19
19
 
20
- const QUESTION_TYPES = ['noul', 'choice', 'score'];
20
+ const QUESTION_TYPES = ['noul', 'choice', 'score', 'box'];
21
21
  const QUESTION_FIELDS = ['type', 'instructions', 'criteria'];
22
22
  const MAX_CHOICE_OPTIONS = 255;
23
23
  const MIN_SCORE_LEVELS = 2;
@@ -61,6 +61,10 @@ function validateQuestion(key, question) {
61
61
  return `${at}.instructions must be a non-empty string, object, or array.`;
62
62
  }
63
63
 
64
+ if (question.type === 'box') {
65
+ return criteria === undefined ? null : `${at} is a box question, which takes no criteria: describe the target in instructions.`;
66
+ }
67
+
64
68
  if (question.type === 'noul') {
65
69
  if (criteria === undefined) return null;
66
70
  const keys = isPlainObject(criteria) ? Object.keys(criteria).sort() : null;
@@ -177,7 +181,8 @@ async function loadFiles(files, contextProcessor, config) {
177
181
  return problems.length ? { error: problems.join('; ') } : { contents };
178
182
  }
179
183
 
180
- // The formats every image-capable host accepts (Clef takes no GIF or BMP).
184
+ // The formats some image-capable host accepts (Clef takes no GIF or BMP; Jev
185
+ // takes PNG and JPEG only, which its format checks).
181
186
  const IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
182
187
  const DATA_URL = /^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/i;
183
188
 
@@ -254,6 +259,11 @@ function summarizeAnswer(key, answer) {
254
259
  const label = (k) => (answer.legend?.[k] ? `${k} "${answer.legend[k]}"` : k);
255
260
  return `- ${key} (score): ${fixed(answer.score)}${range} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities, label)}`;
256
261
  }
262
+ if (type === 'box') {
263
+ const box = answer.box || {};
264
+ const edges = ['left', 'top', 'right', 'bottom'].map((edge) => `${edge} ${fixed(box[edge])}`).join(', ');
265
+ return `- ${key} (box): ${edges} · confidence ${fixed(answer.confidence)}`;
266
+ }
257
267
  if (type === 'refusal') {
258
268
  return `- ${key} (refusal): the model declined to answer`;
259
269
  }
@@ -264,6 +274,7 @@ function formatResult(response, candidate, failures) {
264
274
  const usage = response.usage || {};
265
275
  const header = [
266
276
  `Decision · ${response.model || candidate.model} via ${candidate.provider.label}`,
277
+ response.longContext ? 'long context' : null,
267
278
  usage.input_tokens !== undefined ? `${usage.input_tokens} input tokens` : null,
268
279
  typeof usage.cost === 'number' ? `$${usage.cost.toFixed(6)}` : null,
269
280
  ].filter(Boolean).join(' · ');
@@ -277,6 +288,7 @@ function formatResult(response, candidate, failures) {
277
288
  const payload = {
278
289
  model: response.model ?? candidate.model,
279
290
  provider: candidate.providerName,
291
+ ...(response.longContext ? { long_context: true } : {}),
280
292
  answers: response.answers,
281
293
  usage: {
282
294
  input_tokens: usage.input_tokens ?? null,
@@ -316,14 +328,25 @@ export async function decideTool(args, dependencies) {
316
328
  if (stateError) return createToolError(stateError);
317
329
  const questionError = validateQuestions(questions);
318
330
  if (questionError) return createToolError(questionError);
331
+ const types = [...new Set(Object.values(questions).map((q) => q.type))];
332
+ if (types.includes('box') && images.length === 0) {
333
+ return createToolError('Box questions locate something in an image: pass the image in "images".');
334
+ }
319
335
 
320
336
  const route = resolveDecisionModel(model, config);
321
337
  if (route.status !== 'ok') return createToolError(route.error);
322
- const candidates = route.candidates.filter((c) => c.maxImages >= images.length);
338
+ const answersAll = (c) => types.every((type) => c.questionTypes.includes(type));
339
+ if (!route.candidates.some(answersAll)) {
340
+ return createToolError(
341
+ `"${model}" cannot answer box questions on any configured host. ` +
342
+ 'Box needs model "jev-1.14" or "jev-preview" on TypeSafe (TYPESAFE_API_KEY).',
343
+ );
344
+ }
345
+ const candidates = route.candidates.filter((c) => answersAll(c) && c.maxImages >= images.length);
323
346
  if (candidates.length === 0) {
324
347
  return createToolError(
325
348
  `"${model}" cannot take ${images.length} image(s) on any configured host. ` +
326
- 'Images work with gpt-6-luna (OpenAI) and clef / clef-flash (Cloudflare, up to 4); Jev and OpenRouter routes are text only.',
349
+ 'Images work with gpt-6-luna (OpenAI), Jev (TypeSafe), and clef / clef-flash (Cloudflare, up to 4); OpenRouter routes Jev text only.',
327
350
  );
328
351
  }
329
352
 
@@ -366,14 +389,18 @@ export async function decideTool(args, dependencies) {
366
389
  decideTool.description =
367
390
  'DECIDE — ask a decision model (OpenAI gpt-6-luna, TypeSafe Jev, Cloudflare Clef) typed questions about a state and get calibrated answers, not text. ' +
368
391
  'Question types: "noul" (yes/no → probability 0..1), "choice" (pick one of 2–255 named options → choice, per-option probabilities, confidence), ' +
369
- '"score" (ordered rubric of 2–10 levels → weighted position, per-level probabilities, confidence). ' +
392
+ '"score" (ordered rubric of 2–10 levels → weighted position, per-level probabilities, confidence), ' +
393
+ '"box" (where one target is in an image → left/top/right/bottom edges as 0..1 fractions of width/height, confidence; jev-1.14 / jev-preview only, needs an image, ' +
394
+ 'always returns a box, so pair it with a noul asking whether the target is there). ' +
370
395
  'Batch independent questions over the same state into one call: they are judged in parallel and in isolation, so none sees another\'s answer; each extra question adds its own input tokens. ' +
371
396
  'Best for fast semantic judgments (classify, route, select, verify, rank). Ask one narrow, coherent judgment per question, with its full meaning in the question; ' +
372
397
  'split independently useful dimensions, but a bounded action choice or contextual interpretation is a valid single question. Do counting, arithmetic, and date comparison in code. ' +
373
398
  'confidence measures how concentrated the distribution is, not permission to act: take the top option to pick a best, and treat a noul near 0.5 as "yes and no equally likely". ' +
374
- 'No explanations are returned. Images (PNG/JPEG/WebP) work with gpt-6-luna and Clef (up to 4, ~180 KB total); Jev is text only, and "auto" skips models that cannot take the images. ' +
399
+ 'No explanations are returned. Images work with gpt-6-luna (PNG/JPEG/WebP), Jev on TypeSafe (PNG/JPEG, 256–1568 px per side, upright pixels: EXIF rotation is ignored), ' +
400
+ 'and Clef (PNG/JPEG/WebP, up to 4, ~180 KB total); "auto" skips models that cannot take the images. ' +
375
401
  'gpt-6-luna may answer a question with type "refusal" instead of a value: treat it as unanswered, never as a no. ' +
376
- 'Limits per model: Jev ~64k tokens per request (~32k for state plus the longest question); Clef ~64k tokens and at most 64 questions named with letters, digits, "_", ".", "-".';
402
+ 'Limits per model: Jev ~64k tokens per request (~32k for state plus the longest question), and on TypeSafe a request over that is resent once in long-context mode ' +
403
+ '(262k, slower and less accurate; the result says "long context"); Clef ~64k tokens and at most 64 questions named with letters, digits, "_", ".", "-".';
377
404
 
378
405
  decideTool.inputSchema = {
379
406
  type: 'object',
@@ -387,9 +414,10 @@ decideTool.inputSchema = {
387
414
  type: 'object',
388
415
  description:
389
416
  'Named questions, all answered against the same state. The name is your own label and is returned as the answer key. ' +
390
- 'Each question: { "type": "noul"|"choice"|"score", "instructions": string|object|array, "criteria": ... }. ' +
417
+ 'Each question: { "type": "noul"|"choice"|"score"|"box", "instructions": string|object|array, "criteria": ... }. ' +
391
418
  'criteria — noul: optional { "true": "...", "false": "..." }; choice: required { "<option>": "description" | null } (2–255 options); ' +
392
- 'score: required ordered array of levels, lowest first (2–10 levels); each level is a description, or { "label": "Short name", "description": "..." } to keep the legend short. ' +
419
+ 'score: required ordered array of levels, lowest first (2–10 levels); each level is a description, or { "label": "Short name", "description": "..." } to keep the legend short; ' +
420
+ 'box: none, the instructions name the target ("the coffee mug nearest the sink"). ' +
393
421
  'instructions may be an object bundling the question with reference data, referenced by `name` in the text. ' +
394
422
  'Example: { "team": { "type": "choice", "instructions": "Which team should handle this?", "criteria": { "billing": "Payments, refunds", "technical": "Bugs, outages" } } }',
395
423
  additionalProperties: {
@@ -407,7 +435,8 @@ decideTool.inputSchema = {
407
435
  type: 'string',
408
436
  description:
409
437
  'Decision model. "auto" (default): gpt-6-luna, then jev-latest, then clef, each on every configured host. ' +
410
- 'A model name ("gpt-6-luna"/"luna", "jev-latest"/"jev", "jev-1.13", "jev-preview", "clef", "clef-flash"): its native host first, falling back to OpenRouter. ' +
438
+ 'A model name ("gpt-6-luna"/"luna", "jev-latest"/"jev", "jev-1.13", "jev-1.14", "jev-preview", "clef", "clef-flash"): its native host first, falling back to OpenRouter. ' +
439
+ 'jev-latest is TypeSafe\'s stable release (1.13); jev-preview is its newest (1.14). ' +
411
440
  '"provider:model" (e.g. "openai:gpt-6-luna", "cloudflare:clef-flash", "openrouter:jev-latest"): that provider only. ' +
412
441
  'Providers: openai (OPENAI_API_KEY), typesafe (TYPESAFE_API_KEY), cloudflare (CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN), openrouter (OPENROUTER_API_KEY).',
413
442
  },
@@ -422,7 +451,8 @@ decideTool.inputSchema = {
422
451
  items: { type: 'string' },
423
452
  description:
424
453
  'Images judged together with the state: file paths or base64 data URLs, PNG/JPEG/WebP. Refer to them in questions ("Does the image show ..."). ' +
425
- 'Supported by gpt-6-luna and clef/clef-flash (Clef: up to 4, about 180 KB in total); other models are skipped or rejected.',
454
+ 'Supported by gpt-6-luna, Jev on TypeSafe (PNG/JPEG, 256–1568 px per side; sent as state { "input": <state>, "images": [...] }), ' +
455
+ 'and clef/clef-flash (Clef: up to 4, about 180 KB in total); other routes are skipped or rejected.',
426
456
  },
427
457
  },
428
458
  required: ['questions'],