@volter/twin-cohere 0.1.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 +224 -0
- package/defaults/handlers.json +26 -0
- package/dist/defaults/handlers.json +26 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +31 -0
- package/dist/src/cohere-budget.d.ts +55 -0
- package/dist/src/cohere-budget.js +171 -0
- package/dist/src/cohere-capabilities.d.ts +14 -0
- package/dist/src/cohere-capabilities.js +1852 -0
- package/dist/src/cohere-conformance.d.ts +17 -0
- package/dist/src/cohere-conformance.js +464 -0
- package/dist/src/cohere-connector.d.ts +150 -0
- package/dist/src/cohere-connector.js +625 -0
- package/dist/src/cohere-models.d.ts +21 -0
- package/dist/src/cohere-models.js +73 -0
- package/dist/src/cohere-scenario.d.ts +57 -0
- package/dist/src/cohere-scenario.js +176 -0
- package/dist/src/cohere-server.d.ts +16 -0
- package/dist/src/cohere-server.js +184 -0
- package/dist/src/cohere-stub.d.ts +119 -0
- package/dist/src/cohere-stub.js +321 -0
- package/dist/src/cohere-twin.d.ts +82 -0
- package/dist/src/cohere-twin.js +1243 -0
- package/dist/src/cohere-types.d.ts +226 -0
- package/dist/src/cohere-types.js +40 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +84 -0
- package/package.json +71 -0
- package/src/cli.ts +30 -0
- package/src/cohere-budget.ts +197 -0
- package/src/cohere-capabilities.ts +1855 -0
- package/src/cohere-conformance.ts +489 -0
- package/src/cohere-connector.ts +709 -0
- package/src/cohere-models.ts +79 -0
- package/src/cohere-scenario.ts +194 -0
- package/src/cohere-server.ts +195 -0
- package/src/cohere-stub.ts +337 -0
- package/src/cohere-twin.ts +1290 -0
- package/src/cohere-types.ts +231 -0
- package/src/index.ts +159 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
// Cohere twin CONFORMANCE — an offline, deterministic check with REAL TEETH.
|
|
2
|
+
//
|
|
3
|
+
// The bar (docs/contributing/adding-a-twin.md §6, "The conformance check needs the same teeth"): delete the
|
|
4
|
+
// handler branch and this must go RED. Two known false-green shapes are avoided by construction:
|
|
5
|
+
//
|
|
6
|
+
// 1. NOT "two constants asserting about each other." Every claim is exercised by a REAL request
|
|
7
|
+
// against a throwaway root, and each expectation is a LITERAL written here — never a value
|
|
8
|
+
// imported from the handler it is certifying (that would be a tautology that could not catch
|
|
9
|
+
// the value drifting).
|
|
10
|
+
// 2. NOT "graded only on the router's own miss." Grading a probe as "not the 404 envelope" has
|
|
11
|
+
// teeth at the dispatch and nowhere deeper: a sub-handler that lost its body would still fall
|
|
12
|
+
// through to its own 4xx. So every probe asserts an expected STATUS SET **plus a predicate
|
|
13
|
+
// over the BODY** — the outcome a live handler produces.
|
|
14
|
+
//
|
|
15
|
+
// THREE DIRECTIONS, closed:
|
|
16
|
+
// • probe → claim : every probe names a claimed endpoint.
|
|
17
|
+
// • claim → probe : every claimed endpoint has a probe (a two-way bijection).
|
|
18
|
+
// • router → claim : every method/path pair the router branches on (`COHERE_ROUTER_SURFACE`) is
|
|
19
|
+
// exercised; anything that answers something OTHER than the vendor's
|
|
20
|
+
// not-found envelope without a matching claim is SERVED-BUT-UNCLAIMED
|
|
21
|
+
// surface and fails. Probe⇄claim alone is blind to that direction.
|
|
22
|
+
// HONEST LIMIT: `COHERE_ROUTER_SURFACE` is HAND-authored, so a branch added
|
|
23
|
+
// to the handler and to neither list is invisible here. The mechanical
|
|
24
|
+
// backstop for that lives in cohere-twin.test.ts ("covers every literal path
|
|
25
|
+
// the handler dispatches on"), which reads the dispatch source itself;
|
|
26
|
+
// segment-matched sub-routes still rest on review.
|
|
27
|
+
//
|
|
28
|
+
// Honest scope: this certifies the PROTOCOL ENVELOPE and the dispatch, NOT model output (a
|
|
29
|
+
// deterministic, clearly-labeled stub by design).
|
|
30
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
31
|
+
import { tmpdir } from 'node:os';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { COHERE_ROUTER_SURFACE, handleCohereTwinRequest, type CohereResponseEnvelope } from './cohere-twin.ts';
|
|
34
|
+
|
|
35
|
+
export type ConformanceViolation = { check: string; detail: string };
|
|
36
|
+
export type CohereConformanceReport = {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
checksRun: number;
|
|
39
|
+
claimed: number;
|
|
40
|
+
violations: ConformanceViolation[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type Body = Record<string, any>;
|
|
44
|
+
const isObj = (v: unknown): v is Body => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
45
|
+
|
|
46
|
+
/** The endpoints this twin CLAIMS to serve — hand-written, one line per claim. `{id}` stands for
|
|
47
|
+
* one path segment, matching `COHERE_ROUTER_SURFACE`'s notation. */
|
|
48
|
+
export const COHERE_TWIN_SNAPSHOT: ReadonlyArray<string> = [
|
|
49
|
+
'POST /v2/chat',
|
|
50
|
+
'POST /v2/embed',
|
|
51
|
+
'POST /v2/rerank',
|
|
52
|
+
'POST /v1/chat',
|
|
53
|
+
'POST /v1/embed',
|
|
54
|
+
'POST /v1/rerank',
|
|
55
|
+
'POST /v1/classify',
|
|
56
|
+
'POST /v1/tokenize',
|
|
57
|
+
'POST /v1/detokenize',
|
|
58
|
+
'POST /v1/check-api-key',
|
|
59
|
+
'GET /v1/models',
|
|
60
|
+
'GET /v1/models/{id}',
|
|
61
|
+
'POST /v1/datasets',
|
|
62
|
+
'GET /v1/datasets',
|
|
63
|
+
'GET /v1/datasets/usage',
|
|
64
|
+
'GET /v1/datasets/{id}',
|
|
65
|
+
'DELETE /v1/datasets/{id}',
|
|
66
|
+
'POST /v1/connectors',
|
|
67
|
+
'GET /v1/connectors',
|
|
68
|
+
'GET /v1/connectors/{id}',
|
|
69
|
+
'PATCH /v1/connectors/{id}',
|
|
70
|
+
'DELETE /v1/connectors/{id}',
|
|
71
|
+
'POST /v1/connectors/{id}/oauth/authorize',
|
|
72
|
+
'POST /v1/embed-jobs',
|
|
73
|
+
'GET /v1/embed-jobs',
|
|
74
|
+
'GET /v1/embed-jobs/{id}',
|
|
75
|
+
'POST /v1/embed-jobs/{id}/cancel',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** Live ids the seeding probes hand to the later ones. */
|
|
79
|
+
type Ctx = { dataset?: string; connector?: string; oauthConnector?: string; job?: string; tokens?: number[] };
|
|
80
|
+
|
|
81
|
+
type Probe = {
|
|
82
|
+
/** The claim this probe certifies — must appear verbatim in COHERE_TWIN_SNAPSHOT. */
|
|
83
|
+
claim: string;
|
|
84
|
+
/** Build the real request (may read ids seeded by earlier probes). */
|
|
85
|
+
request: (ctx: Ctx) => { method: string; path: string; body?: unknown };
|
|
86
|
+
/** The status codes a LIVE handler may answer with. */
|
|
87
|
+
status: number[];
|
|
88
|
+
/** The outcome predicate over the response body — the teeth below the dispatch. */
|
|
89
|
+
expect: (body: unknown, ctx: Ctx) => boolean;
|
|
90
|
+
/** Record an id for later probes. */
|
|
91
|
+
capture?: (body: unknown, ctx: Ctx) => void;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const CHAT = { model: 'command-a-03-2025', messages: [{ role: 'user', content: 'conformance probe' }] };
|
|
95
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The probe list, in SEEDING ORDER. A dataset is created before the embed job that references it
|
|
99
|
+
* and before the reads that expect exactly one row; the OAuth connector is created before the
|
|
100
|
+
* authorize probe. The order is the contract, not an accident of list position.
|
|
101
|
+
*/
|
|
102
|
+
const PROBES: Probe[] = [
|
|
103
|
+
{
|
|
104
|
+
claim: 'POST /v2/chat',
|
|
105
|
+
request: () => ({ method: 'POST', path: '/v2/chat', body: CHAT }),
|
|
106
|
+
status: [200],
|
|
107
|
+
expect: (b) => isObj(b) && UUID.test(String(b.id))
|
|
108
|
+
// NO `choices`, NO `object`, NO `created` — the three keys an OpenAI-shaped copy would add.
|
|
109
|
+
&& b.choices === undefined && b.object === undefined && b.created === undefined
|
|
110
|
+
&& b.finish_reason === 'COMPLETE'
|
|
111
|
+
&& b.message?.role === 'assistant'
|
|
112
|
+
&& b.message?.content?.[0]?.type === 'text'
|
|
113
|
+
&& String(b.message.content[0].text).includes('[twin-stub:command-a-03-2025]')
|
|
114
|
+
&& String(b.message.content[0].text).includes('conformance probe')
|
|
115
|
+
// Usage is TWO nested counters, both present, and they agree.
|
|
116
|
+
&& b.usage?.billed_units?.input_tokens > 0 && b.usage?.tokens?.input_tokens === b.usage.billed_units.input_tokens
|
|
117
|
+
&& b.usage?.tokens?.output_tokens > 0,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
claim: 'POST /v1/chat',
|
|
121
|
+
request: () => ({ method: 'POST', path: '/v1/chat', body: { message: 'v1 probe', model: 'command-r-08-2024' } }),
|
|
122
|
+
status: [200],
|
|
123
|
+
// v1 is a DIFFERENT envelope: flat `text`, `chat_history`, `meta` (not `usage`), no `message`.
|
|
124
|
+
expect: (b) => isObj(b) && typeof b.text === 'string' && b.text.includes('[twin-stub:command-r-08-2024]')
|
|
125
|
+
&& b.message === undefined && b.usage === undefined
|
|
126
|
+
&& UUID.test(String(b.generation_id)) && UUID.test(String(b.response_id))
|
|
127
|
+
&& b.finish_reason === 'COMPLETE'
|
|
128
|
+
&& Array.isArray(b.chat_history) && b.chat_history.length === 2
|
|
129
|
+
&& b.chat_history[0].role === 'USER' && b.chat_history[0].message === 'v1 probe'
|
|
130
|
+
&& b.chat_history[1].role === 'CHATBOT'
|
|
131
|
+
&& b.meta?.api_version?.version === '1' && b.meta?.billed_units?.input_tokens > 0,
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
claim: 'POST /v2/embed',
|
|
135
|
+
request: () => ({ method: 'POST', path: '/v2/embed', body: { model: 'embed-v4.0', input_type: 'search_query', texts: ['alpha', 'beta'], embedding_types: ['float', 'int8', 'base64'], output_dimension: 256 } }),
|
|
136
|
+
status: [200],
|
|
137
|
+
expect: (b) => isObj(b) && b.response_type === 'embeddings_by_type'
|
|
138
|
+
&& Array.isArray(b.embeddings?.float) && b.embeddings.float.length === 2 && b.embeddings.float[0].length === 256
|
|
139
|
+
&& Array.isArray(b.embeddings?.int8) && b.embeddings.int8[0].length === 256
|
|
140
|
+
&& typeof b.embeddings?.base64?.[0] === 'string'
|
|
141
|
+
// int8 really is a QUANTIZATION of the same vector, not a second unrelated one.
|
|
142
|
+
&& b.embeddings.int8[0][0] === Math.max(-128, Math.min(127, Math.round(b.embeddings.float[0][0] * 127)))
|
|
143
|
+
&& JSON.stringify(b.embeddings.float[0]) !== JSON.stringify(b.embeddings.float[1])
|
|
144
|
+
&& JSON.stringify(b.texts) === '["alpha","beta"]'
|
|
145
|
+
&& b.meta?.billed_units?.input_tokens > 0,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
claim: 'POST /v1/embed',
|
|
149
|
+
request: () => ({ method: 'POST', path: '/v1/embed', body: { model: 'embed-english-v3.0', input_type: 'search_query', texts: ['gamma'] } }),
|
|
150
|
+
status: [200],
|
|
151
|
+
// v1's DEFAULT is the FLAT shape — the union arm `embeddings_by_type` would put a caller in
|
|
152
|
+
// the wrong branch of cohere-ai's discriminated `EmbedResponse`.
|
|
153
|
+
expect: (b) => isObj(b) && b.response_type === 'embeddings_floats'
|
|
154
|
+
&& Array.isArray(b.embeddings) && Array.isArray(b.embeddings[0]) && b.embeddings[0].length === 1024,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
claim: 'POST /v2/rerank',
|
|
158
|
+
request: () => ({ method: 'POST', path: '/v2/rerank', body: { model: 'rerank-v3.5', query: 'apple pie recipe', documents: ['a recipe for apple pie', 'how to service a car engine', 'apple orchard tours'], top_n: 2 } }),
|
|
159
|
+
status: [200],
|
|
160
|
+
expect: (b) => isObj(b) && Array.isArray(b.results) && b.results.length === 2
|
|
161
|
+
&& b.results[0].index === 0 && b.results[0].relevance_score > b.results[1].relevance_score
|
|
162
|
+
// v2 rerank has NO `return_documents`, so no `document` rides on a result.
|
|
163
|
+
&& b.results[0].document === undefined
|
|
164
|
+
// Rerank is billed in SEARCH UNITS, not tokens.
|
|
165
|
+
&& b.meta?.billed_units?.search_units === 1 && b.meta?.billed_units?.input_tokens === undefined,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
claim: 'POST /v1/rerank',
|
|
169
|
+
request: () => ({ method: 'POST', path: '/v1/rerank', body: { query: 'apple pie', documents: [{ text: 'apple pie recipe' }, { text: 'engine oil' }], return_documents: true } }),
|
|
170
|
+
status: [200],
|
|
171
|
+
// v1 accepts OBJECT documents and honours `return_documents` — neither exists in v2.
|
|
172
|
+
expect: (b) => isObj(b) && Array.isArray(b.results) && b.results.length === 2
|
|
173
|
+
&& b.results[0].document?.text === 'apple pie recipe'
|
|
174
|
+
&& b.meta?.billed_units?.search_units === 1,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
claim: 'POST /v1/classify',
|
|
178
|
+
request: () => ({ method: 'POST', path: '/v1/classify', body: { inputs: ['a great day', 'a terrible day'], examples: [{ text: 'good', label: 'positive' }, { text: 'bad', label: 'negative' }] } }),
|
|
179
|
+
status: [200],
|
|
180
|
+
expect: (b) => isObj(b) && Array.isArray(b.classifications) && b.classifications.length === 2
|
|
181
|
+
&& b.classifications[0].input === 'a great day'
|
|
182
|
+
&& ['positive', 'negative'].includes(b.classifications[0].prediction)
|
|
183
|
+
&& Object.keys(b.classifications[0].labels).sort().join(',') === 'negative,positive'
|
|
184
|
+
// The label confidences are normalized — they sum to 1, like a real single-label result's.
|
|
185
|
+
&& Math.abs(Object.values(b.classifications[0].labels as Record<string, { confidence: number }>).reduce((a, l) => a + l.confidence, 0) - 1) < 1e-3
|
|
186
|
+
&& b.classifications[0].classification_type === 'single-label'
|
|
187
|
+
&& b.meta?.billed_units?.classifications === 2,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
claim: 'POST /v1/tokenize',
|
|
191
|
+
request: () => ({ method: 'POST', path: '/v1/tokenize', body: { text: 'hello brave new world', model: 'command-a-03-2025' } }),
|
|
192
|
+
status: [200],
|
|
193
|
+
expect: (b) => isObj(b) && Array.isArray(b.tokens) && Array.isArray(b.token_strings)
|
|
194
|
+
&& b.tokens.length === b.token_strings.length && b.tokens.length === 4
|
|
195
|
+
&& b.token_strings.join('') === 'hello brave new world'
|
|
196
|
+
&& b.tokens.every((t: unknown) => typeof t === 'number' && (t as number) > 0),
|
|
197
|
+
capture: (b, ctx) => { ctx.tokens = (b as Body).tokens; },
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
claim: 'POST /v1/detokenize',
|
|
201
|
+
request: (ctx) => ({ method: 'POST', path: '/v1/detokenize', body: { tokens: ctx.tokens, model: 'command-a-03-2025' } }),
|
|
202
|
+
status: [200],
|
|
203
|
+
// The round trip is the whole property: the twin's vocabulary was LEARNED by the probe above.
|
|
204
|
+
expect: (b) => isObj(b) && b.text === 'hello brave new world',
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
claim: 'POST /v1/check-api-key',
|
|
208
|
+
request: () => ({ method: 'POST', path: '/v1/check-api-key' }),
|
|
209
|
+
status: [200],
|
|
210
|
+
expect: (b) => isObj(b) && b.valid === true && typeof b.organization_id === 'string' && typeof b.owner_id === 'string',
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
claim: 'GET /v1/models',
|
|
214
|
+
request: () => ({ method: 'GET', path: '/v1/models?endpoint=rerank' }),
|
|
215
|
+
status: [200],
|
|
216
|
+
expect: (b) => isObj(b) && Array.isArray(b.models) && b.next_page_token === null
|
|
217
|
+
&& b.models.length >= 3
|
|
218
|
+
&& b.models.every((m: Body) => Array.isArray(m.endpoints) && m.endpoints.includes('rerank'))
|
|
219
|
+
&& b.models.some((m: Body) => m.name === 'rerank-v3.5' && m.context_length === 4000),
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
claim: 'GET /v1/models/{id}',
|
|
223
|
+
request: () => ({ method: 'GET', path: '/v1/models/embed-v4.0' }),
|
|
224
|
+
status: [200],
|
|
225
|
+
expect: (b) => isObj(b) && b.name === 'embed-v4.0' && b.context_length === 128000
|
|
226
|
+
&& JSON.stringify(b.endpoints) === '["embed"]' && b.finetuned === false
|
|
227
|
+
&& String(b.tokenizer_url).startsWith('https://'),
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
claim: 'POST /v1/datasets',
|
|
231
|
+
request: () => ({ method: 'POST', path: '/v1/datasets?name=conf-ds&type=embed-input', body: { content: '{"text":"a"}' } }),
|
|
232
|
+
status: [200],
|
|
233
|
+
// Cohere's create answers ONLY `{ id }` — not the dataset object.
|
|
234
|
+
expect: (b) => isObj(b) && UUID.test(String(b.id)) && Object.keys(b).length === 1,
|
|
235
|
+
capture: (b, ctx) => { ctx.dataset = (b as Body).id; },
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
claim: 'GET /v1/datasets',
|
|
239
|
+
request: () => ({ method: 'GET', path: '/v1/datasets?datasetType=embed-input' }),
|
|
240
|
+
status: [200],
|
|
241
|
+
expect: (b, ctx) => isObj(b) && Array.isArray(b.datasets) && b.datasets.length === 1
|
|
242
|
+
&& b.datasets[0].id === ctx.dataset && b.datasets[0].name === 'conf-ds',
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
claim: 'GET /v1/datasets/usage',
|
|
246
|
+
request: () => ({ method: 'GET', path: '/v1/datasets/usage' }),
|
|
247
|
+
status: [200],
|
|
248
|
+
// Real bytes, folded from the stored content — a constant would not move with the seed.
|
|
249
|
+
expect: (b) => isObj(b) && b.organization_usage === 12,
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
claim: 'GET /v1/datasets/{id}',
|
|
253
|
+
request: (ctx) => ({ method: 'GET', path: `/v1/datasets/${ctx.dataset}` }),
|
|
254
|
+
status: [200],
|
|
255
|
+
// The dataset is NESTED under `dataset` — `DatasetsGetResponse` is `{ dataset }`.
|
|
256
|
+
expect: (b, ctx) => isObj(b) && b.dataset?.id === ctx.dataset && b.dataset?.dataset_type === 'embed-input'
|
|
257
|
+
&& b.dataset?.validation_status === 'validated' && typeof b.dataset?.created_at === 'string'
|
|
258
|
+
&& b.id === undefined,
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
claim: 'POST /v1/connectors',
|
|
262
|
+
request: () => ({ method: 'POST', path: '/v1/connectors', body: { name: 'conf-conn', url: 'https://example.test/search', excludes: ['x'] } }),
|
|
263
|
+
status: [200],
|
|
264
|
+
// NESTED under `connector` — `CreateConnectorResponse` is `{ connector }`.
|
|
265
|
+
expect: (b) => isObj(b) && b.connector?.name === 'conf-conn' && b.connector?.url === 'https://example.test/search'
|
|
266
|
+
&& b.connector?.auth_type === 'none' && b.connector?.auth_status === 'valid'
|
|
267
|
+
&& b.connector?.active === true && UUID.test(String(b.connector?.id)),
|
|
268
|
+
capture: (b, ctx) => { ctx.connector = (b as Body).connector.id; },
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
claim: 'GET /v1/connectors',
|
|
272
|
+
request: () => ({ method: 'GET', path: '/v1/connectors' }),
|
|
273
|
+
status: [200],
|
|
274
|
+
expect: (b, ctx) => isObj(b) && Array.isArray(b.connectors) && b.total_count === b.connectors.length
|
|
275
|
+
&& b.connectors.some((c: Body) => c.id === ctx.connector),
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
claim: 'GET /v1/connectors/{id}',
|
|
279
|
+
request: (ctx) => ({ method: 'GET', path: `/v1/connectors/${ctx.connector}` }),
|
|
280
|
+
status: [200],
|
|
281
|
+
expect: (b, ctx) => isObj(b) && b.connector?.id === ctx.connector && JSON.stringify(b.connector?.excludes) === '["x"]',
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
claim: 'PATCH /v1/connectors/{id}',
|
|
285
|
+
request: (ctx) => ({ method: 'PATCH', path: `/v1/connectors/${ctx.connector}`, body: { name: 'conf-renamed', active: false } }),
|
|
286
|
+
status: [200],
|
|
287
|
+
// The patch really lands AND the untouched field survives — a whole-row replace would lose it.
|
|
288
|
+
expect: (b, ctx) => isObj(b) && b.connector?.id === ctx.connector && b.connector?.name === 'conf-renamed'
|
|
289
|
+
&& b.connector?.active === false && b.connector?.url === 'https://example.test/search',
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
claim: 'POST /v1/connectors/{id}/oauth/authorize',
|
|
293
|
+
request: (ctx) => ({ method: 'POST', path: `/v1/connectors/${ctx.oauthConnector}/oauth/authorize?after_token_redirect=https://app.test/done` }),
|
|
294
|
+
status: [200],
|
|
295
|
+
expect: (b) => isObj(b) && typeof b.redirect_url === 'string'
|
|
296
|
+
&& b.redirect_url.startsWith('https://auth.example.test/authorize?')
|
|
297
|
+
&& b.redirect_url.includes('client_id=cid-1')
|
|
298
|
+
&& b.redirect_url.includes('after_token_redirect=https%3A%2F%2Fapp.test%2Fdone'),
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
claim: 'POST /v1/embed-jobs',
|
|
302
|
+
request: (ctx) => ({ method: 'POST', path: '/v1/embed-jobs', body: { model: 'embed-english-v3.0', dataset_id: ctx.dataset, input_type: 'search_document', name: 'conf-job' } }),
|
|
303
|
+
status: [200],
|
|
304
|
+
// The create answers `{ job_id, meta }` — a different key from every other create here.
|
|
305
|
+
expect: (b) => isObj(b) && UUID.test(String(b.job_id)) && b.id === undefined && isObj(b.meta),
|
|
306
|
+
capture: (b, ctx) => { ctx.job = (b as Body).job_id; },
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
claim: 'GET /v1/embed-jobs',
|
|
310
|
+
request: () => ({ method: 'GET', path: '/v1/embed-jobs' }),
|
|
311
|
+
status: [200],
|
|
312
|
+
expect: (b, ctx) => isObj(b) && Array.isArray(b.embed_jobs) && b.embed_jobs.length === 1
|
|
313
|
+
&& b.embed_jobs[0].job_id === ctx.job,
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
claim: 'GET /v1/embed-jobs/{id}',
|
|
317
|
+
request: (ctx) => ({ method: 'GET', path: `/v1/embed-jobs/${ctx.job}` }),
|
|
318
|
+
status: [200],
|
|
319
|
+
expect: (b, ctx) => isObj(b) && b.job_id === ctx.job && b.status === 'processing'
|
|
320
|
+
&& b.input_dataset_id === ctx.dataset && b.model === 'embed-english-v3.0'
|
|
321
|
+
&& b.truncate === 'END' && b.name === 'conf-job'
|
|
322
|
+
// The vendor's EmbedJob schema has NO `id` key; emitting one would be invented surface.
|
|
323
|
+
&& b.id === undefined,
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
claim: 'POST /v1/embed-jobs/{id}/cancel',
|
|
327
|
+
request: (ctx) => ({ method: 'POST', path: `/v1/embed-jobs/${ctx.job}/cancel` }),
|
|
328
|
+
status: [200],
|
|
329
|
+
// `embedJobs.cancel` is declared `-> void`: an empty body, and the STATE moved.
|
|
330
|
+
expect: (b) => isObj(b) && Object.keys(b).length === 0,
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
claim: 'DELETE /v1/connectors/{id}',
|
|
334
|
+
request: (ctx) => ({ method: 'DELETE', path: `/v1/connectors/${ctx.connector}` }),
|
|
335
|
+
status: [200],
|
|
336
|
+
expect: (b) => isObj(b) && Object.keys(b).length === 0,
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
claim: 'DELETE /v1/datasets/{id}',
|
|
340
|
+
request: (ctx) => ({ method: 'DELETE', path: `/v1/datasets/${ctx.dataset}` }),
|
|
341
|
+
status: [200],
|
|
342
|
+
// Cohere's delete answers an EMPTY object, not the `{deleted:true}` envelope most vendors send.
|
|
343
|
+
expect: (b) => isObj(b) && Object.keys(b).length === 0,
|
|
344
|
+
},
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
/** The vendor not-found envelope this twin answers unmodeled surface with — written as a LITERAL
|
|
348
|
+
* predicate here, never imported from the handler that produces it. Cohere's error body has
|
|
349
|
+
* EXACTLY ONE key. */
|
|
350
|
+
function isNotFoundEnvelope(status: number, body: unknown): boolean {
|
|
351
|
+
return status === 404 && isObj(body) && typeof body.message === 'string' && Object.keys(body).length === 1;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** A concrete request for a router-census entry — `{id}` becomes a deliberately nonexistent id, so
|
|
355
|
+
* a LIVE branch answers its own 404 while a MISSING branch answers the router's 404. Both are
|
|
356
|
+
* not-found envelopes, which is exactly why this direction only asks "is anything OTHER than a
|
|
357
|
+
* not-found envelope served here, unclaimed?" */
|
|
358
|
+
function censusRequest(entry: { method: string; path: string }): { method: string; path: string; body?: unknown } {
|
|
359
|
+
const path = entry.path.replace('{id}', 'zzz-nonexistent-zzz');
|
|
360
|
+
return { method: entry.method, path, body: entry.method === 'GET' || entry.method === 'DELETE' ? undefined : {} };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Run the offline conformance checks against a fresh temp root. */
|
|
364
|
+
export async function checkCohereConformance(opts: { root?: string } = {}): Promise<CohereConformanceReport> {
|
|
365
|
+
const root = opts.root ?? mkdtempSync(join(tmpdir(), 'cohere-conf-'));
|
|
366
|
+
const owned = opts.root === undefined;
|
|
367
|
+
const violations: ConformanceViolation[] = [];
|
|
368
|
+
let checksRun = 0;
|
|
369
|
+
const fail = (check: string, detail: string) => violations.push({ check, detail });
|
|
370
|
+
const H = (r: { method: string; path: string; body?: unknown }, at = root): Promise<CohereResponseEnvelope> =>
|
|
371
|
+
handleCohereTwinRequest({ method: r.method, path: r.path, root: at, ...(r.body === undefined ? {} : { body: JSON.stringify(r.body) }) });
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
// ── DIRECTION 1+2: probe ⇄ claim, a two-way bijection ──────────────────────────────
|
|
375
|
+
const claims = new Set(COHERE_TWIN_SNAPSHOT);
|
|
376
|
+
const probed = new Set(PROBES.map((p) => p.claim));
|
|
377
|
+
for (const p of PROBES) if (!claims.has(p.claim)) fail('probe.unclaimed', `probe certifies "${p.claim}", which is not in COHERE_TWIN_SNAPSHOT`);
|
|
378
|
+
for (const c of COHERE_TWIN_SNAPSHOT) if (!probed.has(c)) fail('claim.unprobed', `claimed endpoint "${c}" has no probe — an unexercised claim certifies nothing`);
|
|
379
|
+
if (probed.size !== PROBES.length) fail('probe.duplicate', 'two probes certify the same claim');
|
|
380
|
+
|
|
381
|
+
const ctx: Ctx = {};
|
|
382
|
+
// The oauth-authorize probe needs a connector that HAS an oauth configuration, which the
|
|
383
|
+
// ordinary `POST /v1/connectors` probe deliberately does not create (its own claim is the
|
|
384
|
+
// no-auth shape). Seeding it here keeps each probe's assertions about one thing.
|
|
385
|
+
const oauthCreate = await H({ method: 'POST', path: '/v1/connectors', body: { name: 'conf-oauth', url: 'https://example.test/oauth', oauth: { client_id: 'cid-1', client_secret: 's', authorize_url: 'https://auth.example.test/authorize', token_url: 'https://auth.example.test/token' } } });
|
|
386
|
+
ctx.oauthConnector = ((oauthCreate.body as Body)?.connector?.id) as string | undefined;
|
|
387
|
+
if (ctx.oauthConnector === undefined) fail('seed.oauth_connector', `seeding an oauth connector failed: ${oauthCreate.status} ${JSON.stringify(oauthCreate.body).slice(0, 200)}`);
|
|
388
|
+
|
|
389
|
+
for (const p of PROBES) {
|
|
390
|
+
checksRun++;
|
|
391
|
+
const res = await H(p.request(ctx));
|
|
392
|
+
if (!p.status.includes(res.status)) {
|
|
393
|
+
fail('probe.status', `${p.claim}: status ${res.status} (expected one of ${p.status.join(', ')}) — body ${JSON.stringify(res.body).slice(0, 200)}`);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
let outcomeOk: boolean;
|
|
397
|
+
try { outcomeOk = p.expect(res.body, ctx); } catch (e) { outcomeOk = false; void e; }
|
|
398
|
+
if (!outcomeOk) {
|
|
399
|
+
fail('probe.outcome', `${p.claim}: status was right but the body is not what a live handler produces — ${JSON.stringify(res.body).slice(0, 300)}`);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
p.capture?.(res.body, ctx);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// The deletes above must have actually removed the rows — a delete that answered `{}` while
|
|
406
|
+
// leaving the row standing would satisfy its own probe. This reads the state AFTER.
|
|
407
|
+
checksRun++;
|
|
408
|
+
const afterDelete = await H({ method: 'GET', path: '/v1/datasets' });
|
|
409
|
+
if (JSON.stringify((afterDelete.body as Body)?.datasets) !== '[]') {
|
|
410
|
+
fail('delete.effective', `DELETE /v1/datasets/{id} answered 200 but the row is still listed: ${JSON.stringify(afterDelete.body).slice(0, 200)}`);
|
|
411
|
+
}
|
|
412
|
+
checksRun++;
|
|
413
|
+
// The three `{}`-bodied claims (both deletes and the cancel) are each satisfied by a handler
|
|
414
|
+
// that answered `{status:200, body:{}}` with its `applyTwinWrite` deleted, so each needs a
|
|
415
|
+
// post-hoc re-read. The connectors one was missing (§9 round 1, finding 3) — and its probe runs
|
|
416
|
+
// AFTER the list probe, so nothing else re-read connectors either.
|
|
417
|
+
const afterConnectorDelete = await H({ method: 'GET', path: '/v1/connectors' });
|
|
418
|
+
const remaining = (afterConnectorDelete.body as Body)?.connectors as Body[] | undefined;
|
|
419
|
+
// The oauth-seeded connector is the only one that must survive; the probed one must be gone.
|
|
420
|
+
if (!Array.isArray(remaining) || remaining.some((c) => c.id === ctx.connector)) {
|
|
421
|
+
fail('connector_delete.effective', `DELETE /v1/connectors/{id} answered 200 but the row is still listed: ${JSON.stringify(afterConnectorDelete.body).slice(0, 200)}`);
|
|
422
|
+
}
|
|
423
|
+
if ((afterConnectorDelete.body as Body)?.total_count !== remaining?.length) {
|
|
424
|
+
fail('connector_delete.effective', `total_count (${String((afterConnectorDelete.body as Body)?.total_count)}) disagrees with the listed rows after a delete`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
checksRun++;
|
|
428
|
+
const jobAfterCancel = await H({ method: 'GET', path: `/v1/embed-jobs/${ctx.job}` });
|
|
429
|
+
if ((jobAfterCancel.body as Body)?.status !== 'cancelling') {
|
|
430
|
+
fail('cancel.effective', `POST /v1/embed-jobs/{id}/cancel answered 200 but the status did not move: ${JSON.stringify(jobAfterCancel.body).slice(0, 200)}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ── DIRECTION 3: router → claim (served-but-unclaimed surface) ─────────────────────
|
|
434
|
+
const censusRoot = mkdtempSync(join(tmpdir(), 'cohere-conf-census-'));
|
|
435
|
+
try {
|
|
436
|
+
for (const entry of COHERE_ROUTER_SURFACE) {
|
|
437
|
+
checksRun++;
|
|
438
|
+
const key = `${entry.method} ${entry.path}`;
|
|
439
|
+
const res = await H(censusRequest(entry), censusRoot);
|
|
440
|
+
const inert = isNotFoundEnvelope(res.status, res.body);
|
|
441
|
+
if (!inert && !claims.has(key)) {
|
|
442
|
+
fail('router.unclaimed', `${key} serves a response (${res.status}) but COHERE_TWIN_SNAPSHOT never claims it`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
// …and the other way: a claim the router census has never heard of is a claim about a
|
|
446
|
+
// branch that does not exist.
|
|
447
|
+
const census = new Set(COHERE_ROUTER_SURFACE.map((e) => `${e.method} ${e.path}`));
|
|
448
|
+
for (const c of COHERE_TWIN_SNAPSHOT) if (!census.has(c)) fail('claim.unrouted', `claimed endpoint "${c}" is not a branch in COHERE_ROUTER_SURFACE`);
|
|
449
|
+
} finally {
|
|
450
|
+
rmSync(censusRoot, { recursive: true, force: true });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── The vendor's error envelopes, asserted as LITERALS ─────────────────────────────
|
|
454
|
+
checksRun++;
|
|
455
|
+
const badBody = await H({ method: 'POST', path: '/v2/chat', body: { model: 'command-a-03-2025' } });
|
|
456
|
+
const bb = badBody.body as Body;
|
|
457
|
+
// ONE key. Asserting the ABSENCE of `error`/`type`/`code` is what stops the envelope drifting
|
|
458
|
+
// toward the OpenAI shape, which no positive assertion would catch.
|
|
459
|
+
if (badBody.status !== 400 || !isObj(bb) || Object.keys(bb).join(',') !== 'message'
|
|
460
|
+
|| bb.message !== 'invalid request: messages is required') {
|
|
461
|
+
fail('error.invalid_request', `a v2 chat with no messages did not yield Cohere's bare {message} 400 — got ${badBody.status} ${JSON.stringify(badBody.body).slice(0, 300)}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
checksRun++;
|
|
465
|
+
const badModel = await H({ method: 'POST', path: '/v2/chat', body: { model: 'gpt-4o', messages: [{ role: 'user', content: 'x' }] } });
|
|
466
|
+
const bm = badModel.body as Body;
|
|
467
|
+
if (badModel.status !== 404 || bm?.message !== "model 'gpt-4o' not found, make sure the correct model ID was used and that you have access to the model.") {
|
|
468
|
+
fail('error.model_not_found', `an unknown model did not yield the live 404 envelope — got ${badModel.status} ${JSON.stringify(badModel.body).slice(0, 300)}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
checksRun++;
|
|
472
|
+
const nf = await H({ method: 'GET', path: '/v1/nonexistent' });
|
|
473
|
+
if (!isNotFoundEnvelope(nf.status, nf.body)) fail('error.not_found', `an unknown route did not answer the 404 error envelope — got ${nf.status}`);
|
|
474
|
+
|
|
475
|
+
checksRun++;
|
|
476
|
+
const unauthorized = await handleCohereTwinRequest({ method: 'GET', path: '/v1/models', root, headers: {} });
|
|
477
|
+
const ub = unauthorized.body as Body;
|
|
478
|
+
if (unauthorized.status !== 401 || Object.keys(ub ?? {}).join(',') !== 'message') {
|
|
479
|
+
fail('error.unauthorized', `a request with no bearer token did not 401 with the bare {message} envelope — got ${unauthorized.status}`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
checksRun++;
|
|
483
|
+
const readOnly = await handleCohereTwinRequest({ method: 'POST', path: '/v2/chat', body: JSON.stringify(CHAT), root, readOnly: true });
|
|
484
|
+
if (readOnly.status !== 405) fail('error.read_only', `a read-only twin did not refuse a write with 405 — got ${readOnly.status}`);
|
|
485
|
+
} finally {
|
|
486
|
+
if (owned) rmSync(root, { recursive: true, force: true });
|
|
487
|
+
}
|
|
488
|
+
return { ok: violations.length === 0, checksRun, claimed: COHERE_TWIN_SNAPSHOT.length, violations };
|
|
489
|
+
}
|