@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,17 @@
|
|
|
1
|
+
export type ConformanceViolation = {
|
|
2
|
+
check: string;
|
|
3
|
+
detail: string;
|
|
4
|
+
};
|
|
5
|
+
export type CohereConformanceReport = {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
checksRun: number;
|
|
8
|
+
claimed: number;
|
|
9
|
+
violations: ConformanceViolation[];
|
|
10
|
+
};
|
|
11
|
+
/** The endpoints this twin CLAIMS to serve — hand-written, one line per claim. `{id}` stands for
|
|
12
|
+
* one path segment, matching `COHERE_ROUTER_SURFACE`'s notation. */
|
|
13
|
+
export declare const COHERE_TWIN_SNAPSHOT: ReadonlyArray<string>;
|
|
14
|
+
/** Run the offline conformance checks against a fresh temp root. */
|
|
15
|
+
export declare function checkCohereConformance(opts?: {
|
|
16
|
+
root?: string;
|
|
17
|
+
}): Promise<CohereConformanceReport>;
|
|
@@ -0,0 +1,464 @@
|
|
|
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 } from "./cohere-twin.js";
|
|
34
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
35
|
+
/** The endpoints this twin CLAIMS to serve — hand-written, one line per claim. `{id}` stands for
|
|
36
|
+
* one path segment, matching `COHERE_ROUTER_SURFACE`'s notation. */
|
|
37
|
+
export const COHERE_TWIN_SNAPSHOT = [
|
|
38
|
+
'POST /v2/chat',
|
|
39
|
+
'POST /v2/embed',
|
|
40
|
+
'POST /v2/rerank',
|
|
41
|
+
'POST /v1/chat',
|
|
42
|
+
'POST /v1/embed',
|
|
43
|
+
'POST /v1/rerank',
|
|
44
|
+
'POST /v1/classify',
|
|
45
|
+
'POST /v1/tokenize',
|
|
46
|
+
'POST /v1/detokenize',
|
|
47
|
+
'POST /v1/check-api-key',
|
|
48
|
+
'GET /v1/models',
|
|
49
|
+
'GET /v1/models/{id}',
|
|
50
|
+
'POST /v1/datasets',
|
|
51
|
+
'GET /v1/datasets',
|
|
52
|
+
'GET /v1/datasets/usage',
|
|
53
|
+
'GET /v1/datasets/{id}',
|
|
54
|
+
'DELETE /v1/datasets/{id}',
|
|
55
|
+
'POST /v1/connectors',
|
|
56
|
+
'GET /v1/connectors',
|
|
57
|
+
'GET /v1/connectors/{id}',
|
|
58
|
+
'PATCH /v1/connectors/{id}',
|
|
59
|
+
'DELETE /v1/connectors/{id}',
|
|
60
|
+
'POST /v1/connectors/{id}/oauth/authorize',
|
|
61
|
+
'POST /v1/embed-jobs',
|
|
62
|
+
'GET /v1/embed-jobs',
|
|
63
|
+
'GET /v1/embed-jobs/{id}',
|
|
64
|
+
'POST /v1/embed-jobs/{id}/cancel',
|
|
65
|
+
];
|
|
66
|
+
const CHAT = { model: 'command-a-03-2025', messages: [{ role: 'user', content: 'conformance probe' }] };
|
|
67
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
68
|
+
/**
|
|
69
|
+
* The probe list, in SEEDING ORDER. A dataset is created before the embed job that references it
|
|
70
|
+
* and before the reads that expect exactly one row; the OAuth connector is created before the
|
|
71
|
+
* authorize probe. The order is the contract, not an accident of list position.
|
|
72
|
+
*/
|
|
73
|
+
const PROBES = [
|
|
74
|
+
{
|
|
75
|
+
claim: 'POST /v2/chat',
|
|
76
|
+
request: () => ({ method: 'POST', path: '/v2/chat', body: CHAT }),
|
|
77
|
+
status: [200],
|
|
78
|
+
expect: (b) => isObj(b) && UUID.test(String(b.id))
|
|
79
|
+
// NO `choices`, NO `object`, NO `created` — the three keys an OpenAI-shaped copy would add.
|
|
80
|
+
&& b.choices === undefined && b.object === undefined && b.created === undefined
|
|
81
|
+
&& b.finish_reason === 'COMPLETE'
|
|
82
|
+
&& b.message?.role === 'assistant'
|
|
83
|
+
&& b.message?.content?.[0]?.type === 'text'
|
|
84
|
+
&& String(b.message.content[0].text).includes('[twin-stub:command-a-03-2025]')
|
|
85
|
+
&& String(b.message.content[0].text).includes('conformance probe')
|
|
86
|
+
// Usage is TWO nested counters, both present, and they agree.
|
|
87
|
+
&& b.usage?.billed_units?.input_tokens > 0 && b.usage?.tokens?.input_tokens === b.usage.billed_units.input_tokens
|
|
88
|
+
&& b.usage?.tokens?.output_tokens > 0,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
claim: 'POST /v1/chat',
|
|
92
|
+
request: () => ({ method: 'POST', path: '/v1/chat', body: { message: 'v1 probe', model: 'command-r-08-2024' } }),
|
|
93
|
+
status: [200],
|
|
94
|
+
// v1 is a DIFFERENT envelope: flat `text`, `chat_history`, `meta` (not `usage`), no `message`.
|
|
95
|
+
expect: (b) => isObj(b) && typeof b.text === 'string' && b.text.includes('[twin-stub:command-r-08-2024]')
|
|
96
|
+
&& b.message === undefined && b.usage === undefined
|
|
97
|
+
&& UUID.test(String(b.generation_id)) && UUID.test(String(b.response_id))
|
|
98
|
+
&& b.finish_reason === 'COMPLETE'
|
|
99
|
+
&& Array.isArray(b.chat_history) && b.chat_history.length === 2
|
|
100
|
+
&& b.chat_history[0].role === 'USER' && b.chat_history[0].message === 'v1 probe'
|
|
101
|
+
&& b.chat_history[1].role === 'CHATBOT'
|
|
102
|
+
&& b.meta?.api_version?.version === '1' && b.meta?.billed_units?.input_tokens > 0,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
claim: 'POST /v2/embed',
|
|
106
|
+
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 } }),
|
|
107
|
+
status: [200],
|
|
108
|
+
expect: (b) => isObj(b) && b.response_type === 'embeddings_by_type'
|
|
109
|
+
&& Array.isArray(b.embeddings?.float) && b.embeddings.float.length === 2 && b.embeddings.float[0].length === 256
|
|
110
|
+
&& Array.isArray(b.embeddings?.int8) && b.embeddings.int8[0].length === 256
|
|
111
|
+
&& typeof b.embeddings?.base64?.[0] === 'string'
|
|
112
|
+
// int8 really is a QUANTIZATION of the same vector, not a second unrelated one.
|
|
113
|
+
&& b.embeddings.int8[0][0] === Math.max(-128, Math.min(127, Math.round(b.embeddings.float[0][0] * 127)))
|
|
114
|
+
&& JSON.stringify(b.embeddings.float[0]) !== JSON.stringify(b.embeddings.float[1])
|
|
115
|
+
&& JSON.stringify(b.texts) === '["alpha","beta"]'
|
|
116
|
+
&& b.meta?.billed_units?.input_tokens > 0,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
claim: 'POST /v1/embed',
|
|
120
|
+
request: () => ({ method: 'POST', path: '/v1/embed', body: { model: 'embed-english-v3.0', input_type: 'search_query', texts: ['gamma'] } }),
|
|
121
|
+
status: [200],
|
|
122
|
+
// v1's DEFAULT is the FLAT shape — the union arm `embeddings_by_type` would put a caller in
|
|
123
|
+
// the wrong branch of cohere-ai's discriminated `EmbedResponse`.
|
|
124
|
+
expect: (b) => isObj(b) && b.response_type === 'embeddings_floats'
|
|
125
|
+
&& Array.isArray(b.embeddings) && Array.isArray(b.embeddings[0]) && b.embeddings[0].length === 1024,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
claim: 'POST /v2/rerank',
|
|
129
|
+
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 } }),
|
|
130
|
+
status: [200],
|
|
131
|
+
expect: (b) => isObj(b) && Array.isArray(b.results) && b.results.length === 2
|
|
132
|
+
&& b.results[0].index === 0 && b.results[0].relevance_score > b.results[1].relevance_score
|
|
133
|
+
// v2 rerank has NO `return_documents`, so no `document` rides on a result.
|
|
134
|
+
&& b.results[0].document === undefined
|
|
135
|
+
// Rerank is billed in SEARCH UNITS, not tokens.
|
|
136
|
+
&& b.meta?.billed_units?.search_units === 1 && b.meta?.billed_units?.input_tokens === undefined,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
claim: 'POST /v1/rerank',
|
|
140
|
+
request: () => ({ method: 'POST', path: '/v1/rerank', body: { query: 'apple pie', documents: [{ text: 'apple pie recipe' }, { text: 'engine oil' }], return_documents: true } }),
|
|
141
|
+
status: [200],
|
|
142
|
+
// v1 accepts OBJECT documents and honours `return_documents` — neither exists in v2.
|
|
143
|
+
expect: (b) => isObj(b) && Array.isArray(b.results) && b.results.length === 2
|
|
144
|
+
&& b.results[0].document?.text === 'apple pie recipe'
|
|
145
|
+
&& b.meta?.billed_units?.search_units === 1,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
claim: 'POST /v1/classify',
|
|
149
|
+
request: () => ({ method: 'POST', path: '/v1/classify', body: { inputs: ['a great day', 'a terrible day'], examples: [{ text: 'good', label: 'positive' }, { text: 'bad', label: 'negative' }] } }),
|
|
150
|
+
status: [200],
|
|
151
|
+
expect: (b) => isObj(b) && Array.isArray(b.classifications) && b.classifications.length === 2
|
|
152
|
+
&& b.classifications[0].input === 'a great day'
|
|
153
|
+
&& ['positive', 'negative'].includes(b.classifications[0].prediction)
|
|
154
|
+
&& Object.keys(b.classifications[0].labels).sort().join(',') === 'negative,positive'
|
|
155
|
+
// The label confidences are normalized — they sum to 1, like a real single-label result's.
|
|
156
|
+
&& Math.abs(Object.values(b.classifications[0].labels).reduce((a, l) => a + l.confidence, 0) - 1) < 1e-3
|
|
157
|
+
&& b.classifications[0].classification_type === 'single-label'
|
|
158
|
+
&& b.meta?.billed_units?.classifications === 2,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
claim: 'POST /v1/tokenize',
|
|
162
|
+
request: () => ({ method: 'POST', path: '/v1/tokenize', body: { text: 'hello brave new world', model: 'command-a-03-2025' } }),
|
|
163
|
+
status: [200],
|
|
164
|
+
expect: (b) => isObj(b) && Array.isArray(b.tokens) && Array.isArray(b.token_strings)
|
|
165
|
+
&& b.tokens.length === b.token_strings.length && b.tokens.length === 4
|
|
166
|
+
&& b.token_strings.join('') === 'hello brave new world'
|
|
167
|
+
&& b.tokens.every((t) => typeof t === 'number' && t > 0),
|
|
168
|
+
capture: (b, ctx) => { ctx.tokens = b.tokens; },
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
claim: 'POST /v1/detokenize',
|
|
172
|
+
request: (ctx) => ({ method: 'POST', path: '/v1/detokenize', body: { tokens: ctx.tokens, model: 'command-a-03-2025' } }),
|
|
173
|
+
status: [200],
|
|
174
|
+
// The round trip is the whole property: the twin's vocabulary was LEARNED by the probe above.
|
|
175
|
+
expect: (b) => isObj(b) && b.text === 'hello brave new world',
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
claim: 'POST /v1/check-api-key',
|
|
179
|
+
request: () => ({ method: 'POST', path: '/v1/check-api-key' }),
|
|
180
|
+
status: [200],
|
|
181
|
+
expect: (b) => isObj(b) && b.valid === true && typeof b.organization_id === 'string' && typeof b.owner_id === 'string',
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
claim: 'GET /v1/models',
|
|
185
|
+
request: () => ({ method: 'GET', path: '/v1/models?endpoint=rerank' }),
|
|
186
|
+
status: [200],
|
|
187
|
+
expect: (b) => isObj(b) && Array.isArray(b.models) && b.next_page_token === null
|
|
188
|
+
&& b.models.length >= 3
|
|
189
|
+
&& b.models.every((m) => Array.isArray(m.endpoints) && m.endpoints.includes('rerank'))
|
|
190
|
+
&& b.models.some((m) => m.name === 'rerank-v3.5' && m.context_length === 4000),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
claim: 'GET /v1/models/{id}',
|
|
194
|
+
request: () => ({ method: 'GET', path: '/v1/models/embed-v4.0' }),
|
|
195
|
+
status: [200],
|
|
196
|
+
expect: (b) => isObj(b) && b.name === 'embed-v4.0' && b.context_length === 128000
|
|
197
|
+
&& JSON.stringify(b.endpoints) === '["embed"]' && b.finetuned === false
|
|
198
|
+
&& String(b.tokenizer_url).startsWith('https://'),
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
claim: 'POST /v1/datasets',
|
|
202
|
+
request: () => ({ method: 'POST', path: '/v1/datasets?name=conf-ds&type=embed-input', body: { content: '{"text":"a"}' } }),
|
|
203
|
+
status: [200],
|
|
204
|
+
// Cohere's create answers ONLY `{ id }` — not the dataset object.
|
|
205
|
+
expect: (b) => isObj(b) && UUID.test(String(b.id)) && Object.keys(b).length === 1,
|
|
206
|
+
capture: (b, ctx) => { ctx.dataset = b.id; },
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
claim: 'GET /v1/datasets',
|
|
210
|
+
request: () => ({ method: 'GET', path: '/v1/datasets?datasetType=embed-input' }),
|
|
211
|
+
status: [200],
|
|
212
|
+
expect: (b, ctx) => isObj(b) && Array.isArray(b.datasets) && b.datasets.length === 1
|
|
213
|
+
&& b.datasets[0].id === ctx.dataset && b.datasets[0].name === 'conf-ds',
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
claim: 'GET /v1/datasets/usage',
|
|
217
|
+
request: () => ({ method: 'GET', path: '/v1/datasets/usage' }),
|
|
218
|
+
status: [200],
|
|
219
|
+
// Real bytes, folded from the stored content — a constant would not move with the seed.
|
|
220
|
+
expect: (b) => isObj(b) && b.organization_usage === 12,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
claim: 'GET /v1/datasets/{id}',
|
|
224
|
+
request: (ctx) => ({ method: 'GET', path: `/v1/datasets/${ctx.dataset}` }),
|
|
225
|
+
status: [200],
|
|
226
|
+
// The dataset is NESTED under `dataset` — `DatasetsGetResponse` is `{ dataset }`.
|
|
227
|
+
expect: (b, ctx) => isObj(b) && b.dataset?.id === ctx.dataset && b.dataset?.dataset_type === 'embed-input'
|
|
228
|
+
&& b.dataset?.validation_status === 'validated' && typeof b.dataset?.created_at === 'string'
|
|
229
|
+
&& b.id === undefined,
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
claim: 'POST /v1/connectors',
|
|
233
|
+
request: () => ({ method: 'POST', path: '/v1/connectors', body: { name: 'conf-conn', url: 'https://example.test/search', excludes: ['x'] } }),
|
|
234
|
+
status: [200],
|
|
235
|
+
// NESTED under `connector` — `CreateConnectorResponse` is `{ connector }`.
|
|
236
|
+
expect: (b) => isObj(b) && b.connector?.name === 'conf-conn' && b.connector?.url === 'https://example.test/search'
|
|
237
|
+
&& b.connector?.auth_type === 'none' && b.connector?.auth_status === 'valid'
|
|
238
|
+
&& b.connector?.active === true && UUID.test(String(b.connector?.id)),
|
|
239
|
+
capture: (b, ctx) => { ctx.connector = b.connector.id; },
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
claim: 'GET /v1/connectors',
|
|
243
|
+
request: () => ({ method: 'GET', path: '/v1/connectors' }),
|
|
244
|
+
status: [200],
|
|
245
|
+
expect: (b, ctx) => isObj(b) && Array.isArray(b.connectors) && b.total_count === b.connectors.length
|
|
246
|
+
&& b.connectors.some((c) => c.id === ctx.connector),
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
claim: 'GET /v1/connectors/{id}',
|
|
250
|
+
request: (ctx) => ({ method: 'GET', path: `/v1/connectors/${ctx.connector}` }),
|
|
251
|
+
status: [200],
|
|
252
|
+
expect: (b, ctx) => isObj(b) && b.connector?.id === ctx.connector && JSON.stringify(b.connector?.excludes) === '["x"]',
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
claim: 'PATCH /v1/connectors/{id}',
|
|
256
|
+
request: (ctx) => ({ method: 'PATCH', path: `/v1/connectors/${ctx.connector}`, body: { name: 'conf-renamed', active: false } }),
|
|
257
|
+
status: [200],
|
|
258
|
+
// The patch really lands AND the untouched field survives — a whole-row replace would lose it.
|
|
259
|
+
expect: (b, ctx) => isObj(b) && b.connector?.id === ctx.connector && b.connector?.name === 'conf-renamed'
|
|
260
|
+
&& b.connector?.active === false && b.connector?.url === 'https://example.test/search',
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
claim: 'POST /v1/connectors/{id}/oauth/authorize',
|
|
264
|
+
request: (ctx) => ({ method: 'POST', path: `/v1/connectors/${ctx.oauthConnector}/oauth/authorize?after_token_redirect=https://app.test/done` }),
|
|
265
|
+
status: [200],
|
|
266
|
+
expect: (b) => isObj(b) && typeof b.redirect_url === 'string'
|
|
267
|
+
&& b.redirect_url.startsWith('https://auth.example.test/authorize?')
|
|
268
|
+
&& b.redirect_url.includes('client_id=cid-1')
|
|
269
|
+
&& b.redirect_url.includes('after_token_redirect=https%3A%2F%2Fapp.test%2Fdone'),
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
claim: 'POST /v1/embed-jobs',
|
|
273
|
+
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' } }),
|
|
274
|
+
status: [200],
|
|
275
|
+
// The create answers `{ job_id, meta }` — a different key from every other create here.
|
|
276
|
+
expect: (b) => isObj(b) && UUID.test(String(b.job_id)) && b.id === undefined && isObj(b.meta),
|
|
277
|
+
capture: (b, ctx) => { ctx.job = b.job_id; },
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
claim: 'GET /v1/embed-jobs',
|
|
281
|
+
request: () => ({ method: 'GET', path: '/v1/embed-jobs' }),
|
|
282
|
+
status: [200],
|
|
283
|
+
expect: (b, ctx) => isObj(b) && Array.isArray(b.embed_jobs) && b.embed_jobs.length === 1
|
|
284
|
+
&& b.embed_jobs[0].job_id === ctx.job,
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
claim: 'GET /v1/embed-jobs/{id}',
|
|
288
|
+
request: (ctx) => ({ method: 'GET', path: `/v1/embed-jobs/${ctx.job}` }),
|
|
289
|
+
status: [200],
|
|
290
|
+
expect: (b, ctx) => isObj(b) && b.job_id === ctx.job && b.status === 'processing'
|
|
291
|
+
&& b.input_dataset_id === ctx.dataset && b.model === 'embed-english-v3.0'
|
|
292
|
+
&& b.truncate === 'END' && b.name === 'conf-job'
|
|
293
|
+
// The vendor's EmbedJob schema has NO `id` key; emitting one would be invented surface.
|
|
294
|
+
&& b.id === undefined,
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
claim: 'POST /v1/embed-jobs/{id}/cancel',
|
|
298
|
+
request: (ctx) => ({ method: 'POST', path: `/v1/embed-jobs/${ctx.job}/cancel` }),
|
|
299
|
+
status: [200],
|
|
300
|
+
// `embedJobs.cancel` is declared `-> void`: an empty body, and the STATE moved.
|
|
301
|
+
expect: (b) => isObj(b) && Object.keys(b).length === 0,
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
claim: 'DELETE /v1/connectors/{id}',
|
|
305
|
+
request: (ctx) => ({ method: 'DELETE', path: `/v1/connectors/${ctx.connector}` }),
|
|
306
|
+
status: [200],
|
|
307
|
+
expect: (b) => isObj(b) && Object.keys(b).length === 0,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
claim: 'DELETE /v1/datasets/{id}',
|
|
311
|
+
request: (ctx) => ({ method: 'DELETE', path: `/v1/datasets/${ctx.dataset}` }),
|
|
312
|
+
status: [200],
|
|
313
|
+
// Cohere's delete answers an EMPTY object, not the `{deleted:true}` envelope most vendors send.
|
|
314
|
+
expect: (b) => isObj(b) && Object.keys(b).length === 0,
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
/** The vendor not-found envelope this twin answers unmodeled surface with — written as a LITERAL
|
|
318
|
+
* predicate here, never imported from the handler that produces it. Cohere's error body has
|
|
319
|
+
* EXACTLY ONE key. */
|
|
320
|
+
function isNotFoundEnvelope(status, body) {
|
|
321
|
+
return status === 404 && isObj(body) && typeof body.message === 'string' && Object.keys(body).length === 1;
|
|
322
|
+
}
|
|
323
|
+
/** A concrete request for a router-census entry — `{id}` becomes a deliberately nonexistent id, so
|
|
324
|
+
* a LIVE branch answers its own 404 while a MISSING branch answers the router's 404. Both are
|
|
325
|
+
* not-found envelopes, which is exactly why this direction only asks "is anything OTHER than a
|
|
326
|
+
* not-found envelope served here, unclaimed?" */
|
|
327
|
+
function censusRequest(entry) {
|
|
328
|
+
const path = entry.path.replace('{id}', 'zzz-nonexistent-zzz');
|
|
329
|
+
return { method: entry.method, path, body: entry.method === 'GET' || entry.method === 'DELETE' ? undefined : {} };
|
|
330
|
+
}
|
|
331
|
+
/** Run the offline conformance checks against a fresh temp root. */
|
|
332
|
+
export async function checkCohereConformance(opts = {}) {
|
|
333
|
+
const root = opts.root ?? mkdtempSync(join(tmpdir(), 'cohere-conf-'));
|
|
334
|
+
const owned = opts.root === undefined;
|
|
335
|
+
const violations = [];
|
|
336
|
+
let checksRun = 0;
|
|
337
|
+
const fail = (check, detail) => violations.push({ check, detail });
|
|
338
|
+
const H = (r, at = root) => handleCohereTwinRequest({ method: r.method, path: r.path, root: at, ...(r.body === undefined ? {} : { body: JSON.stringify(r.body) }) });
|
|
339
|
+
try {
|
|
340
|
+
// ── DIRECTION 1+2: probe ⇄ claim, a two-way bijection ──────────────────────────────
|
|
341
|
+
const claims = new Set(COHERE_TWIN_SNAPSHOT);
|
|
342
|
+
const probed = new Set(PROBES.map((p) => p.claim));
|
|
343
|
+
for (const p of PROBES)
|
|
344
|
+
if (!claims.has(p.claim))
|
|
345
|
+
fail('probe.unclaimed', `probe certifies "${p.claim}", which is not in COHERE_TWIN_SNAPSHOT`);
|
|
346
|
+
for (const c of COHERE_TWIN_SNAPSHOT)
|
|
347
|
+
if (!probed.has(c))
|
|
348
|
+
fail('claim.unprobed', `claimed endpoint "${c}" has no probe — an unexercised claim certifies nothing`);
|
|
349
|
+
if (probed.size !== PROBES.length)
|
|
350
|
+
fail('probe.duplicate', 'two probes certify the same claim');
|
|
351
|
+
const ctx = {};
|
|
352
|
+
// The oauth-authorize probe needs a connector that HAS an oauth configuration, which the
|
|
353
|
+
// ordinary `POST /v1/connectors` probe deliberately does not create (its own claim is the
|
|
354
|
+
// no-auth shape). Seeding it here keeps each probe's assertions about one thing.
|
|
355
|
+
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' } } });
|
|
356
|
+
ctx.oauthConnector = (oauthCreate.body?.connector?.id);
|
|
357
|
+
if (ctx.oauthConnector === undefined)
|
|
358
|
+
fail('seed.oauth_connector', `seeding an oauth connector failed: ${oauthCreate.status} ${JSON.stringify(oauthCreate.body).slice(0, 200)}`);
|
|
359
|
+
for (const p of PROBES) {
|
|
360
|
+
checksRun++;
|
|
361
|
+
const res = await H(p.request(ctx));
|
|
362
|
+
if (!p.status.includes(res.status)) {
|
|
363
|
+
fail('probe.status', `${p.claim}: status ${res.status} (expected one of ${p.status.join(', ')}) — body ${JSON.stringify(res.body).slice(0, 200)}`);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
let outcomeOk;
|
|
367
|
+
try {
|
|
368
|
+
outcomeOk = p.expect(res.body, ctx);
|
|
369
|
+
}
|
|
370
|
+
catch (e) {
|
|
371
|
+
outcomeOk = false;
|
|
372
|
+
void e;
|
|
373
|
+
}
|
|
374
|
+
if (!outcomeOk) {
|
|
375
|
+
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)}`);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
p.capture?.(res.body, ctx);
|
|
379
|
+
}
|
|
380
|
+
// The deletes above must have actually removed the rows — a delete that answered `{}` while
|
|
381
|
+
// leaving the row standing would satisfy its own probe. This reads the state AFTER.
|
|
382
|
+
checksRun++;
|
|
383
|
+
const afterDelete = await H({ method: 'GET', path: '/v1/datasets' });
|
|
384
|
+
if (JSON.stringify(afterDelete.body?.datasets) !== '[]') {
|
|
385
|
+
fail('delete.effective', `DELETE /v1/datasets/{id} answered 200 but the row is still listed: ${JSON.stringify(afterDelete.body).slice(0, 200)}`);
|
|
386
|
+
}
|
|
387
|
+
checksRun++;
|
|
388
|
+
// The three `{}`-bodied claims (both deletes and the cancel) are each satisfied by a handler
|
|
389
|
+
// that answered `{status:200, body:{}}` with its `applyTwinWrite` deleted, so each needs a
|
|
390
|
+
// post-hoc re-read. The connectors one was missing (§9 round 1, finding 3) — and its probe runs
|
|
391
|
+
// AFTER the list probe, so nothing else re-read connectors either.
|
|
392
|
+
const afterConnectorDelete = await H({ method: 'GET', path: '/v1/connectors' });
|
|
393
|
+
const remaining = afterConnectorDelete.body?.connectors;
|
|
394
|
+
// The oauth-seeded connector is the only one that must survive; the probed one must be gone.
|
|
395
|
+
if (!Array.isArray(remaining) || remaining.some((c) => c.id === ctx.connector)) {
|
|
396
|
+
fail('connector_delete.effective', `DELETE /v1/connectors/{id} answered 200 but the row is still listed: ${JSON.stringify(afterConnectorDelete.body).slice(0, 200)}`);
|
|
397
|
+
}
|
|
398
|
+
if (afterConnectorDelete.body?.total_count !== remaining?.length) {
|
|
399
|
+
fail('connector_delete.effective', `total_count (${String(afterConnectorDelete.body?.total_count)}) disagrees with the listed rows after a delete`);
|
|
400
|
+
}
|
|
401
|
+
checksRun++;
|
|
402
|
+
const jobAfterCancel = await H({ method: 'GET', path: `/v1/embed-jobs/${ctx.job}` });
|
|
403
|
+
if (jobAfterCancel.body?.status !== 'cancelling') {
|
|
404
|
+
fail('cancel.effective', `POST /v1/embed-jobs/{id}/cancel answered 200 but the status did not move: ${JSON.stringify(jobAfterCancel.body).slice(0, 200)}`);
|
|
405
|
+
}
|
|
406
|
+
// ── DIRECTION 3: router → claim (served-but-unclaimed surface) ─────────────────────
|
|
407
|
+
const censusRoot = mkdtempSync(join(tmpdir(), 'cohere-conf-census-'));
|
|
408
|
+
try {
|
|
409
|
+
for (const entry of COHERE_ROUTER_SURFACE) {
|
|
410
|
+
checksRun++;
|
|
411
|
+
const key = `${entry.method} ${entry.path}`;
|
|
412
|
+
const res = await H(censusRequest(entry), censusRoot);
|
|
413
|
+
const inert = isNotFoundEnvelope(res.status, res.body);
|
|
414
|
+
if (!inert && !claims.has(key)) {
|
|
415
|
+
fail('router.unclaimed', `${key} serves a response (${res.status}) but COHERE_TWIN_SNAPSHOT never claims it`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// …and the other way: a claim the router census has never heard of is a claim about a
|
|
419
|
+
// branch that does not exist.
|
|
420
|
+
const census = new Set(COHERE_ROUTER_SURFACE.map((e) => `${e.method} ${e.path}`));
|
|
421
|
+
for (const c of COHERE_TWIN_SNAPSHOT)
|
|
422
|
+
if (!census.has(c))
|
|
423
|
+
fail('claim.unrouted', `claimed endpoint "${c}" is not a branch in COHERE_ROUTER_SURFACE`);
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
rmSync(censusRoot, { recursive: true, force: true });
|
|
427
|
+
}
|
|
428
|
+
// ── The vendor's error envelopes, asserted as LITERALS ─────────────────────────────
|
|
429
|
+
checksRun++;
|
|
430
|
+
const badBody = await H({ method: 'POST', path: '/v2/chat', body: { model: 'command-a-03-2025' } });
|
|
431
|
+
const bb = badBody.body;
|
|
432
|
+
// ONE key. Asserting the ABSENCE of `error`/`type`/`code` is what stops the envelope drifting
|
|
433
|
+
// toward the OpenAI shape, which no positive assertion would catch.
|
|
434
|
+
if (badBody.status !== 400 || !isObj(bb) || Object.keys(bb).join(',') !== 'message'
|
|
435
|
+
|| bb.message !== 'invalid request: messages is required') {
|
|
436
|
+
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)}`);
|
|
437
|
+
}
|
|
438
|
+
checksRun++;
|
|
439
|
+
const badModel = await H({ method: 'POST', path: '/v2/chat', body: { model: 'gpt-4o', messages: [{ role: 'user', content: 'x' }] } });
|
|
440
|
+
const bm = badModel.body;
|
|
441
|
+
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.") {
|
|
442
|
+
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)}`);
|
|
443
|
+
}
|
|
444
|
+
checksRun++;
|
|
445
|
+
const nf = await H({ method: 'GET', path: '/v1/nonexistent' });
|
|
446
|
+
if (!isNotFoundEnvelope(nf.status, nf.body))
|
|
447
|
+
fail('error.not_found', `an unknown route did not answer the 404 error envelope — got ${nf.status}`);
|
|
448
|
+
checksRun++;
|
|
449
|
+
const unauthorized = await handleCohereTwinRequest({ method: 'GET', path: '/v1/models', root, headers: {} });
|
|
450
|
+
const ub = unauthorized.body;
|
|
451
|
+
if (unauthorized.status !== 401 || Object.keys(ub ?? {}).join(',') !== 'message') {
|
|
452
|
+
fail('error.unauthorized', `a request with no bearer token did not 401 with the bare {message} envelope — got ${unauthorized.status}`);
|
|
453
|
+
}
|
|
454
|
+
checksRun++;
|
|
455
|
+
const readOnly = await handleCohereTwinRequest({ method: 'POST', path: '/v2/chat', body: JSON.stringify(CHAT), root, readOnly: true });
|
|
456
|
+
if (readOnly.status !== 405)
|
|
457
|
+
fail('error.read_only', `a read-only twin did not refuse a write with 405 — got ${readOnly.status}`);
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
if (owned)
|
|
461
|
+
rmSync(root, { recursive: true, force: true });
|
|
462
|
+
}
|
|
463
|
+
return { ok: violations.length === 0, checksRun, claimed: COHERE_TWIN_SNAPSHOT.length, violations };
|
|
464
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { SyncResource, TwinAction } from '@volter/world-core';
|
|
2
|
+
import { CohereBudget, type CohereBudgetOptions } from './cohere-budget.js';
|
|
3
|
+
/** The real Cohere API base — `CohereEnvironment.Production` in cohere-ai@8.1.0's
|
|
4
|
+
* `environments.d.ts`, which is the ONLY host either SDK ships. */
|
|
5
|
+
export declare const COHERE_API_BASE = "https://api.cohere.com";
|
|
6
|
+
/**
|
|
7
|
+
* The injected real-Cohere boundary. `request` issues ONE Cohere REST call:
|
|
8
|
+
* method — 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
|
9
|
+
* path — e.g. '/v1/datasets' or '/v1/embed-jobs/<id>/cancel'
|
|
10
|
+
* body — JSON body for POST/PATCH (omitted otherwise)
|
|
11
|
+
* Returns the parsed JSON: one of Cohere's per-collection list envelopes, a single object, or its
|
|
12
|
+
* `{ message }` error envelope.
|
|
13
|
+
*/
|
|
14
|
+
export type CohereExecute = (method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: Record<string, unknown>) => Promise<unknown>;
|
|
15
|
+
/** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
|
|
16
|
+
export type LiveCohereOptions = {
|
|
17
|
+
/** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
|
|
18
|
+
fetchImpl?: typeof fetch;
|
|
19
|
+
/** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
|
|
20
|
+
budget?: CohereBudget;
|
|
21
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
22
|
+
budgetOptions?: CohereBudgetOptions;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A live executor against the real Cohere REST API (the user's own API key). Sends
|
|
26
|
+
* `Authorization: Bearer <key>` — the scheme BOTH SDKs use (cohere-ai's `BearerAuthProvider`,
|
|
27
|
+
* which falls back to the `CO_API_KEY` env var, and @ai-sdk/cohere's provider, which reads
|
|
28
|
+
* `COHERE_API_KEY`). Never imported by the pack's own serving path — only constructed by a caller
|
|
29
|
+
* that opts into real I/O.
|
|
30
|
+
*
|
|
31
|
+
* THIS IS THE ONE PLACE this pack issues a live api.cohere.com request, and therefore the one
|
|
32
|
+
* place the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE
|
|
33
|
+
* the request goes out (`checkBudget`, which THROWS `CohereBudgetError` instead of returning when
|
|
34
|
+
* the ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a
|
|
35
|
+
* `retry-after` / 429 signal becomes a persisted cooldown that makes every later call fail fast
|
|
36
|
+
* WITHOUT touching Cohere. There is deliberately no OPTION to disable the guard, and no value a
|
|
37
|
+
* caller can pass for `budget` that yields an unguarded client. What that does NOT claim is
|
|
38
|
+
* immunity from a caller who WANTS one: a fresh `budgetOptions.path` per construction, or an
|
|
39
|
+
* injected clock, restores the allowance, because the same seam tests need cannot be denied to a
|
|
40
|
+
* determined caller in the same process. See `cohere-budget.ts` and the kernel's `rateBudget.ts`
|
|
41
|
+
* header for the limits of the guarantee.
|
|
42
|
+
*/
|
|
43
|
+
export declare function liveCohereExecute(apiKey: string, base?: string, opts?: LiveCohereOptions): CohereExecute;
|
|
44
|
+
/** Map a real-Cohere Dataset → a twin sync resource. Every field the vendor's `Dataset` schema
|
|
45
|
+
* declares REQUIRED (`id`, `name`, `created_at`, `updated_at`, `dataset_type`,
|
|
46
|
+
* `validation_status`) is carried; a pulled row that lost one would be unparseable by the
|
|
47
|
+
* official SDK the moment the twin served it back. */
|
|
48
|
+
export declare function mapDataset(d: Record<string, unknown>): SyncResource;
|
|
49
|
+
/** Map a real-Cohere Connector → a twin sync resource. */
|
|
50
|
+
export declare function mapConnector(c: Record<string, unknown>): SyncResource;
|
|
51
|
+
/**
|
|
52
|
+
* Map a real-Cohere EmbedJob → a twin sync resource.
|
|
53
|
+
*
|
|
54
|
+
* The vendor's key for an embed job is `job_id`, NOT `id` — there is no `id` on the schema at all.
|
|
55
|
+
* Subjecting the resource under `d.id` would make every pulled job land under the string
|
|
56
|
+
* "undefined" and collapse the whole collection onto one row.
|
|
57
|
+
*/
|
|
58
|
+
export declare function mapEmbedJob(j: Record<string, unknown>): SyncResource;
|
|
59
|
+
/** Pull all modeled real collections via the executor and map them to twin sync resources. */
|
|
60
|
+
export declare function pullCohereState(execute: CohereExecute): Promise<SyncResource[]>;
|
|
61
|
+
export declare function pollTimestamp(): string;
|
|
62
|
+
/**
|
|
63
|
+
* Pull from real Cohere and fold into the twin (mirror seeding). syncPull's shadow-diff makes a
|
|
64
|
+
* re-pull of identical state a no-op.
|
|
65
|
+
*/
|
|
66
|
+
export declare function syncCohereFromReal(execute: CohereExecute, opts?: {
|
|
67
|
+
root?: string;
|
|
68
|
+
occurredAt?: string;
|
|
69
|
+
}): Promise<{
|
|
70
|
+
observed: number;
|
|
71
|
+
deltasAppended: number;
|
|
72
|
+
}>;
|
|
73
|
+
/**
|
|
74
|
+
* Why this op is a KNOWN, filed, un-pushable gap — or undefined if the connector can push it.
|
|
75
|
+
*
|
|
76
|
+
* This answers only for gaps that have been REASONED ABOUT. An operation the connector has simply
|
|
77
|
+
* never heard of is NOT this function's business: it is a coding gap, and `assertPushable` throws
|
|
78
|
+
* on it, loudly. Routing every unknown verb through the skip-and-count path would turn a genuine
|
|
79
|
+
* unhandled write into an anonymous integer that ticks up and tells nobody.
|
|
80
|
+
*/
|
|
81
|
+
export declare function unpushableReason(op: string, subjectType?: string): string | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Resolve the REST (method, path) for ONE pending action — faithful to the real Cohere REST
|
|
84
|
+
* surface for every write op the twin records:
|
|
85
|
+
* - <type>.create → POST <collection>
|
|
86
|
+
* - <type>.update → PATCH <collection>/:id
|
|
87
|
+
* - <type>.cancel → POST <collection>/:id/cancel
|
|
88
|
+
* - <type>.delete → DELETE <collection>/:id
|
|
89
|
+
*/
|
|
90
|
+
export declare function cohereRequestForAction(action: Pick<TwinAction, 'operation' | 'subject'>): {
|
|
91
|
+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
92
|
+
path: string;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Push ONE pending action to REAL Cohere via the injected executor. Returns the real external id.
|
|
96
|
+
* WRITES TO THE REAL ACCOUNT.
|
|
97
|
+
*/
|
|
98
|
+
export declare function pushCohereAction(execute: CohereExecute, action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>): Promise<{
|
|
99
|
+
externalId: string;
|
|
100
|
+
}>;
|
|
101
|
+
/**
|
|
102
|
+
* Push the twin's PENDING local actions to real Cohere and CONFIRM each: for every pending action,
|
|
103
|
+
* call the real API; on success, confirmAction records the confirmed fields as an observed event
|
|
104
|
+
* and maps action → event (suppressing the local projection — counted exactly once). Idempotency:
|
|
105
|
+
* a confirmed action is no longer pending, so a re-push enacts NOTHING.
|
|
106
|
+
*
|
|
107
|
+
* COMPOUND-ACTION AUDIT (the `confirmAction` hazard in ADDING_A_TWIN.md §5): every action this
|
|
108
|
+
* connector pushes is SINGLE-RESOURCE — a connector or embed-job create/update, an embed-job
|
|
109
|
+
* cancel, a dataset or connector delete — so `fields` alone is the complete post-state and no
|
|
110
|
+
* `additionalObservations` are needed for the payload. The one write this twin makes that touches
|
|
111
|
+
* several resources at once is a TOKENIZE (one `token.observe` per fresh segment) — and those are
|
|
112
|
+
* separate single-resource actions on a type that is refused push-wide, so the hazard cannot
|
|
113
|
+
* arise here.
|
|
114
|
+
*/
|
|
115
|
+
export declare function pushPendingCohereActions(execute: CohereExecute, opts: {
|
|
116
|
+
root?: string;
|
|
117
|
+
occurredAt: string;
|
|
118
|
+
}): Promise<{
|
|
119
|
+
pushed: number;
|
|
120
|
+
confirmed: string[];
|
|
121
|
+
externalIds: Record<string, string>;
|
|
122
|
+
refused: Array<{
|
|
123
|
+
actionId: string;
|
|
124
|
+
operation: string;
|
|
125
|
+
subjectId: string;
|
|
126
|
+
reason: string;
|
|
127
|
+
}>;
|
|
128
|
+
}>;
|
|
129
|
+
/**
|
|
130
|
+
* FULL bi-directional sync over the injected client: (1) PUSH every pending local action to real
|
|
131
|
+
* Cohere and confirm it, then (2) PULL all modeled collections back and fold them into the event
|
|
132
|
+
* log. Pushing first means the pull observes the twin's own writes as confirmed external state (no
|
|
133
|
+
* double-count). Re-running with no pending writes and identical real state is a no-op (push 0,
|
|
134
|
+
* deltasAppended 0). Same code path offline (fake executor) and live (real key).
|
|
135
|
+
*/
|
|
136
|
+
export declare function fullSyncCohere(execute: CohereExecute, opts: {
|
|
137
|
+
root?: string;
|
|
138
|
+
occurredAt: string;
|
|
139
|
+
}): Promise<{
|
|
140
|
+
pushed: number;
|
|
141
|
+
refused: Array<{
|
|
142
|
+
actionId: string;
|
|
143
|
+
operation: string;
|
|
144
|
+
subjectId: string;
|
|
145
|
+
reason: string;
|
|
146
|
+
}>;
|
|
147
|
+
observed: number;
|
|
148
|
+
deltasAppended: number;
|
|
149
|
+
collections: number;
|
|
150
|
+
}>;
|