converse-mcp-server 4.7.1 → 4.8.1
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 +1 -1
- package/docs/API.md +14 -3
- package/package.json +5 -5
- package/src/decisionProviders/formats.js +77 -4
- package/src/decisionProviders/http.js +10 -2
- package/src/decisionProviders/imageSize.js +63 -0
- package/src/decisionProviders/index.js +86 -27
- package/src/tools/decide.js +41 -11
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`),
|
|
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.
|
|
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
|
|
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.
|
|
3
|
+
"version": "4.8.1",
|
|
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",
|
|
@@ -95,8 +95,8 @@
|
|
|
95
95
|
"dependencies": {
|
|
96
96
|
"@anthropic-ai/claude-agent-sdk": "^0.3.296",
|
|
97
97
|
"@anthropic-ai/sdk": "^0.133.0",
|
|
98
|
-
"@github/copilot-sdk": "^1.0.
|
|
99
|
-
"@google/genai": "^2.
|
|
98
|
+
"@github/copilot-sdk": "^1.0.20",
|
|
99
|
+
"@google/genai": "^2.29.0",
|
|
100
100
|
"@lydell/node-pty": "1.2.0-beta.15",
|
|
101
101
|
"@mistralai/mistralai": "^2.7.0",
|
|
102
102
|
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
@@ -111,10 +111,10 @@
|
|
|
111
111
|
"vite": "^8.3.4"
|
|
112
112
|
},
|
|
113
113
|
"devDependencies": {
|
|
114
|
-
"@vitest/coverage-v8": "^5.0.
|
|
114
|
+
"@vitest/coverage-v8": "^5.0.4",
|
|
115
115
|
"cross-env": "^10.1.0",
|
|
116
116
|
"eslint": "^10.12.0",
|
|
117
117
|
"rimraf": "^6.1.3",
|
|
118
|
-
"vitest": "^5.0.
|
|
118
|
+
"vitest": "^5.0.4"
|
|
119
119
|
}
|
|
120
120
|
}
|
|
@@ -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
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* format sends
|
|
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:
|
|
54
|
+
format: typesafeFormat,
|
|
41
55
|
endpoint: () => 'https://api.typesafe.ai/v1/systemone',
|
|
42
|
-
headers:
|
|
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`
|
|
86
|
-
* `jev-preview`), so IDs are translated per host
|
|
87
|
-
* prefix rewrite. Every host ID also routes to its
|
|
88
|
-
* many images the model takes per call
|
|
89
|
-
*
|
|
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:
|
|
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:
|
|
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:
|
|
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}
|
|
141
|
-
* to send to that provider, the model it names (hosts of one family
|
|
142
|
-
* failures), and how many images
|
|
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
|
|
151
|
-
return {
|
|
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)
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
}
|
package/src/tools/decide.js
CHANGED
|
@@ -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
|
|
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
|
|
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);
|
|
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
|
|
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)
|
|
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
|
|
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'],
|