@volter/twin-togetherai 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/LICENSE +202 -0
- package/README.md +147 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +28 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +79 -0
- package/dist/src/togetherai-budget.d.ts +52 -0
- package/dist/src/togetherai-budget.js +130 -0
- package/dist/src/togetherai-capabilities.d.ts +4 -0
- package/dist/src/togetherai-capabilities.js +1428 -0
- package/dist/src/togetherai-conformance.d.ts +14 -0
- package/dist/src/togetherai-conformance.js +452 -0
- package/dist/src/togetherai-connector.d.ts +164 -0
- package/dist/src/togetherai-connector.js +457 -0
- package/dist/src/togetherai-models.d.ts +19 -0
- package/dist/src/togetherai-models.js +49 -0
- package/dist/src/togetherai-scenario.d.ts +52 -0
- package/dist/src/togetherai-scenario.js +168 -0
- package/dist/src/togetherai-server.d.ts +16 -0
- package/dist/src/togetherai-server.js +187 -0
- package/dist/src/togetherai-stub.d.ts +59 -0
- package/dist/src/togetherai-stub.js +195 -0
- package/dist/src/togetherai-twin.d.ts +83 -0
- package/dist/src/togetherai-twin.js +1419 -0
- package/dist/src/togetherai-types.d.ts +207 -0
- package/dist/src/togetherai-types.js +26 -0
- package/package.json +52 -0
- package/src/cli.ts +27 -0
- package/src/index.ts +118 -0
- package/src/togetherai-budget.ts +156 -0
- package/src/togetherai-capabilities.ts +1315 -0
- package/src/togetherai-conformance.ts +459 -0
- package/src/togetherai-connector.ts +496 -0
- package/src/togetherai-models.ts +74 -0
- package/src/togetherai-scenario.ts +185 -0
- package/src/togetherai-server.ts +199 -0
- package/src/togetherai-stub.ts +197 -0
- package/src/togetherai-twin.ts +1448 -0
- package/src/togetherai-types.ts +222 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type ConformanceViolation = {
|
|
2
|
+
check: string;
|
|
3
|
+
detail: string;
|
|
4
|
+
};
|
|
5
|
+
export type TogetheraiConformanceReport = {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
checksRun: number;
|
|
8
|
+
probes: number;
|
|
9
|
+
violations: ConformanceViolation[];
|
|
10
|
+
};
|
|
11
|
+
/** Run the offline conformance checks against a fresh temp root. */
|
|
12
|
+
export declare function checkTogetheraiConformance(opts?: {
|
|
13
|
+
root?: string;
|
|
14
|
+
}): Promise<TogetheraiConformanceReport>;
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// Together AI twin conformance — an offline check with REAL TEETH: delete a handler branch and
|
|
2
|
+
// it goes RED. The harness runs THREE passes (the groq pack's method):
|
|
3
|
+
//
|
|
4
|
+
// 1. PROBES — one real request per CLAIMED endpoint, graded on the OUTCOME a live handler
|
|
5
|
+
// produces: an expected status set PLUS a predicate over the body. A handler that returned
|
|
6
|
+
// `{}`, or whose branch was deleted (so the router falls through to its not-found), fails.
|
|
7
|
+
// 2. BIJECTION — the probe table and the claimed-endpoint snapshot must match two ways, so a
|
|
8
|
+
// claim with no probe and a probe with no claim are both RED.
|
|
9
|
+
// 3. ROUTER_SURFACE — a HAND-AUTHORED census of every method/path pair `routeTogetherai`
|
|
10
|
+
// branches on. Every entry must be claimed; and the paths Together serves but this twin does
|
|
11
|
+
// NOT model (plus the surface Together does NOT have — OpenAI's assistants/threads/responses/
|
|
12
|
+
// moderations, OpenAI-shaped batch/file/fine-tune endpoints) must answer the vendor-shaped
|
|
13
|
+
// not-found envelope. This closes the served-but-unclaimed direction that probe⇄claim alone
|
|
14
|
+
// is blind to.
|
|
15
|
+
//
|
|
16
|
+
// Fully offline + deterministic (drives the local handler against a temp root) so it runs in CI
|
|
17
|
+
// without an API key. Honest scope: it checks the protocol envelope, NOT model output (a
|
|
18
|
+
// deterministic labeled stub by design).
|
|
19
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { handleTogetheraiTwinRequest } from "./togetherai-twin.js";
|
|
23
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
24
|
+
const CHAT = { model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', messages: [{ role: 'user', content: 'hi' }] };
|
|
25
|
+
const PROBES = [
|
|
26
|
+
{
|
|
27
|
+
key: 'POST /v1/chat/completions',
|
|
28
|
+
method: 'POST', path: '/v1/chat/completions', body: CHAT, statuses: [200],
|
|
29
|
+
ok: (b) => isObj(b) && b.object === 'chat.completion' && Array.isArray(b.choices)
|
|
30
|
+
&& b.choices[0]?.message?.role === 'assistant'
|
|
31
|
+
&& typeof b.choices[0]?.message?.content === 'string'
|
|
32
|
+
&& isObj(b.usage) && typeof b.usage.total_tokens === 'number'
|
|
33
|
+
&& Array.isArray(b.prompt),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
key: 'GET /v1/models',
|
|
37
|
+
method: 'GET', path: '/v1/models', statuses: [200],
|
|
38
|
+
ok: (b) => Array.isArray(b) && b.some((m) => m.id === 'meta-llama/Llama-3.3-70B-Instruct-Turbo' && m.object === 'model' && typeof m.created === 'number' && typeof m.type === 'string'),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: 'GET /v1/models/{model}',
|
|
42
|
+
method: 'GET', path: '/v1/models/meta-llama/Llama-3.3-70B-Instruct-Turbo', statuses: [200],
|
|
43
|
+
ok: (b) => isObj(b) && b.id === 'meta-llama/Llama-3.3-70B-Instruct-Turbo' && b.object === 'model',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
key: 'POST /v1/completions',
|
|
47
|
+
method: 'POST', path: '/v1/completions', body: { model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', prompt: 'Once' }, statuses: [200],
|
|
48
|
+
ok: (b) => isObj(b) && b.object === 'text.completion' && Array.isArray(b.prompt) && isObj(b.usage),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
key: 'POST /v1/embeddings',
|
|
52
|
+
method: 'POST', path: '/v1/embeddings', body: { model: 'WhereIsAI/UAE-Large-V1', input: 'x' }, statuses: [200],
|
|
53
|
+
ok: (b) => isObj(b) && b.object === 'list' && Array.isArray(b.data) && b.data[0]?.embedding?.length === 1024 && !('usage' in b),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
key: 'POST /v1/rerank',
|
|
57
|
+
method: 'POST', path: '/v1/rerank', body: { model: 'Salesforce/Llama-Rank-v1', query: 'q', documents: ['a', 'b'] }, statuses: [200],
|
|
58
|
+
ok: (b) => isObj(b) && b.object === 'rerank' && Array.isArray(b.results) && b.results[0]?.relevance_score !== undefined,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: 'POST /v1/images/generations',
|
|
62
|
+
method: 'POST', path: '/v1/images/generations', body: { model: 'black-forest-labs/FLUX.1-schnell', prompt: 'a cat' }, statuses: [200],
|
|
63
|
+
ok: (b) => isObj(b) && Array.isArray(b.data) && b.data[0]?.url !== undefined,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'POST /v1/audio/speech',
|
|
67
|
+
method: 'POST', path: '/v1/audio/speech', body: { model: 'cartesia/sonic', input: 'hi', voice: 'female' }, statuses: [200],
|
|
68
|
+
ok: (b) => typeof b === 'string' && b.includes('[twin-stub:'),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
key: 'POST /v1/audio/transcriptions',
|
|
72
|
+
method: 'POST', path: '/v1/audio/transcriptions', body: { model: 'cartesia/sonic', file: 'a.wav' }, statuses: [200],
|
|
73
|
+
ok: (b) => isObj(b) && typeof b.text === 'string',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: 'POST /v1/audio/translations',
|
|
77
|
+
method: 'POST', path: '/v1/audio/translations', body: { model: 'cartesia/sonic', file: 'a.wav', response_format: 'verbose_json' }, statuses: [200],
|
|
78
|
+
ok: (b) => isObj(b) && b.task === 'translate' && typeof b.text === 'string',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: 'GET /v1/whoami',
|
|
82
|
+
method: 'GET', path: '/v1/whoami', statuses: [200],
|
|
83
|
+
ok: (b) => isObj(b) && typeof b.api_key_id === 'string' && typeof b.project_id === 'string' && typeof b.organization_id === 'string',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
key: 'POST /v1/files/upload',
|
|
87
|
+
method: 'POST', path: '/v1/files/upload', body: { purpose: 'fine-tune', filename: 'a.jsonl', content: 'x' }, statuses: [200],
|
|
88
|
+
ok: (b) => isObj(b) && b.object === 'file' && b.Processed === true && b.FileType === 'jsonl',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
key: 'GET /v1/files',
|
|
92
|
+
method: 'GET', path: '/v1/files', statuses: [200],
|
|
93
|
+
ok: (b) => isObj(b) && Array.isArray(b.data),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: 'GET /v1/files/{file_id}',
|
|
97
|
+
method: 'GET', path: '/v1/files/file_twin_1', statuses: [200],
|
|
98
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 'a.jsonl', content: 'x' }); },
|
|
99
|
+
ok: (b) => isObj(b) && b.id === 'file_twin_1' && b.object === 'file',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
key: 'GET /v1/files/{file_id}/content',
|
|
103
|
+
method: 'GET', path: '/v1/files/file_twin_1/content', statuses: [200],
|
|
104
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 'a.jsonl', content: 'x' }); },
|
|
105
|
+
ok: (b) => b === 'x',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: 'DELETE /v1/files/{file_id}',
|
|
109
|
+
method: 'DELETE', path: '/v1/files/file_twin_1', statuses: [200],
|
|
110
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 'a.jsonl', content: 'x' }); },
|
|
111
|
+
ok: (b) => isObj(b) && b.deleted === true,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
key: 'POST /v1/batches',
|
|
115
|
+
method: 'POST', path: '/v1/batches', statuses: [201],
|
|
116
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'batch-api', filename: 'in.jsonl', content: '{"custom_id":"a"}' }); },
|
|
117
|
+
body: { input_file_id: 'file_twin_1', endpoint: '/v1/chat/completions' },
|
|
118
|
+
ok: (b) => isObj(b) && isObj(b.job) && typeof b.job.id === 'string' && b.job.status === 'VALIDATING',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
key: 'GET /v1/batches',
|
|
122
|
+
method: 'GET', path: '/v1/batches', statuses: [200],
|
|
123
|
+
ok: (b) => Array.isArray(b),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
key: 'GET /v1/batches/{batch_id}',
|
|
127
|
+
method: 'GET', path: '/v1/batches/batch_twin_1', statuses: [200],
|
|
128
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'batch-api', filename: 'in.jsonl', content: '{}' }); await h('POST', '/v1/batches', { input_file_id: 'file_twin_1', endpoint: '/v1/chat/completions' }); },
|
|
129
|
+
ok: (b) => isObj(b) && b.id === 'batch_twin_1' && (b.error === null || isObj(b.error)),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
key: 'POST /v1/batches/{batch_id}/cancel',
|
|
133
|
+
method: 'POST', path: '/v1/batches/batch_twin_1/cancel', statuses: [200],
|
|
134
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'batch-api', filename: 'in.jsonl', content: '{}' }); await h('POST', '/v1/batches', { input_file_id: 'file_twin_1', endpoint: '/v1/chat/completions' }); },
|
|
135
|
+
ok: (b) => isObj(b) && b.status === 'CANCELLED',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
key: 'POST /v1/fine-tunes',
|
|
139
|
+
method: 'POST', path: '/v1/fine-tunes', statuses: [200],
|
|
140
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 't.jsonl', content: 'x' }); },
|
|
141
|
+
body: { model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', training_file: 'file_twin_1' },
|
|
142
|
+
ok: (b) => isObj(b) && b.status === 'pending' && typeof b.id === 'string' && b.id.startsWith('ft'),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
key: 'GET /v1/fine-tunes',
|
|
146
|
+
method: 'GET', path: '/v1/fine-tunes', statuses: [200],
|
|
147
|
+
ok: (b) => Array.isArray(b),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
key: 'GET /v1/fine-tunes/{id}',
|
|
151
|
+
method: 'GET', path: '/v1/fine-tunes/ft_twin_1', statuses: [200],
|
|
152
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 't.jsonl', content: 'x' }); await h('POST', '/v1/fine-tunes', { model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', training_file: 'file_twin_1' }); },
|
|
153
|
+
ok: (b) => isObj(b) && b.id === 'ft_twin_1',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
key: 'POST /v1/fine-tunes/{id}/cancel',
|
|
157
|
+
method: 'POST', path: '/v1/fine-tunes/ft_twin_1/cancel', statuses: [200],
|
|
158
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 't.jsonl', content: 'x' }); await h('POST', '/v1/fine-tunes', { model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', training_file: 'file_twin_1' }); },
|
|
159
|
+
ok: (b) => isObj(b) && b.status === 'cancel_requested',
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
// Together's estimate-price is a discriminated union on `estimation_available`
|
|
163
|
+
// (together-ai@0.53.0 fine-tuning.d.ts:1323), not OpenAI's {cost_usd, estimated_tokens}.
|
|
164
|
+
// The probe seeds a real file — an unknown training_file answers the OTHER arm of the union
|
|
165
|
+
// (UnavailableEstimate / train_file_invalid), asserted by the capabilities verify.
|
|
166
|
+
key: 'POST /v1/fine-tunes/estimate-price',
|
|
167
|
+
method: 'POST', path: '/v1/fine-tunes/estimate-price', body: { model: 'm', training_file: 'file_twin_1' }, statuses: [200],
|
|
168
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 'est.jsonl', content: '{"text":"r"}\n' }); },
|
|
169
|
+
ok: (b) => isObj(b) && b.estimation_available === true && typeof b.estimated_total_price === 'number',
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
// Together's FinetuneModelLimits for ONE model — `model_name` is REQUIRED
|
|
173
|
+
// (together-ai@0.53.0 fine-tuning.d.ts:1798), so the probe names a catalog model and
|
|
174
|
+
// asserts the shape's REQUIRED keys; an unknown model is a 404, asserted by the
|
|
175
|
+
// capabilities verify.
|
|
176
|
+
key: 'GET /v1/fine-tunes/models/limits',
|
|
177
|
+
method: 'GET', path: '/v1/fine-tunes/models/limits?model_name=meta-llama/Llama-3.3-70B-Instruct-Turbo', statuses: [200],
|
|
178
|
+
ok: (b) => isObj(b) && b.model_name === 'meta-llama/Llama-3.3-70B-Instruct-Turbo' && typeof b.max_num_epochs === 'number' && typeof b.lora_training?.max_rank === 'number',
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
const ROUTER_SURFACE = [
|
|
182
|
+
{ key: 'GET /v1/models', method: 'GET', path: '/v1/models' },
|
|
183
|
+
{ key: 'GET /v1/models/{model}', method: 'GET', path: '/v1/models/meta-llama/Llama-3.3-70B-Instruct-Turbo' },
|
|
184
|
+
{ key: 'POST /v1/chat/completions', method: 'POST', path: '/v1/chat/completions', body: CHAT },
|
|
185
|
+
{ key: 'POST /v1/completions', method: 'POST', path: '/v1/completions', body: { model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', prompt: 'Once' } },
|
|
186
|
+
{ key: 'POST /v1/embeddings', method: 'POST', path: '/v1/embeddings', body: { model: 'WhereIsAI/UAE-Large-V1', input: 'x' } },
|
|
187
|
+
{ key: 'POST /v1/rerank', method: 'POST', path: '/v1/rerank', body: { model: 'Salesforce/Llama-Rank-v1', query: 'q', documents: ['a'] } },
|
|
188
|
+
{ key: 'POST /v1/images/generations', method: 'POST', path: '/v1/images/generations', body: { model: 'black-forest-labs/FLUX.1-schnell', prompt: 'a cat' } },
|
|
189
|
+
{ key: 'POST /v1/audio/speech', method: 'POST', path: '/v1/audio/speech', body: { model: 'cartesia/sonic', input: 'hi', voice: 'female' } },
|
|
190
|
+
{ key: 'POST /v1/audio/transcriptions', method: 'POST', path: '/v1/audio/transcriptions', body: { model: 'cartesia/sonic', file: 'a.wav' } },
|
|
191
|
+
{ key: 'POST /v1/audio/translations', method: 'POST', path: '/v1/audio/translations', body: { model: 'cartesia/sonic', file: 'a.wav' } },
|
|
192
|
+
{ key: 'GET /v1/whoami', method: 'GET', path: '/v1/whoami' },
|
|
193
|
+
{ key: 'POST /v1/files/upload', method: 'POST', path: '/v1/files/upload', body: { purpose: 'fine-tune', filename: 'a.jsonl', content: 'x' } },
|
|
194
|
+
{ key: 'GET /v1/files', method: 'GET', path: '/v1/files' },
|
|
195
|
+
{ key: 'GET /v1/files/{file_id}', method: 'GET', path: '/v1/files/file_twin_1' },
|
|
196
|
+
{ key: 'GET /v1/files/{file_id}/content', method: 'GET', path: '/v1/files/file_twin_1/content' },
|
|
197
|
+
{ key: 'DELETE /v1/files/{file_id}', method: 'DELETE', path: '/v1/files/file_twin_1' },
|
|
198
|
+
{ key: 'POST /v1/batches', method: 'POST', path: '/v1/batches', body: { input_file_id: 'file_twin_1', endpoint: '/v1/chat/completions' } },
|
|
199
|
+
{ key: 'GET /v1/batches', method: 'GET', path: '/v1/batches' },
|
|
200
|
+
{ key: 'GET /v1/batches/{batch_id}', method: 'GET', path: '/v1/batches/batch_twin_1' },
|
|
201
|
+
{ key: 'POST /v1/batches/{batch_id}/cancel', method: 'POST', path: '/v1/batches/batch_twin_1/cancel' },
|
|
202
|
+
{ key: 'POST /v1/fine-tunes', method: 'POST', path: '/v1/fine-tunes', body: { model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', training_file: 'file_twin_1' } },
|
|
203
|
+
{ key: 'GET /v1/fine-tunes', method: 'GET', path: '/v1/fine-tunes' },
|
|
204
|
+
{ key: 'GET /v1/fine-tunes/{id}', method: 'GET', path: '/v1/fine-tunes/ft_twin_1' },
|
|
205
|
+
{ key: 'POST /v1/fine-tunes/{id}/cancel', method: 'POST', path: '/v1/fine-tunes/ft_twin_1/cancel' },
|
|
206
|
+
{ key: 'POST /v1/fine-tunes/estimate-price', method: 'POST', path: '/v1/fine-tunes/estimate-price', body: { model: 'm', training_file: 'f' } },
|
|
207
|
+
{ key: 'GET /v1/fine-tunes/models/limits', method: 'GET', path: '/v1/fine-tunes/models/limits' },
|
|
208
|
+
];
|
|
209
|
+
/** The literal the router falls through to when NO branch matched. A census entry answering this
|
|
210
|
+
* means its branch is gone. Kept as a LITERAL — importing the handler's string would be a
|
|
211
|
+
* tautology that could not catch the message drifting. */
|
|
212
|
+
const ROUTER_MISS = 'Unknown request URL';
|
|
213
|
+
const ROUTER_SURFACE_KEYS = ROUTER_SURFACE.map((e) => e.key);
|
|
214
|
+
/**
|
|
215
|
+
* Surface the twin must NOT serve: real Together endpoints this twin does not model yet (unmodeled
|
|
216
|
+
* ops fail like the vendor, never a fake success), plus the OpenAI-shaped surface Together does
|
|
217
|
+
* NOT have (Together's OpenAI compatibility is the INFERENCE endpoints only — its Batch/Files/
|
|
218
|
+
* Fine-tuning APIs are Together-native and are served at their own shapes here; assistants,
|
|
219
|
+
* threads, responses and moderations do not exist at Together at all).
|
|
220
|
+
*/
|
|
221
|
+
const MUST_NOT_SERVE = [
|
|
222
|
+
{ method: 'POST', path: '/v1/responses', why: "OpenAI's Responses API does not exist at Together" },
|
|
223
|
+
{ method: 'POST', path: '/v1/moderations', why: "OpenAI's moderations endpoint does not exist at Together" },
|
|
224
|
+
{ method: 'GET', path: '/v1/threads', why: "OpenAI's Assistants/Threads surface does not exist at Together" },
|
|
225
|
+
{ method: 'POST', path: '/v1/fine_tuning/jobs', why: "OpenAI's fine-tuning shape — Together's own is /v1/fine-tunes (modeled)" },
|
|
226
|
+
{ method: 'POST', path: '/v1/vector_stores', why: "OpenAI's vector stores do not exist at Together" },
|
|
227
|
+
{ method: 'POST', path: '/v2/endpoints', why: 'the v2 management half is real Together surface this twin does not serve' },
|
|
228
|
+
{ method: 'POST', path: '/v1/chat/completions/extra', why: 'a path under /v1 the router does not model' },
|
|
229
|
+
];
|
|
230
|
+
/** Run the offline conformance checks against a fresh temp root. */
|
|
231
|
+
export async function checkTogetheraiConformance(opts = {}) {
|
|
232
|
+
const violations = [];
|
|
233
|
+
let checksRun = 0;
|
|
234
|
+
const fail = (check, detail) => violations.push({ check, detail });
|
|
235
|
+
// `--root DIR` is the PARENT the throwaway probe roots are minted under; a dir that does not
|
|
236
|
+
// exist yet is created (mkdtempSync would otherwise die on a raw ENOENT).
|
|
237
|
+
if (opts.root !== undefined)
|
|
238
|
+
mkdirSync(opts.root, { recursive: true });
|
|
239
|
+
// ── 1. PROBES: each in its OWN throwaway root, so a probe's seed can't leak into another. ──
|
|
240
|
+
for (const probe of PROBES) {
|
|
241
|
+
checksRun++;
|
|
242
|
+
// A FRESH root per probe, always. `opts.root` is the PARENT directory, never a shared root:
|
|
243
|
+
// sharing it let `nextId` ratchet across probes, so the `files/file_twin_1` probe read the id
|
|
244
|
+
// an earlier probe had minted and reported false violations (the groq pack's §9 round two,
|
|
245
|
+
// finding 12).
|
|
246
|
+
const root = mkdtempSync(join(opts.root ?? tmpdir(), 'togetherai-conf-'));
|
|
247
|
+
const h = (method, path, body) => handleTogetheraiTwinRequest({ method, path, ...(body === undefined ? {} : { body: JSON.stringify(body) }), root });
|
|
248
|
+
try {
|
|
249
|
+
if (probe.seed)
|
|
250
|
+
await probe.seed(h);
|
|
251
|
+
const res = await h(probe.method, probe.path, probe.body);
|
|
252
|
+
if (!probe.statuses.includes(res.status)) {
|
|
253
|
+
fail(`probe:${probe.key}`, `status ${res.status} (expected one of ${probe.statuses.join('/')}) body=${JSON.stringify(res.body).slice(0, 200)}`);
|
|
254
|
+
}
|
|
255
|
+
else if (!probe.ok(res.body, res.status)) {
|
|
256
|
+
fail(`probe:${probe.key}`, `body did not satisfy the live-handler predicate: ${JSON.stringify(res.body).slice(0, 300)}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
rmSync(root, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// ── 2. BIJECTION: probe table ⇄ router census, both directions. ──
|
|
264
|
+
checksRun++;
|
|
265
|
+
const probeKeys = new Set(PROBES.map((p) => p.key));
|
|
266
|
+
for (const key of ROUTER_SURFACE_KEYS)
|
|
267
|
+
if (!probeKeys.has(key))
|
|
268
|
+
fail('bijection', `router branch "${key}" has no probe`);
|
|
269
|
+
for (const key of probeKeys)
|
|
270
|
+
if (!ROUTER_SURFACE_KEYS.includes(key))
|
|
271
|
+
fail('bijection', `probe "${key}" is not in the hand-authored router census`);
|
|
272
|
+
if (PROBES.length !== new Set(PROBES.map((p) => p.key)).size)
|
|
273
|
+
fail('bijection', 'duplicate probe key');
|
|
274
|
+
if (ROUTER_SURFACE.length !== ROUTER_SURFACE_KEYS.length)
|
|
275
|
+
fail('bijection', 'the router census and its key list disagree');
|
|
276
|
+
// ── 2b. THE CENSUS HAS TEETH: every declared branch must actually be REACHED. A branch whose
|
|
277
|
+
// handler was deleted falls through to the router's own not-found, which is what this
|
|
278
|
+
// catches — independently of that endpoint's own probe.
|
|
279
|
+
for (const entry of ROUTER_SURFACE) {
|
|
280
|
+
checksRun++;
|
|
281
|
+
const root = mkdtempSync(join(opts.root ?? tmpdir(), 'togetherai-conf-'));
|
|
282
|
+
try {
|
|
283
|
+
const res = await handleTogetheraiTwinRequest({ method: entry.method, path: entry.path, ...(entry.body === undefined ? {} : { body: JSON.stringify(entry.body) }), root });
|
|
284
|
+
const message = String(res.body?.error?.message ?? '');
|
|
285
|
+
if (message.includes(ROUTER_MISS)) {
|
|
286
|
+
fail('router_surface', `${entry.key} fell through to the router's not-found — the branch is gone (answered ${res.status}: ${message})`);
|
|
287
|
+
}
|
|
288
|
+
if (!ROUTER_SURFACE_KEYS.includes(entry.key))
|
|
289
|
+
fail('router_surface', `${entry.key} is exercised but not declared`);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
rmSync(root, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// ── 3. The twin must not serve surface it does not model (or the vendor does not have). ──
|
|
296
|
+
{
|
|
297
|
+
const root = opts.root ?? mkdtempSync(join(tmpdir(), 'togetherai-conf-'));
|
|
298
|
+
try {
|
|
299
|
+
for (const entry of MUST_NOT_SERVE) {
|
|
300
|
+
checksRun++;
|
|
301
|
+
const res = await handleTogetheraiTwinRequest({ method: entry.method, path: entry.path, root });
|
|
302
|
+
if (res.status !== 404)
|
|
303
|
+
fail('must_not_serve', `${entry.method} ${entry.path} answered ${res.status}, not 404 — ${entry.why}`);
|
|
304
|
+
else if (!isObj(res.body) || !isObj(res.body.error))
|
|
305
|
+
fail('must_not_serve', `${entry.method} ${entry.path} 404 body is not the vendor error envelope`);
|
|
306
|
+
}
|
|
307
|
+
// ── 4. The error envelope must carry the keys Together's own published schema declares
|
|
308
|
+
// (`ErrorData`: message + type REQUIRED, param + code nullable-with-default-null).
|
|
309
|
+
// The key list is a LITERAL here — asserting against the handler's own constant would
|
|
310
|
+
// be a tautology that could not catch it drifting.
|
|
311
|
+
checksRun++;
|
|
312
|
+
const ERROR_DATA_KEYS = new Set(['message', 'type', 'param', 'code']);
|
|
313
|
+
const bad = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', body: JSON.stringify({ model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', messages: [] }), root });
|
|
314
|
+
const eb = bad.body;
|
|
315
|
+
if (bad.status !== 400 || !isObj(eb?.error)) {
|
|
316
|
+
fail('error.envelope', `empty messages did not yield a 400 with an error envelope (status ${bad.status})`);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const undeclared = Object.keys(eb.error).filter((k) => !ERROR_DATA_KEYS.has(k));
|
|
320
|
+
if (undeclared.length)
|
|
321
|
+
fail('error.envelope', `error object carries key(s) Together's ErrorData does not declare: ${undeclared.join(', ')}`);
|
|
322
|
+
if (typeof eb.error.message !== 'string' || !eb.error.message)
|
|
323
|
+
fail('error.envelope', 'error.message is not a non-empty string');
|
|
324
|
+
if (typeof eb.error.type !== 'string' || !eb.error.type)
|
|
325
|
+
fail('error.envelope', 'error.type is not a non-empty string');
|
|
326
|
+
}
|
|
327
|
+
// ── 4b. Together's OWN status table (docs.together.ai/docs/error-codes, read 2026-09-16):
|
|
328
|
+
// 402 = monthly spending limit; 403 = context length exceeded; 503 = engine overloaded.
|
|
329
|
+
// A twin answering 400/429/500 there would be OpenAI's table, not Together's.
|
|
330
|
+
checksRun++;
|
|
331
|
+
const long = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify({ ...CHAT, max_tokens: 200_000 }) });
|
|
332
|
+
if (long.status !== 403)
|
|
333
|
+
fail('vendor_status_table', `over-window request answered ${long.status}, not Together's 403`);
|
|
334
|
+
const forced402 = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify(CHAT), headers: { authorization: 'Bearer key_twin', 'x-twin-force-spending-limit': '1' } });
|
|
335
|
+
if (forced402.status !== 402)
|
|
336
|
+
fail('vendor_status_table', `the spending-limit trigger answered ${forced402.status}, not Together's 402`);
|
|
337
|
+
const forced503 = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify(CHAT), headers: { authorization: 'Bearer key_twin', 'x-twin-force-engine-overloaded': '1' } });
|
|
338
|
+
if (forced503.status !== 503)
|
|
339
|
+
fail('vendor_status_table', `the engine-overloaded trigger answered ${forced503.status}, not Together's 503`);
|
|
340
|
+
// ── 4c. The fields Together ACCEPTS BUT IGNORES must not change the answer, and the fields
|
|
341
|
+
// it 400s must not be served (docs.together.ai openai-compatibility, read 2026-09-16).
|
|
342
|
+
checksRun++;
|
|
343
|
+
const plain = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify(CHAT) });
|
|
344
|
+
const decorated = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify({ ...CHAT, service_tier: 'flex', store: true, metadata: { k: 'v' }, prediction: { content: 'x' } }) });
|
|
345
|
+
if (plain.status !== 200 || decorated.status !== 200)
|
|
346
|
+
fail('accepted_ignored_fields', `plain=${plain.status} decorated=${decorated.status}`);
|
|
347
|
+
else if (plain.body.choices[0].message.content !== decorated.body.choices[0].message.content) {
|
|
348
|
+
fail('accepted_ignored_fields', 'an accepted-but-ignored field changed the answer');
|
|
349
|
+
}
|
|
350
|
+
const n129 = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify({ ...CHAT, n: 129 }) });
|
|
351
|
+
if (n129.status !== 400)
|
|
352
|
+
fail('rejected_fields', 'n:129 answered ' + n129.status + ', not 400');
|
|
353
|
+
const logprobs21 = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify({ ...CHAT, logprobs: 21 }) });
|
|
354
|
+
if (logprobs21.status !== 400)
|
|
355
|
+
fail('rejected_fields', 'logprobs:21 answered ' + logprobs21.status + ', not 400');
|
|
356
|
+
const badBehavior = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify({ ...CHAT, context_length_exceeded_behavior: 'shrink' }) });
|
|
357
|
+
if (badBehavior.status !== 400)
|
|
358
|
+
fail('rejected_fields', 'an off-enum context_length_exceeded_behavior answered ' + badBehavior.status + ', not 400');
|
|
359
|
+
const noSchemaName = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify({ ...CHAT, response_format: { type: 'json_schema', json_schema: { schema: {} } } }) });
|
|
360
|
+
if (noSchemaName.status !== 400)
|
|
361
|
+
fail('rejected_fields', 'json_schema without .name answered ' + noSchemaName.status + ', not 400');
|
|
362
|
+
// ── 5. The streaming chunk sequence: role chunk → content/reasoning/tool deltas →
|
|
363
|
+
// finish_reason chunk → Together's usage-bearing tail (empty choices) → [DONE].
|
|
364
|
+
// Every chunk carries Together's nullable usage + warnings (the schema declares both
|
|
365
|
+
// on ChatCompletionChunk itself — OpenAI puts usage only in an opt-in tail chunk).
|
|
366
|
+
checksRun++;
|
|
367
|
+
const events = [];
|
|
368
|
+
await handleTogetheraiTwinRequest({
|
|
369
|
+
method: 'POST', path: '/v1/chat/completions', root,
|
|
370
|
+
body: JSON.stringify({ ...CHAT, stream: true }),
|
|
371
|
+
sseSink: (e) => events.push(e),
|
|
372
|
+
});
|
|
373
|
+
const data = events.filter((e) => !e.done).map((e) => e.data);
|
|
374
|
+
const hasRole = data.some((c) => c.choices?.[0]?.delta?.role === 'assistant');
|
|
375
|
+
const doneFrame = events.length > 0 && events[events.length - 1].done === true;
|
|
376
|
+
const firstObj = data[0]?.object;
|
|
377
|
+
const tail = data[data.length - 1];
|
|
378
|
+
if (!hasRole)
|
|
379
|
+
fail('chat.stream', 'no role delta chunk');
|
|
380
|
+
if (!doneFrame)
|
|
381
|
+
fail('chat.stream', 'stream did not end with [DONE]');
|
|
382
|
+
if (firstObj !== 'chat.completion.chunk')
|
|
383
|
+
fail('chat.stream', `first chunk object is ${String(firstObj)}`);
|
|
384
|
+
if (!data.every((c) => 'usage' in c && 'warnings' in c))
|
|
385
|
+
fail('chat.stream', 'a chunk is missing Together\'s usage/warnings keys');
|
|
386
|
+
if (!tail || !Array.isArray(tail.choices) || tail.choices.length !== 0 || !isObj(tail.usage) || typeof tail.usage.total_tokens !== 'number') {
|
|
387
|
+
fail('chat.stream', "final chunk is not Together's empty-choices usage tail");
|
|
388
|
+
}
|
|
389
|
+
// ── 5b. The LEGACY completions endpoint streams too (the SDK types it
|
|
390
|
+
// Stream<CompletionChunk>): token/text chunks with `object: 'completion.chunk'`,
|
|
391
|
+
// then the finish chunk carrying the real usage, then [DONE].
|
|
392
|
+
checksRun++;
|
|
393
|
+
const compEvents = [];
|
|
394
|
+
await handleTogetheraiTwinRequest({
|
|
395
|
+
method: 'POST', path: '/v1/completions', root,
|
|
396
|
+
body: JSON.stringify({ model: CHAT.model, prompt: 'Once', stream: true, max_tokens: 20 }),
|
|
397
|
+
sseSink: (e) => compEvents.push(e),
|
|
398
|
+
});
|
|
399
|
+
const compData = compEvents.filter((e) => !e.done).map((e) => e.data);
|
|
400
|
+
const compTail = compData[compData.length - 1];
|
|
401
|
+
if (compData.length === 0)
|
|
402
|
+
fail('completions.stream', 'no chunks emitted');
|
|
403
|
+
if (!compData.every((c) => c.object === 'completion.chunk'))
|
|
404
|
+
fail('completions.stream', "a chunk's object is not 'completion.chunk'");
|
|
405
|
+
if (!compData.every((c) => 'usage' in c))
|
|
406
|
+
fail('completions.stream', "a chunk is missing Together's nullable usage key");
|
|
407
|
+
if (!compData.slice(0, -1).every((c) => c.finish_reason === null))
|
|
408
|
+
fail('completions.stream', 'a mid-stream chunk carries a finish_reason');
|
|
409
|
+
if (!compTail || compTail.finish_reason === null || !isObj(compTail.usage) || typeof compTail.usage.total_tokens !== 'number') {
|
|
410
|
+
fail('completions.stream', 'the finish chunk does not carry finish_reason + the real usage');
|
|
411
|
+
}
|
|
412
|
+
const compDone = compEvents.length > 0 && compEvents[compEvents.length - 1].done === true;
|
|
413
|
+
if (!compDone)
|
|
414
|
+
fail('completions.stream', 'stream did not end with [DONE]');
|
|
415
|
+
// ── 6. tool_calls envelope when tools are provided, and the message shape Together's
|
|
416
|
+
// schema declares (reasoning/reasoning_content, NO OpenAI `refusal`).
|
|
417
|
+
checksRun++;
|
|
418
|
+
const tool = await handleTogetheraiTwinRequest({
|
|
419
|
+
method: 'POST', path: '/v1/chat/completions', root,
|
|
420
|
+
body: JSON.stringify({ ...CHAT, tools: [{ type: 'function', function: { name: 'get_weather', parameters: { type: 'object', properties: { city: { type: 'string' } } } } }] }),
|
|
421
|
+
});
|
|
422
|
+
const choice = tool.body.choices?.[0];
|
|
423
|
+
if (choice?.finish_reason !== 'tool_calls')
|
|
424
|
+
fail('chat.tool_calls', 'finish_reason not tool_calls');
|
|
425
|
+
const calls = choice?.message?.tool_calls;
|
|
426
|
+
if (!Array.isArray(calls) || calls[0]?.type !== 'function' || calls[0]?.function?.name !== 'get_weather')
|
|
427
|
+
fail('chat.tool_calls', 'no get_weather function tool_call');
|
|
428
|
+
if (choice && 'refusal' in choice.message)
|
|
429
|
+
fail('chat.message_shape', "assistant message carries a `refusal` field Together's schema does not define");
|
|
430
|
+
// ── 7. The deprecated `functions` parameter answers with the deprecated `function_call`
|
|
431
|
+
// response shape, not `tool_calls` (Together's ChatCompletionMessage.function_call +
|
|
432
|
+
// FinishReason 'function_call').
|
|
433
|
+
checksRun++;
|
|
434
|
+
const legacy = await handleTogetheraiTwinRequest({
|
|
435
|
+
method: 'POST', path: '/v1/chat/completions', root,
|
|
436
|
+
body: JSON.stringify({ ...CHAT, functions: [{ name: 'legacy_fn', parameters: { type: 'object', properties: { q: { type: 'string' } } } }] }),
|
|
437
|
+
});
|
|
438
|
+
const lc = legacy.body.choices?.[0];
|
|
439
|
+
if (lc?.finish_reason !== 'function_call')
|
|
440
|
+
fail('chat.legacy_functions', `finish_reason is ${String(lc?.finish_reason)}, not function_call`);
|
|
441
|
+
if (lc?.message?.function_call?.name !== 'legacy_fn')
|
|
442
|
+
fail('chat.legacy_functions', 'no function_call on the assistant message');
|
|
443
|
+
if (lc?.message?.tool_calls !== undefined)
|
|
444
|
+
fail('chat.legacy_functions', 'legacy functions must not answer with tool_calls');
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
if (opts.root === undefined)
|
|
448
|
+
rmSync(root, { recursive: true, force: true });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return { ok: violations.length === 0, checksRun, probes: PROBES.length, violations };
|
|
452
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
2
|
+
import { TogetheraiBudget, type TogetheraiBudgetOptions } from './togetherai-budget.js';
|
|
3
|
+
/**
|
|
4
|
+
* The injected real-Together boundary. `request` issues ONE Together REST call:
|
|
5
|
+
* method — 'GET' | 'POST' | 'DELETE'
|
|
6
|
+
* path — e.g. '/v1/files' or '/v1/batches/batch_123/cancel'
|
|
7
|
+
* body — JSON body for POST (omitted otherwise)
|
|
8
|
+
* Returns the parsed JSON (an object, a `{ data }` list, or an `{ error }` envelope).
|
|
9
|
+
*/
|
|
10
|
+
export type TogetheraiExecute = (method: 'GET' | 'POST' | 'DELETE', path: string, body?: Record<string, unknown>) => Promise<{
|
|
11
|
+
data?: any;
|
|
12
|
+
error?: {
|
|
13
|
+
message?: string;
|
|
14
|
+
type?: string;
|
|
15
|
+
};
|
|
16
|
+
[k: string]: unknown;
|
|
17
|
+
}>;
|
|
18
|
+
/** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
|
|
19
|
+
export type LiveTogetheraiOptions = {
|
|
20
|
+
/** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
/** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
|
|
23
|
+
budget?: TogetheraiBudget;
|
|
24
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
25
|
+
budgetOptions?: TogetheraiBudgetOptions;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* A live executor against the real Together REST API (the user's own API key). Sends the required
|
|
29
|
+
* `Authorization: Bearer` header. Never imported by the pack's own serve path — only constructed
|
|
30
|
+
* by a caller that opts into real I/O.
|
|
31
|
+
*
|
|
32
|
+
* THIS IS THE ONE PLACE this pack issues a live `api.together.xyz` request, and therefore the one
|
|
33
|
+
* place the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE
|
|
34
|
+
* the request goes out (`checkBudget`, which THROWS `TogetheraiBudgetError` instead of returning
|
|
35
|
+
* when the ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a
|
|
36
|
+
* `x-ratelimit-reset` / 429 signal becomes a persisted cooldown that makes every later call fail
|
|
37
|
+
* fast WITHOUT touching Together. There is deliberately no OPTION to disable the guard, and no
|
|
38
|
+
* value a caller can pass for `budget` that yields an unguarded client. What that does NOT claim
|
|
39
|
+
* is immunity from a caller who WANTS one: a fresh `budgetOptions.path` per construction, or an
|
|
40
|
+
* injected clock, restores the allowance, because the same seam tests need cannot be denied to a
|
|
41
|
+
* determined caller in the same process. See `togetherai-budget.ts` and the kernel header for the
|
|
42
|
+
* limits of the guarantee.
|
|
43
|
+
*/
|
|
44
|
+
export declare function liveTogetheraiExecute(apiKey: string, base?: string, opts?: LiveTogetheraiOptions): TogetheraiExecute;
|
|
45
|
+
/** Map a real-Together ModelInfo object → a twin sync resource. */
|
|
46
|
+
export declare function mapModel(m: Record<string, unknown>): SyncResource;
|
|
47
|
+
/** Map a real-Together FileResponse object → a twin sync resource. */
|
|
48
|
+
export declare function mapFile(f: Record<string, unknown>): SyncResource;
|
|
49
|
+
/** Map a real-Together BatchJob object → a twin sync resource. */
|
|
50
|
+
export declare function mapBatch(b: Record<string, unknown>): SyncResource;
|
|
51
|
+
/** Pull all modeled real collections via the executor and map them to twin sync resources. */
|
|
52
|
+
export declare function pullTogetheraiState(execute: TogetheraiExecute): Promise<SyncResource[]>;
|
|
53
|
+
/**
|
|
54
|
+
* Pull from real Together and fold into the twin (mirror seeding).
|
|
55
|
+
*
|
|
56
|
+
* `occurredAt` has NO pinned default on purpose: the kernel hashes an observed event over
|
|
57
|
+
* (occurredAt + post-state), so under a fixed poll time a vendor value that REVERTS across polls
|
|
58
|
+
* collides with its own earlier observation and the fold reports a phantom delta while the
|
|
59
|
+
* projection keeps the stale value (ADDING_A_TWIN.md §6). Callers pass a moving timestamp.
|
|
60
|
+
*/
|
|
61
|
+
export declare function syncTogetheraiFromReal(execute: TogetheraiExecute, opts: {
|
|
62
|
+
root?: string;
|
|
63
|
+
occurredAt: string;
|
|
64
|
+
}): Promise<{
|
|
65
|
+
observed: number;
|
|
66
|
+
deltasAppended: number;
|
|
67
|
+
}>;
|
|
68
|
+
/** Why this action cannot be pushed, or null if it can. Pure — no vendor call on its path. */
|
|
69
|
+
export declare function unpushableReason(op: string): string | null;
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the id the VENDOR knows this subject by.
|
|
72
|
+
*
|
|
73
|
+
* §9 ROUND TWO, BLOCKER (transcribed from the groq pack): a request builder that addresses the
|
|
74
|
+
* vendor straight from `action.subject.id` uses the twin's OWN mint for anything created locally.
|
|
75
|
+
* So a create's real id is written into the resource as `_external_id` at confirm time, and every
|
|
76
|
+
* later delete/cancel addresses the vendor by THAT. A locally-minted subject with no recorded
|
|
77
|
+
* external id is refused rather than guessed at.
|
|
78
|
+
*/
|
|
79
|
+
export declare function externalIdFor(subjectType: string, subjectId: string, root?: string): string | null;
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the REST (method, path) for ONE pending action — faithful to the real Together REST
|
|
82
|
+
* surface:
|
|
83
|
+
* - <type>.create → POST <collection>
|
|
84
|
+
* - <type>.cancel → POST <collection>/:id/cancel
|
|
85
|
+
* - <type>.delete → DELETE <collection>/:id
|
|
86
|
+
*/
|
|
87
|
+
export declare function togetheraiRequestForAction(action: Pick<TwinAction, 'operation' | 'subject'>,
|
|
88
|
+
/** The id the VENDOR knows this subject by. Required for anything but a create — see
|
|
89
|
+
* `externalIdFor`; passing the twin's own mint would address a resource the vendor never had. */
|
|
90
|
+
externalId?: string): {
|
|
91
|
+
method: 'GET' | 'POST' | 'DELETE';
|
|
92
|
+
path: string;
|
|
93
|
+
};
|
|
94
|
+
/** A push that must not go to the vendor (an unresolvable reference, a local id offered as a
|
|
95
|
+
* vendor id). In the SWEEP it is caught and reported as a refusal — one bad action never aborts
|
|
96
|
+
* the rest (the groq pack's §9 round one, finding 5); in PERFORM the head catches the throw and
|
|
97
|
+
* records a failed receipt, which is exactly the entry-level answer a deploy wants. */
|
|
98
|
+
export declare class PushRefusalError extends Error {
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Push ONE pending action to REAL Together via the injected executor. Returns the real external
|
|
102
|
+
* id (the object id from the response; for a create that's a freshly minted id, otherwise it
|
|
103
|
+
* echoes the subject). WRITES TO THE REAL ACCOUNT.
|
|
104
|
+
*/
|
|
105
|
+
export declare function pushTogetheraiAction(execute: TogetheraiExecute, action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>, opts?: {
|
|
106
|
+
externalId?: string;
|
|
107
|
+
root?: string;
|
|
108
|
+
}): Promise<{
|
|
109
|
+
externalId: string;
|
|
110
|
+
}>;
|
|
111
|
+
/**
|
|
112
|
+
* Push the twin's PENDING local actions to real Together and CONFIRM each. Idempotency: a
|
|
113
|
+
* confirmed action is no longer pending, so a re-push enacts NOTHING.
|
|
114
|
+
*
|
|
115
|
+
* Every action this pack records is SINGLE-RESOURCE (one file, one batch), so confirming with
|
|
116
|
+
* `fields` alone is correct — see ADDING_A_TWIN.md §5 on compound actions, which this pack has
|
|
117
|
+
* none of. If a compound write is ever added here, it must ride its extra resources in
|
|
118
|
+
* `additionalObservations` or the push will silently delete them.
|
|
119
|
+
*/
|
|
120
|
+
export declare function pushPendingTogetheraiActions(execute: TogetheraiExecute, opts: {
|
|
121
|
+
root?: string;
|
|
122
|
+
occurredAt: string;
|
|
123
|
+
}): Promise<{
|
|
124
|
+
pushed: number;
|
|
125
|
+
confirmed: string[];
|
|
126
|
+
externalIds: Record<string, string>;
|
|
127
|
+
refused: Array<{
|
|
128
|
+
actionId: string;
|
|
129
|
+
operation: string;
|
|
130
|
+
reason: string;
|
|
131
|
+
}>;
|
|
132
|
+
}>;
|
|
133
|
+
/**
|
|
134
|
+
* FULL bi-directional sync over the injected client: (1) PUSH every pending local action to real
|
|
135
|
+
* Together and confirm it, then (2) PULL all modeled collections back and fold them into the event
|
|
136
|
+
* log. Pushing first means the pull observes the twin's own writes as confirmed external state (no
|
|
137
|
+
* double-count). Re-running with no pending writes and identical real state is a no-op.
|
|
138
|
+
*/
|
|
139
|
+
export declare function fullSyncTogetherai(execute: TogetheraiExecute, opts: {
|
|
140
|
+
root?: string;
|
|
141
|
+
occurredAt: string;
|
|
142
|
+
}): Promise<{
|
|
143
|
+
pushed: number;
|
|
144
|
+
observed: number;
|
|
145
|
+
deltasAppended: number;
|
|
146
|
+
collections: number;
|
|
147
|
+
refused: Array<{
|
|
148
|
+
actionId: string;
|
|
149
|
+
operation: string;
|
|
150
|
+
reason: string;
|
|
151
|
+
}>;
|
|
152
|
+
}>;
|
|
153
|
+
/** The pack's executor over the kernel's: the same Together call, carried by the head. */
|
|
154
|
+
export declare function togetheraiExecuteOver(execute: RemoteExecute): TogetheraiExecute;
|
|
155
|
+
/** The refresh adapter: pull the account's models/files/batches through the executor and fold. */
|
|
156
|
+
export declare function syncTogetheraiFromRemote(execute: RemoteExecute, opts?: {
|
|
157
|
+
root?: string;
|
|
158
|
+
occurredAt?: string;
|
|
159
|
+
}): Promise<ReturnType<typeof syncTogetheraiFromReal>>;
|
|
160
|
+
/** The perform adapter: one entry crosses to Together, or settles with the reason it never could.
|
|
161
|
+
* Account-shaped writes (batches, files, fine-tunes) cross through `pushTogetheraiAction`; the
|
|
162
|
+
* stub-only subjects (a chat completion, its record) are the twin's own record of a call already
|
|
163
|
+
* answered — nothing at Together to write. */
|
|
164
|
+
export declare function performTogetheraiAction(execute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome>;
|