@crossworks/client-types 0.232.175 → 0.232.181

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.175",
3
+ "version": "0.232.181",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -9,7 +9,7 @@
9
9
  "./*": "./src/*.ts"
10
10
  },
11
11
  "devDependencies": {
12
- "@types/node": "^26.4.0"
12
+ "@types/node": "^26.4.1"
13
13
  },
14
14
  "license": "SEE LICENSE IN LICENSE.md",
15
15
  "repository": {
@@ -6,12 +6,18 @@
6
6
  * vision worker wanted text.
7
7
  */
8
8
  import { describe, expect, it } from 'vitest';
9
- import { MODEL_POOLS, poolModelIssue } from './model-pools';
9
+ import { MODEL_POOLS, poolModelIssue, kindFromModalities } from './model-pools';
10
10
  import { CURATED_MODEL_POOLS } from './model-pools-data';
11
11
 
12
12
  const READER = { input: ['text', 'image'], output: ['text'] };
13
13
  const GENERATOR = { input: ['image', 'text'], output: ['image', 'text'] };
14
14
  const TEXT_ONLY = { input: ['text'], output: ['text'] };
15
+ // The voice shapes, verbatim from OpenRouter's catalog once the fetch asks
16
+ // for `output_modalities=all`: dedicated engines on either side, plus the
17
+ // speech-capable chat model that legitimately serves BOTH voice pools.
18
+ const TTS_ENGINE = { input: ['text'], output: ['speech'] };
19
+ const STT_ENGINE = { input: ['audio'], output: ['transcription'] };
20
+ const AUDIO_CHAT = { input: ['text', 'audio'], output: ['text', 'audio'] };
15
21
 
16
22
  describe('poolModelIssue', () => {
17
23
  it('keeps an image generator out of the vision pool', () => {
@@ -37,9 +43,32 @@ describe('poolModelIssue', () => {
37
43
  expect(poolModelIssue('image_gen', READER)).toMatch(/does not output images/);
38
44
  });
39
45
 
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();
46
+ it('keeps every other non-text output out of a text-out pool', () => {
47
+ expect(poolModelIssue('summarizer', TTS_ENGINE)).toMatch(/outputs speech, not text/);
48
+ expect(poolModelIssue('agents', STT_ENGINE)).toMatch(/outputs transcription, not text/);
49
+ expect(poolModelIssue('extractor', { input: ['text'], output: ['embeddings'] })).toMatch(
50
+ /outputs embeddings, not text/,
51
+ );
52
+ });
53
+
54
+ it('checks the voice pools now that the catalog lists their engines', () => {
55
+ expect(poolModelIssue('tts', TTS_ENGINE)).toBeNull();
56
+ expect(poolModelIssue('stt', STT_ENGINE)).toBeNull();
57
+ // The classic swap, which used to sail through unchecked.
58
+ expect(poolModelIssue('tts', STT_ENGINE)).toMatch(/produces no audio/);
59
+ expect(poolModelIssue('stt', TTS_ENGINE)).toMatch(/does not turn audio into text/);
60
+ expect(poolModelIssue('tts', TEXT_ONLY)).toMatch(/produces no audio/);
61
+ });
62
+
63
+ it('accepts a speech-capable chat model in both voice pools (gpt-audio)', () => {
64
+ expect(poolModelIssue('tts', AUDIO_CHAT)).toBeNull();
65
+ expect(poolModelIssue('stt', AUDIO_CHAT)).toBeNull();
66
+ });
67
+
68
+ it('still fails open for the direct-provider voice slugs OpenRouter never lists', () => {
69
+ // ElevenLabs, Deepgram, Gemini voices: no catalog row, so no evidence.
70
+ expect(poolModelIssue('tts', { input: [], output: [] })).toBeNull();
71
+ expect(poolModelIssue('stt', null)).toBeNull();
43
72
  });
44
73
 
45
74
  it('fails OPEN on an unloaded catalog and on an unknown pool', () => {
@@ -49,6 +78,37 @@ describe('poolModelIssue', () => {
49
78
  });
50
79
  });
51
80
 
81
+ describe('kindFromModalities', () => {
82
+ it('reads the bucket off the catalog instead of guessing from the slug', () => {
83
+ expect(kindFromModalities(['transcription'])).toBe('stt');
84
+ expect(kindFromModalities(['speech'])).toBe('tts');
85
+ expect(kindFromModalities(['embeddings'])).toBe('embedding');
86
+ expect(kindFromModalities(['rerank'])).toBe('rerank');
87
+ expect(kindFromModalities(['video'])).toBe('video');
88
+ expect(kindFromModalities(['image', 'text'])).toBe('image');
89
+ expect(kindFromModalities(['text'])).toBe('chat');
90
+ expect(kindFromModalities([])).toBe('chat');
91
+ });
92
+
93
+ it('separates a speech-capable chat model from a sound generator', () => {
94
+ // openai/gpt-audio: text+audio->text+audio, still a chat model.
95
+ expect(kindFromModalities(['text', 'audio'])).toBe('chat');
96
+ // google/lyria-*: audio out only.
97
+ expect(kindFromModalities(['audio'])).toBe('audio');
98
+ });
99
+
100
+ it('agrees with the pool contract it sits beside', () => {
101
+ // A model this bucket calls 'tts' must be one the tts pool accepts, or
102
+ // the /models filter and the curation guard would tell different stories.
103
+ const tts = { input: ['text'], output: ['speech'] };
104
+ expect(kindFromModalities(tts.output)).toBe('tts');
105
+ expect(poolModelIssue('tts', tts)).toBeNull();
106
+ const stt = { input: ['audio'], output: ['transcription'] };
107
+ expect(kindFromModalities(stt.output)).toBe('stt');
108
+ expect(poolModelIssue('stt', stt)).toBeNull();
109
+ });
110
+ });
111
+
52
112
  describe('the shipped curated template', () => {
53
113
  const slugs = (pool: string) =>
54
114
  new Set(
@@ -37,9 +37,13 @@ export type ModelPoolDef = {
37
37
  export type PoolModality = {
38
38
  /** Modalities the model must ACCEPT. Empty = text-only is fine. */
39
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';
40
+ /** What the consumer reads back, in OpenRouter's own output_modalities
41
+ * vocabulary `speech` is a TTS engine, `transcription` an ASR one. Those
42
+ * two used to be uncheckable (the catalog fetch was the text-out slice, so
43
+ * a voice route was always "unknown"); since the fetch asks for
44
+ * `output_modalities=all` they carry positive evidence like everything
45
+ * else. */
46
+ output: 'text' | 'image' | 'speech' | 'transcription';
43
47
  };
44
48
 
45
49
  const TEXT_OUT: PoolModality = { input: [], output: 'text' };
@@ -103,7 +107,7 @@ export const MODEL_POOLS: readonly ModelPoolDef[] = [
103
107
  description:
104
108
  'Turns replies into speech. Provider-specific catalog (Grok voice, GPT-4o TTS voices, ElevenLabs).',
105
109
  group: 'workers',
106
- modality: { input: [], output: 'audio' },
110
+ modality: { input: [], output: 'speech' },
107
111
  },
108
112
  {
109
113
  id: 'stt',
@@ -111,7 +115,7 @@ export const MODEL_POOLS: readonly ModelPoolDef[] = [
111
115
  description:
112
116
  'Speech to text for voice notes and video ingest (Whisper family, grok-stt, gpt-4o-mini-transcribe).',
113
117
  group: 'workers',
114
- modality: { input: [], output: 'audio' },
118
+ modality: { input: [], output: 'transcription' },
115
119
  },
116
120
  {
117
121
  id: 'search',
@@ -156,6 +160,33 @@ export type ModelModalities = {
156
160
  output: readonly string[];
157
161
  };
158
162
 
163
+ /** The coarse bucket a catalog row falls into. `chat` is what a text-out row
164
+ * is called; the rest mirror OpenRouter's output-modality vocabulary. */
165
+ export type CatalogKind =
166
+ 'chat' | 'image' | 'video' | 'tts' | 'stt' | 'embedding' | 'rerank' | 'audio';
167
+
168
+ /**
169
+ * Bucket a model by what it PRODUCES. Lives here, beside `poolModelIssue`, so
170
+ * the /models type filter and the Curator's `model_catalog` filter cannot
171
+ * drift apart — they are answering the same question off the same field, and
172
+ * two copies of this ordering would eventually disagree about (say) a model
173
+ * that both generates images and returns text.
174
+ *
175
+ * Order matters: the dedicated buckets win over the generic ones, and audio
176
+ * beside text is a speech-capable CHAT model (`openai/gpt-audio`) rather than
177
+ * a sound generator.
178
+ */
179
+ export function kindFromModalities(output: readonly string[]): CatalogKind {
180
+ if (output.includes('transcription')) return 'stt';
181
+ if (output.includes('speech')) return 'tts';
182
+ if (output.includes('embeddings')) return 'embedding';
183
+ if (output.includes('rerank')) return 'rerank';
184
+ if (output.includes('video')) return 'video';
185
+ if (output.includes('image')) return 'image';
186
+ if (output.includes('audio') && !output.includes('text')) return 'audio';
187
+ return 'chat';
188
+ }
189
+
159
190
  /**
160
191
  * Does this model belong in this pool? Returns the reason it does NOT, or
161
192
  * null when it fits.
@@ -163,9 +194,9 @@ export type ModelModalities = {
163
194
  * Fail-open by design (same rule as the worker-config catalog check): a
164
195
  * `null` modalities argument means the catalog never loaded, and an outage
165
196
  * 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.
197
+ * catalog evidence rejects a route the catalog does not list (every
198
+ * direct-provider voice slug: ElevenLabs, Deepgram, Gemini voices) still
199
+ * arrives here as an empty pair and passes.
169
200
  */
170
201
  export function poolModelIssue(
171
202
  poolId: string,
@@ -174,7 +205,6 @@ export function poolModelIssue(
174
205
  const pool = POOL_BY_ID.get(poolId);
175
206
  if (!pool || !modalities) return null;
176
207
  const want = pool.modality;
177
- if (want.output === 'audio') return null;
178
208
  const outputs = modalities.output ?? [];
179
209
  const inputs = modalities.input ?? [];
180
210
  if (outputs.length === 0 && inputs.length === 0) return null;
@@ -187,9 +217,34 @@ export function poolModelIssue(
187
217
  `pool instead. Reading images is just a capable text-out model that accepts pictures.`
188
218
  );
189
219
  }
220
+ // Everything else non-text on a text-out pool: video, speech (a TTS engine),
221
+ // transcription (an ASR one), embeddings, rerank. Before the catalog was
222
+ // widened these could not reach a text pool because the fetch never listed
223
+ // them; now they can, so the guard has to name them.
224
+ if (want.output === 'text' && !outputs.includes('text') && outputs.length > 0) {
225
+ return `this model outputs ${outputs.join('+')}, not text — the ${pool.label} pool needs a text-out model.`;
226
+ }
190
227
  if (want.output === 'image' && !makesImages && outputs.length > 0) {
191
228
  return `this model does not output images (${outputs.join('+')}) — the ${pool.label} pool needs a generator.`;
192
229
  }
230
+ // Voice pools. Two shapes qualify for each: the dedicated engine
231
+ // (`speech` / `transcription`) and the speech-capable chat model
232
+ // (`openai/gpt-audio` is `text+audio->text+audio` and legitimately serves
233
+ // BOTH pools — it is in the shipped template for both). What that still
234
+ // catches is the classic swap: a pure TTS engine parked in Transcribe emits
235
+ // no text and takes no audio, which is a positive contradiction.
236
+ if (want.output === 'speech' && outputs.length > 0) {
237
+ if (!outputs.includes('speech') && !outputs.includes('audio')) {
238
+ return `this model outputs ${outputs.join('+')} — it produces no audio, and the ${pool.label} pool needs a model that speaks.`;
239
+ }
240
+ }
241
+ if (want.output === 'transcription' && outputs.length > 0) {
242
+ const transcribes =
243
+ outputs.includes('transcription') || (outputs.includes('text') && inputs.includes('audio'));
244
+ if (!transcribes) {
245
+ return `this model does not turn audio into text (${inputs.join('+') || '?'}->${outputs.join('+')}) — the ${pool.label} pool needs one that does.`;
246
+ }
247
+ }
193
248
  for (const need of want.input) {
194
249
  if (inputs.length > 0 && !inputs.includes(need)) {
195
250
  return `this model does not accept ${need} input (accepts ${inputs.join('+')}) — the ${pool.label} pool needs one that does.`;