@crossworks/client-types 0.232.110 → 0.232.114

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossworks/client-types",
3
- "version": "0.232.110",
3
+ "version": "0.232.114",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -6,6 +6,11 @@
6
6
  * at curation time). Do NOT hand-edit entries here: curate at /models/pools
7
7
  * (by hand or via the Curator) and re-export with GET /api/model-pools/export.
8
8
  *
9
+ * One hand-correction on 2026-09-02: the vision ("Read images") pool carried
10
+ * Nano Banana Pro (`google/gemini-3-pro-image`), which is an image GENERATOR
11
+ * (`text+image->text+image`). It is out, and `poolModelIssue` in
12
+ * ./model-pools.ts now rejects that class of entry on every write path.
13
+ *
9
14
  * Seeded into `curated_models` for owners who have no curated entries yet
10
15
  * (fresh installs at onboarding; empty existing brains on upgrade). Owners who
11
16
  * have curated ANYTHING are never touched — their pools are their judgment.
@@ -2138,27 +2143,6 @@ export const CURATED_MODEL_POOLS: readonly CuratedTemplateEntry[] = [
2138
2143
  {
2139
2144
  pool: 'vision',
2140
2145
  position: 1,
2141
- name: 'Gemini 3 Pro Image (Nano Banana Pro)',
2142
- vendor: 'Google',
2143
- routes: [
2144
- {
2145
- provider: 'openrouter',
2146
- model: 'google/gemini-3-pro-image',
2147
- },
2148
- ],
2149
- pricing: {
2150
- source: 'openrouter',
2151
- currency: 'USD',
2152
- inputPerM: 2,
2153
- capturedAt: '2026-08-22T15:31:39.522Z',
2154
- outputPerM: 12,
2155
- },
2156
- rating: 5,
2157
- note: 'purpose-built image model, flagship OCR/extraction quality, can also edit/generate images; 131K context',
2158
- },
2159
- {
2160
- pool: 'vision',
2161
- position: 2,
2162
2146
  name: 'Claude Sonnet 5',
2163
2147
  vendor: 'Anthropic',
2164
2148
  routes: [
@@ -2183,7 +2167,7 @@ export const CURATED_MODEL_POOLS: readonly CuratedTemplateEntry[] = [
2183
2167
  },
2184
2168
  {
2185
2169
  pool: 'vision',
2186
- position: 3,
2170
+ position: 2,
2187
2171
  name: 'Qwen3 VL 235B A22B Instruct',
2188
2172
  vendor: 'Alibaba/Qwen',
2189
2173
  routes: [
@@ -2204,7 +2188,7 @@ export const CURATED_MODEL_POOLS: readonly CuratedTemplateEntry[] = [
2204
2188
  },
2205
2189
  {
2206
2190
  pool: 'vision',
2207
- position: 4,
2191
+ position: 3,
2208
2192
  name: 'Qwen3 VL 30B A3B Instruct',
2209
2193
  vendor: 'Alibaba/Qwen',
2210
2194
  routes: [
@@ -2225,7 +2209,7 @@ export const CURATED_MODEL_POOLS: readonly CuratedTemplateEntry[] = [
2225
2209
  },
2226
2210
  {
2227
2211
  pool: 'vision',
2228
- position: 5,
2212
+ position: 4,
2229
2213
  name: 'Nemotron Nano 12B V2 VL (free)',
2230
2214
  vendor: 'NVIDIA',
2231
2215
  routes: [
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pool/model fit. Pins the 2026-09-02 bug: the shipped vision ("Read images")
3
+ * pool carried Nano Banana Pro, an image GENERATOR. Generators accept image
4
+ * input exactly like readers do, so nothing on the input side caught it —
5
+ * the model billed image-generation tokens and returned a picture where the
6
+ * vision worker wanted text.
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { MODEL_POOLS, poolModelIssue } from './model-pools';
10
+ import { CURATED_MODEL_POOLS } from './model-pools-data';
11
+
12
+ const READER = { input: ['text', 'image'], output: ['text'] };
13
+ const GENERATOR = { input: ['image', 'text'], output: ['image', 'text'] };
14
+ const TEXT_ONLY = { input: ['text'], output: ['text'] };
15
+
16
+ describe('poolModelIssue', () => {
17
+ it('keeps an image generator out of the vision pool', () => {
18
+ expect(poolModelIssue('vision', GENERATOR)).toMatch(/OUTPUTS images/);
19
+ });
20
+
21
+ it('allows a real image reader in the vision pool', () => {
22
+ expect(poolModelIssue('vision', READER)).toBeNull();
23
+ });
24
+
25
+ it('keeps a blind model out of the vision pool', () => {
26
+ expect(poolModelIssue('vision', TEXT_ONLY)).toMatch(/does not accept image input/);
27
+ });
28
+
29
+ it('keeps an image generator out of every text-out pool', () => {
30
+ for (const pool of MODEL_POOLS.filter((p) => p.modality.output === 'text')) {
31
+ expect(poolModelIssue(pool.id, GENERATOR), pool.id).toMatch(/OUTPUTS images/);
32
+ }
33
+ });
34
+
35
+ it('wants a generator in the image_gen pool, and nothing else', () => {
36
+ expect(poolModelIssue('image_gen', GENERATOR)).toBeNull();
37
+ expect(poolModelIssue('image_gen', READER)).toMatch(/does not output images/);
38
+ });
39
+
40
+ it('never checks the audio pools — their models are not in the chat catalog', () => {
41
+ expect(poolModelIssue('tts', GENERATOR)).toBeNull();
42
+ expect(poolModelIssue('stt', TEXT_ONLY)).toBeNull();
43
+ });
44
+
45
+ it('fails OPEN on an unloaded catalog and on an unknown pool', () => {
46
+ expect(poolModelIssue('vision', null)).toBeNull();
47
+ expect(poolModelIssue('vision', { input: [], output: [] })).toBeNull();
48
+ expect(poolModelIssue('not-a-pool', GENERATOR)).toBeNull();
49
+ });
50
+ });
51
+
52
+ describe('the shipped curated template', () => {
53
+ const slugs = (pool: string) =>
54
+ new Set(
55
+ CURATED_MODEL_POOLS.filter((e) => e.pool === pool).flatMap((e) =>
56
+ e.routes.map((r) => r.model),
57
+ ),
58
+ );
59
+
60
+ it('never lists the same model as both an image reader and an image generator', () => {
61
+ const overlap = [...slugs('vision')].filter((s) => slugs('image_gen').has(s));
62
+ expect(overlap).toEqual([]);
63
+ });
64
+
65
+ it('gives every pool contiguous positions from 0', () => {
66
+ for (const pool of MODEL_POOLS) {
67
+ const positions = CURATED_MODEL_POOLS.filter((e) => e.pool === pool.id)
68
+ .map((e) => e.position)
69
+ .sort((a, b) => a - b);
70
+ if (positions.length === 0) continue;
71
+ expect(positions, pool.id).toEqual(positions.map((_, i) => i));
72
+ }
73
+ });
74
+ });
@@ -17,8 +17,33 @@ export type ModelPoolDef = {
17
17
  description: string;
18
18
  /** Which side of the split it belongs to. */
19
19
  group: 'agents' | 'workers';
20
+ /** What the pool's consumer needs the model to DO, in catalog terms. */
21
+ modality: PoolModality;
20
22
  };
21
23
 
24
+ /**
25
+ * A pool's modality contract, expressed the way OpenRouter's catalog does
26
+ * (`architecture.input_modalities` / `output_modalities`).
27
+ *
28
+ * This exists because of one specific trap: "Read images" and "Image
29
+ * generation" BOTH accept image input, so the input side alone cannot tell
30
+ * them apart. A generator like Nano Banana Pro (`google/gemini-3-pro-image`,
31
+ * `text+image->text+image`) looks like a perfect vision model on inputs
32
+ * alone, and a curator reading names picks it for the reader pool — where it
33
+ * bills image-generation tokens and hands back a picture instead of the text
34
+ * the vision worker parses. The OUTPUT side is the decider: an image READER
35
+ * is just a capable text-out model that happens to accept pictures.
36
+ */
37
+ export type PoolModality = {
38
+ /** Modalities the model must ACCEPT. Empty = text-only is fine. */
39
+ input: readonly ('image' | 'file')[];
40
+ /** What the consumer reads back. `audio` pools live in the provider voice
41
+ * catalogs, not OpenRouter's chat catalog, so they are never checked. */
42
+ output: 'text' | 'image' | 'audio';
43
+ };
44
+
45
+ const TEXT_OUT: PoolModality = { input: [], output: 'text' };
46
+
22
47
  export const MODEL_POOLS: readonly ModelPoolDef[] = [
23
48
  {
24
49
  id: 'agents',
@@ -26,6 +51,7 @@ export const MODEL_POOLS: readonly ModelPoolDef[] = [
26
51
  description:
27
52
  'The shared premium pool: the assistant persona, team responder, and every specialist (pages, tables, coder, appsmith, researcher…). Frontier chat models with strong tool use.',
28
53
  group: 'agents',
54
+ modality: TEXT_OUT,
29
55
  },
30
56
  {
31
57
  id: 'extractor',
@@ -33,18 +59,21 @@ export const MODEL_POOLS: readonly ModelPoolDef[] = [
33
59
  description:
34
60
  'Reads every ingested item and produces the summary, facts, and entities. Highest call volume in the system — needs cheap, fast, reliable structured output.',
35
61
  group: 'workers',
62
+ modality: TEXT_OUT,
36
63
  },
37
64
  {
38
65
  id: 'summarizer',
39
66
  label: 'Summarizer',
40
67
  description: 'Condenses text wherever a summary is needed. Cheap, fast workhorse.',
41
68
  group: 'workers',
69
+ modality: TEXT_OUT,
42
70
  },
43
71
  {
44
72
  id: 'reflector',
45
73
  label: 'Reflector',
46
74
  description: 'Periodic memory-reflection passes over recent activity. Cheap, fast workhorse.',
47
75
  group: 'workers',
76
+ modality: TEXT_OUT,
48
77
  },
49
78
  {
50
79
  id: 'document',
@@ -52,18 +81,21 @@ export const MODEL_POOLS: readonly ModelPoolDef[] = [
52
81
  description:
53
82
  'Reads whole documents natively (PDF understanding). Needs a multimodal model that accepts document input.',
54
83
  group: 'workers',
84
+ modality: TEXT_OUT,
55
85
  },
56
86
  {
57
87
  id: 'vision',
58
88
  label: 'Read images',
59
89
  description: 'Pulls text and meaning out of images (uploads, extracted document images).',
60
90
  group: 'workers',
91
+ modality: { input: ['image'], output: 'text' },
61
92
  },
62
93
  {
63
94
  id: 'image_gen',
64
95
  label: 'Image generation',
65
96
  description: 'Generates images. Provider-specific catalog (Gemini image, DALL-E, …).',
66
97
  group: 'workers',
98
+ modality: { input: [], output: 'image' },
67
99
  },
68
100
  {
69
101
  id: 'tts',
@@ -71,6 +103,7 @@ export const MODEL_POOLS: readonly ModelPoolDef[] = [
71
103
  description:
72
104
  'Turns replies into speech. Provider-specific catalog (Grok voice, GPT-4o TTS voices, ElevenLabs).',
73
105
  group: 'workers',
106
+ modality: { input: [], output: 'audio' },
74
107
  },
75
108
  {
76
109
  id: 'stt',
@@ -78,6 +111,7 @@ export const MODEL_POOLS: readonly ModelPoolDef[] = [
78
111
  description:
79
112
  'Speech to text for voice notes and video ingest (Whisper family, grok-stt, gpt-4o-mini-transcribe).',
80
113
  group: 'workers',
114
+ modality: { input: [], output: 'audio' },
81
115
  },
82
116
  {
83
117
  id: 'search',
@@ -85,12 +119,14 @@ export const MODEL_POOLS: readonly ModelPoolDef[] = [
85
119
  description:
86
120
  'The standard web-search answer tier. Must be a search-native model (Perplexity Sonar family).',
87
121
  group: 'workers',
122
+ modality: TEXT_OUT,
88
123
  },
89
124
  {
90
125
  id: 'search_advanced',
91
126
  label: 'Deep web search',
92
127
  description: 'The strong search tier for hard or conflicting questions (sonar-pro class).',
93
128
  group: 'workers',
129
+ modality: TEXT_OUT,
94
130
  },
95
131
  {
96
132
  id: 'narrator',
@@ -98,6 +134,7 @@ export const MODEL_POOLS: readonly ModelPoolDef[] = [
98
134
  description:
99
135
  'Turns tool outcomes and events into short prose for the user. Off the critical path — cheap.',
100
136
  group: 'workers',
137
+ modality: TEXT_OUT,
101
138
  },
102
139
  {
103
140
  id: 'suggester',
@@ -105,7 +142,58 @@ export const MODEL_POOLS: readonly ModelPoolDef[] = [
105
142
  description:
106
143
  'Generates the follow-up suggestion chips after a reply. Cheapest of all; has a fallback chain (suggester → narrator → summarizer).',
107
144
  group: 'workers',
145
+ modality: TEXT_OUT,
108
146
  },
109
147
  ];
110
148
 
111
149
  export const MODEL_POOL_IDS = new Set(MODEL_POOLS.map((p) => p.id));
150
+
151
+ const POOL_BY_ID = new Map(MODEL_POOLS.map((p) => [p.id, p]));
152
+
153
+ /** One model's modalities as OpenRouter reports them. */
154
+ export type ModelModalities = {
155
+ input: readonly string[];
156
+ output: readonly string[];
157
+ };
158
+
159
+ /**
160
+ * Does this model belong in this pool? Returns the reason it does NOT, or
161
+ * null when it fits.
162
+ *
163
+ * Fail-open by design (same rule as the worker-config catalog check): a
164
+ * `null` modalities argument means the catalog never loaded, and an outage
165
+ * must never block a curator from recording their judgment. Only positive
166
+ * catalog evidence rejects. Audio pools (tts/stt) are never checked — their
167
+ * models live in the provider voice catalogs, not OpenRouter's chat catalog,
168
+ * so any "evidence" about them here would be an absence, not a fact.
169
+ */
170
+ export function poolModelIssue(
171
+ poolId: string,
172
+ modalities: ModelModalities | null | undefined,
173
+ ): string | null {
174
+ const pool = POOL_BY_ID.get(poolId);
175
+ if (!pool || !modalities) return null;
176
+ const want = pool.modality;
177
+ if (want.output === 'audio') return null;
178
+ const outputs = modalities.output ?? [];
179
+ const inputs = modalities.input ?? [];
180
+ if (outputs.length === 0 && inputs.length === 0) return null;
181
+
182
+ const makesImages = outputs.includes('image');
183
+ if (want.output === 'text' && makesImages) {
184
+ return (
185
+ `this model OUTPUTS images (${outputs.join('+')}) — it is an image generator, ` +
186
+ `and the ${pool.label} pool needs a text-out model. Put it in the Image generation ` +
187
+ `pool instead. Reading images is just a capable text-out model that accepts pictures.`
188
+ );
189
+ }
190
+ if (want.output === 'image' && !makesImages && outputs.length > 0) {
191
+ return `this model does not output images (${outputs.join('+')}) — the ${pool.label} pool needs a generator.`;
192
+ }
193
+ for (const need of want.input) {
194
+ if (inputs.length > 0 && !inputs.includes(need)) {
195
+ return `this model does not accept ${need} input (accepts ${inputs.join('+')}) — the ${pool.label} pool needs one that does.`;
196
+ }
197
+ }
198
+ return null;
199
+ }