@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,459 @@
|
|
|
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, type TogetheraiResponseEnvelope } from './togetherai-twin.ts';
|
|
23
|
+
import type { SseEvent } from './togetherai-types.ts';
|
|
24
|
+
|
|
25
|
+
export type ConformanceViolation = { check: string; detail: string };
|
|
26
|
+
export type TogetheraiConformanceReport = {
|
|
27
|
+
ok: boolean;
|
|
28
|
+
checksRun: number;
|
|
29
|
+
probes: number;
|
|
30
|
+
violations: ConformanceViolation[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type Body = Record<string, any>;
|
|
34
|
+
const isObj = (v: unknown): v is Body => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* THE CLAIMED SURFACE. Every entry is a real request plus the OUTCOME a live handler produces.
|
|
38
|
+
* The expected values are LITERALS here, never imported from the handler — an assertion against
|
|
39
|
+
* the module's own constant is a tautology that cannot catch the value drifting.
|
|
40
|
+
*/
|
|
41
|
+
type Probe = {
|
|
42
|
+
/** `"<METHOD> <path>"` — the claim key, and the bijection key. */
|
|
43
|
+
key: string;
|
|
44
|
+
method: string;
|
|
45
|
+
path: string;
|
|
46
|
+
body?: unknown;
|
|
47
|
+
/** Which statuses a live handler may answer with. */
|
|
48
|
+
statuses: number[];
|
|
49
|
+
/** What a live handler's body must look like. */
|
|
50
|
+
ok: (body: unknown, status: number) => boolean;
|
|
51
|
+
/** Seed the root before probing (returns nothing; failures surface as probe failures). */
|
|
52
|
+
seed?: (h: (m: string, p: string, b?: unknown) => Promise<TogetheraiResponseEnvelope>) => Promise<void>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const CHAT = { model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', messages: [{ role: 'user', content: 'hi' }] };
|
|
56
|
+
|
|
57
|
+
const PROBES: Probe[] = [
|
|
58
|
+
{
|
|
59
|
+
key: 'POST /v1/chat/completions',
|
|
60
|
+
method: 'POST', path: '/v1/chat/completions', body: CHAT, statuses: [200],
|
|
61
|
+
ok: (b) => isObj(b) && b.object === 'chat.completion' && Array.isArray(b.choices)
|
|
62
|
+
&& (b.choices as Body[])[0]?.message?.role === 'assistant'
|
|
63
|
+
&& typeof (b.choices as Body[])[0]?.message?.content === 'string'
|
|
64
|
+
&& isObj(b.usage) && typeof b.usage.total_tokens === 'number'
|
|
65
|
+
&& Array.isArray(b.prompt),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: 'GET /v1/models',
|
|
69
|
+
method: 'GET', path: '/v1/models', statuses: [200],
|
|
70
|
+
ok: (b) => Array.isArray(b) && (b as Body[]).some((m) => m.id === 'meta-llama/Llama-3.3-70B-Instruct-Turbo' && m.object === 'model' && typeof m.created === 'number' && typeof m.type === 'string'),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
key: 'GET /v1/models/{model}',
|
|
74
|
+
method: 'GET', path: '/v1/models/meta-llama/Llama-3.3-70B-Instruct-Turbo', statuses: [200],
|
|
75
|
+
ok: (b) => isObj(b) && b.id === 'meta-llama/Llama-3.3-70B-Instruct-Turbo' && b.object === 'model',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
key: 'POST /v1/completions',
|
|
79
|
+
method: 'POST', path: '/v1/completions', body: { model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', prompt: 'Once' }, statuses: [200],
|
|
80
|
+
ok: (b) => isObj(b) && b.object === 'text.completion' && Array.isArray(b.prompt) && isObj(b.usage),
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
key: 'POST /v1/embeddings',
|
|
84
|
+
method: 'POST', path: '/v1/embeddings', body: { model: 'WhereIsAI/UAE-Large-V1', input: 'x' }, statuses: [200],
|
|
85
|
+
ok: (b) => isObj(b) && b.object === 'list' && Array.isArray(b.data) && (b.data as Body[])[0]?.embedding?.length === 1024 && !('usage' in b),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: 'POST /v1/rerank',
|
|
89
|
+
method: 'POST', path: '/v1/rerank', body: { model: 'Salesforce/Llama-Rank-v1', query: 'q', documents: ['a', 'b'] }, statuses: [200],
|
|
90
|
+
ok: (b) => isObj(b) && b.object === 'rerank' && Array.isArray(b.results) && (b.results as Body[])[0]?.relevance_score !== undefined,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: 'POST /v1/images/generations',
|
|
94
|
+
method: 'POST', path: '/v1/images/generations', body: { model: 'black-forest-labs/FLUX.1-schnell', prompt: 'a cat' }, statuses: [200],
|
|
95
|
+
ok: (b) => isObj(b) && Array.isArray(b.data) && (b.data as Body[])[0]?.url !== undefined,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: 'POST /v1/audio/speech',
|
|
99
|
+
method: 'POST', path: '/v1/audio/speech', body: { model: 'cartesia/sonic', input: 'hi', voice: 'female' }, statuses: [200],
|
|
100
|
+
ok: (b) => typeof b === 'string' && b.includes('[twin-stub:'),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
key: 'POST /v1/audio/transcriptions',
|
|
104
|
+
method: 'POST', path: '/v1/audio/transcriptions', body: { model: 'cartesia/sonic', file: 'a.wav' }, statuses: [200],
|
|
105
|
+
ok: (b) => isObj(b) && typeof b.text === 'string',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: 'POST /v1/audio/translations',
|
|
109
|
+
method: 'POST', path: '/v1/audio/translations', body: { model: 'cartesia/sonic', file: 'a.wav', response_format: 'verbose_json' }, statuses: [200],
|
|
110
|
+
ok: (b) => isObj(b) && b.task === 'translate' && typeof b.text === 'string',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
key: 'GET /v1/whoami',
|
|
114
|
+
method: 'GET', path: '/v1/whoami', statuses: [200],
|
|
115
|
+
ok: (b) => isObj(b) && typeof b.api_key_id === 'string' && typeof b.project_id === 'string' && typeof b.organization_id === 'string',
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
key: 'POST /v1/files/upload',
|
|
119
|
+
method: 'POST', path: '/v1/files/upload', body: { purpose: 'fine-tune', filename: 'a.jsonl', content: 'x' }, statuses: [200],
|
|
120
|
+
ok: (b) => isObj(b) && b.object === 'file' && b.Processed === true && b.FileType === 'jsonl',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
key: 'GET /v1/files',
|
|
124
|
+
method: 'GET', path: '/v1/files', statuses: [200],
|
|
125
|
+
ok: (b) => isObj(b) && Array.isArray(b.data),
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: 'GET /v1/files/{file_id}',
|
|
129
|
+
method: 'GET', path: '/v1/files/file_twin_1', statuses: [200],
|
|
130
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 'a.jsonl', content: 'x' }); },
|
|
131
|
+
ok: (b) => isObj(b) && b.id === 'file_twin_1' && b.object === 'file',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: 'GET /v1/files/{file_id}/content',
|
|
135
|
+
method: 'GET', path: '/v1/files/file_twin_1/content', statuses: [200],
|
|
136
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 'a.jsonl', content: 'x' }); },
|
|
137
|
+
ok: (b) => b === 'x',
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
key: 'DELETE /v1/files/{file_id}',
|
|
141
|
+
method: 'DELETE', path: '/v1/files/file_twin_1', statuses: [200],
|
|
142
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 'a.jsonl', content: 'x' }); },
|
|
143
|
+
ok: (b) => isObj(b) && b.deleted === true,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
key: 'POST /v1/batches',
|
|
147
|
+
method: 'POST', path: '/v1/batches', statuses: [201],
|
|
148
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'batch-api', filename: 'in.jsonl', content: '{"custom_id":"a"}' }); },
|
|
149
|
+
body: { input_file_id: 'file_twin_1', endpoint: '/v1/chat/completions' },
|
|
150
|
+
ok: (b) => isObj(b) && isObj(b.job) && typeof b.job.id === 'string' && b.job.status === 'VALIDATING',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
key: 'GET /v1/batches',
|
|
154
|
+
method: 'GET', path: '/v1/batches', statuses: [200],
|
|
155
|
+
ok: (b) => Array.isArray(b),
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
key: 'GET /v1/batches/{batch_id}',
|
|
159
|
+
method: 'GET', path: '/v1/batches/batch_twin_1', statuses: [200],
|
|
160
|
+
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' }); },
|
|
161
|
+
ok: (b) => isObj(b) && b.id === 'batch_twin_1' && (b.error === null || isObj(b.error)),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
key: 'POST /v1/batches/{batch_id}/cancel',
|
|
165
|
+
method: 'POST', path: '/v1/batches/batch_twin_1/cancel', statuses: [200],
|
|
166
|
+
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' }); },
|
|
167
|
+
ok: (b) => isObj(b) && b.status === 'CANCELLED',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
key: 'POST /v1/fine-tunes',
|
|
171
|
+
method: 'POST', path: '/v1/fine-tunes', statuses: [200],
|
|
172
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 't.jsonl', content: 'x' }); },
|
|
173
|
+
body: { model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', training_file: 'file_twin_1' },
|
|
174
|
+
ok: (b) => isObj(b) && b.status === 'pending' && typeof b.id === 'string' && b.id.startsWith('ft'),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
key: 'GET /v1/fine-tunes',
|
|
178
|
+
method: 'GET', path: '/v1/fine-tunes', statuses: [200],
|
|
179
|
+
ok: (b) => Array.isArray(b),
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
key: 'GET /v1/fine-tunes/{id}',
|
|
183
|
+
method: 'GET', path: '/v1/fine-tunes/ft_twin_1', statuses: [200],
|
|
184
|
+
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' }); },
|
|
185
|
+
ok: (b) => isObj(b) && b.id === 'ft_twin_1',
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
key: 'POST /v1/fine-tunes/{id}/cancel',
|
|
189
|
+
method: 'POST', path: '/v1/fine-tunes/ft_twin_1/cancel', statuses: [200],
|
|
190
|
+
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' }); },
|
|
191
|
+
ok: (b) => isObj(b) && b.status === 'cancel_requested',
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
// Together's estimate-price is a discriminated union on `estimation_available`
|
|
195
|
+
// (together-ai@0.53.0 fine-tuning.d.ts:1323), not OpenAI's {cost_usd, estimated_tokens}.
|
|
196
|
+
// The probe seeds a real file — an unknown training_file answers the OTHER arm of the union
|
|
197
|
+
// (UnavailableEstimate / train_file_invalid), asserted by the capabilities verify.
|
|
198
|
+
key: 'POST /v1/fine-tunes/estimate-price',
|
|
199
|
+
method: 'POST', path: '/v1/fine-tunes/estimate-price', body: { model: 'm', training_file: 'file_twin_1' }, statuses: [200],
|
|
200
|
+
seed: async (h) => { await h('POST', '/v1/files/upload', { purpose: 'fine-tune', filename: 'est.jsonl', content: '{"text":"r"}\n' }); },
|
|
201
|
+
ok: (b) => isObj(b) && b.estimation_available === true && typeof b.estimated_total_price === 'number',
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
// Together's FinetuneModelLimits for ONE model — `model_name` is REQUIRED
|
|
205
|
+
// (together-ai@0.53.0 fine-tuning.d.ts:1798), so the probe names a catalog model and
|
|
206
|
+
// asserts the shape's REQUIRED keys; an unknown model is a 404, asserted by the
|
|
207
|
+
// capabilities verify.
|
|
208
|
+
key: 'GET /v1/fine-tunes/models/limits',
|
|
209
|
+
method: 'GET', path: '/v1/fine-tunes/models/limits?model_name=meta-llama/Llama-3.3-70B-Instruct-Turbo', statuses: [200],
|
|
210
|
+
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',
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
const ROUTER_SURFACE: Array<{ key: string; method: string; path: string; body?: unknown }> = [
|
|
215
|
+
{ key: 'GET /v1/models', method: 'GET', path: '/v1/models' },
|
|
216
|
+
{ key: 'GET /v1/models/{model}', method: 'GET', path: '/v1/models/meta-llama/Llama-3.3-70B-Instruct-Turbo' },
|
|
217
|
+
{ key: 'POST /v1/chat/completions', method: 'POST', path: '/v1/chat/completions', body: CHAT },
|
|
218
|
+
{ key: 'POST /v1/completions', method: 'POST', path: '/v1/completions', body: { model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', prompt: 'Once' } },
|
|
219
|
+
{ key: 'POST /v1/embeddings', method: 'POST', path: '/v1/embeddings', body: { model: 'WhereIsAI/UAE-Large-V1', input: 'x' } },
|
|
220
|
+
{ key: 'POST /v1/rerank', method: 'POST', path: '/v1/rerank', body: { model: 'Salesforce/Llama-Rank-v1', query: 'q', documents: ['a'] } },
|
|
221
|
+
{ key: 'POST /v1/images/generations', method: 'POST', path: '/v1/images/generations', body: { model: 'black-forest-labs/FLUX.1-schnell', prompt: 'a cat' } },
|
|
222
|
+
{ key: 'POST /v1/audio/speech', method: 'POST', path: '/v1/audio/speech', body: { model: 'cartesia/sonic', input: 'hi', voice: 'female' } },
|
|
223
|
+
{ key: 'POST /v1/audio/transcriptions', method: 'POST', path: '/v1/audio/transcriptions', body: { model: 'cartesia/sonic', file: 'a.wav' } },
|
|
224
|
+
{ key: 'POST /v1/audio/translations', method: 'POST', path: '/v1/audio/translations', body: { model: 'cartesia/sonic', file: 'a.wav' } },
|
|
225
|
+
{ key: 'GET /v1/whoami', method: 'GET', path: '/v1/whoami' },
|
|
226
|
+
{ key: 'POST /v1/files/upload', method: 'POST', path: '/v1/files/upload', body: { purpose: 'fine-tune', filename: 'a.jsonl', content: 'x' } },
|
|
227
|
+
{ key: 'GET /v1/files', method: 'GET', path: '/v1/files' },
|
|
228
|
+
{ key: 'GET /v1/files/{file_id}', method: 'GET', path: '/v1/files/file_twin_1' },
|
|
229
|
+
{ key: 'GET /v1/files/{file_id}/content', method: 'GET', path: '/v1/files/file_twin_1/content' },
|
|
230
|
+
{ key: 'DELETE /v1/files/{file_id}', method: 'DELETE', path: '/v1/files/file_twin_1' },
|
|
231
|
+
{ key: 'POST /v1/batches', method: 'POST', path: '/v1/batches', body: { input_file_id: 'file_twin_1', endpoint: '/v1/chat/completions' } },
|
|
232
|
+
{ key: 'GET /v1/batches', method: 'GET', path: '/v1/batches' },
|
|
233
|
+
{ key: 'GET /v1/batches/{batch_id}', method: 'GET', path: '/v1/batches/batch_twin_1' },
|
|
234
|
+
{ key: 'POST /v1/batches/{batch_id}/cancel', method: 'POST', path: '/v1/batches/batch_twin_1/cancel' },
|
|
235
|
+
{ 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' } },
|
|
236
|
+
{ key: 'GET /v1/fine-tunes', method: 'GET', path: '/v1/fine-tunes' },
|
|
237
|
+
{ key: 'GET /v1/fine-tunes/{id}', method: 'GET', path: '/v1/fine-tunes/ft_twin_1' },
|
|
238
|
+
{ key: 'POST /v1/fine-tunes/{id}/cancel', method: 'POST', path: '/v1/fine-tunes/ft_twin_1/cancel' },
|
|
239
|
+
{ key: 'POST /v1/fine-tunes/estimate-price', method: 'POST', path: '/v1/fine-tunes/estimate-price', body: { model: 'm', training_file: 'f' } },
|
|
240
|
+
{ key: 'GET /v1/fine-tunes/models/limits', method: 'GET', path: '/v1/fine-tunes/models/limits' },
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
/** The literal the router falls through to when NO branch matched. A census entry answering this
|
|
244
|
+
* means its branch is gone. Kept as a LITERAL — importing the handler's string would be a
|
|
245
|
+
* tautology that could not catch the message drifting. */
|
|
246
|
+
const ROUTER_MISS = 'Unknown request URL';
|
|
247
|
+
|
|
248
|
+
const ROUTER_SURFACE_KEYS: string[] = ROUTER_SURFACE.map((e) => e.key);
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Surface the twin must NOT serve: real Together endpoints this twin does not model yet (unmodeled
|
|
252
|
+
* ops fail like the vendor, never a fake success), plus the OpenAI-shaped surface Together does
|
|
253
|
+
* NOT have (Together's OpenAI compatibility is the INFERENCE endpoints only — its Batch/Files/
|
|
254
|
+
* Fine-tuning APIs are Together-native and are served at their own shapes here; assistants,
|
|
255
|
+
* threads, responses and moderations do not exist at Together at all).
|
|
256
|
+
*/
|
|
257
|
+
const MUST_NOT_SERVE: Array<{ method: string; path: string; why: string }> = [
|
|
258
|
+
{ method: 'POST', path: '/v1/responses', why: "OpenAI's Responses API does not exist at Together" },
|
|
259
|
+
{ method: 'POST', path: '/v1/moderations', why: "OpenAI's moderations endpoint does not exist at Together" },
|
|
260
|
+
{ method: 'GET', path: '/v1/threads', why: "OpenAI's Assistants/Threads surface does not exist at Together" },
|
|
261
|
+
{ method: 'POST', path: '/v1/fine_tuning/jobs', why: "OpenAI's fine-tuning shape — Together's own is /v1/fine-tunes (modeled)" },
|
|
262
|
+
{ method: 'POST', path: '/v1/vector_stores', why: "OpenAI's vector stores do not exist at Together" },
|
|
263
|
+
{ method: 'POST', path: '/v2/endpoints', why: 'the v2 management half is real Together surface this twin does not serve' },
|
|
264
|
+
{ method: 'POST', path: '/v1/chat/completions/extra', why: 'a path under /v1 the router does not model' },
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
/** Run the offline conformance checks against a fresh temp root. */
|
|
268
|
+
export async function checkTogetheraiConformance(opts: { root?: string } = {}): Promise<TogetheraiConformanceReport> {
|
|
269
|
+
const violations: ConformanceViolation[] = [];
|
|
270
|
+
let checksRun = 0;
|
|
271
|
+
const fail = (check: string, detail: string) => violations.push({ check, detail });
|
|
272
|
+
// `--root DIR` is the PARENT the throwaway probe roots are minted under; a dir that does not
|
|
273
|
+
// exist yet is created (mkdtempSync would otherwise die on a raw ENOENT).
|
|
274
|
+
if (opts.root !== undefined) mkdirSync(opts.root, { recursive: true });
|
|
275
|
+
|
|
276
|
+
// ── 1. PROBES: each in its OWN throwaway root, so a probe's seed can't leak into another. ──
|
|
277
|
+
for (const probe of PROBES) {
|
|
278
|
+
checksRun++;
|
|
279
|
+
// A FRESH root per probe, always. `opts.root` is the PARENT directory, never a shared root:
|
|
280
|
+
// sharing it let `nextId` ratchet across probes, so the `files/file_twin_1` probe read the id
|
|
281
|
+
// an earlier probe had minted and reported false violations (the groq pack's §9 round two,
|
|
282
|
+
// finding 12).
|
|
283
|
+
const root = mkdtempSync(join(opts.root ?? tmpdir(), 'togetherai-conf-'));
|
|
284
|
+
const h = (method: string, path: string, body?: unknown) =>
|
|
285
|
+
handleTogetheraiTwinRequest({ method, path, ...(body === undefined ? {} : { body: JSON.stringify(body) }), root });
|
|
286
|
+
try {
|
|
287
|
+
if (probe.seed) await probe.seed(h);
|
|
288
|
+
const res = await h(probe.method, probe.path, probe.body);
|
|
289
|
+
if (!probe.statuses.includes(res.status)) {
|
|
290
|
+
fail(`probe:${probe.key}`, `status ${res.status} (expected one of ${probe.statuses.join('/')}) body=${JSON.stringify(res.body).slice(0, 200)}`);
|
|
291
|
+
} else if (!probe.ok(res.body, res.status)) {
|
|
292
|
+
fail(`probe:${probe.key}`, `body did not satisfy the live-handler predicate: ${JSON.stringify(res.body).slice(0, 300)}`);
|
|
293
|
+
}
|
|
294
|
+
} finally {
|
|
295
|
+
rmSync(root, { recursive: true, force: true });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── 2. BIJECTION: probe table ⇄ router census, both directions. ──
|
|
300
|
+
checksRun++;
|
|
301
|
+
const probeKeys = new Set(PROBES.map((p) => p.key));
|
|
302
|
+
for (const key of ROUTER_SURFACE_KEYS) if (!probeKeys.has(key)) fail('bijection', `router branch "${key}" has no probe`);
|
|
303
|
+
for (const key of probeKeys) if (!ROUTER_SURFACE_KEYS.includes(key)) fail('bijection', `probe "${key}" is not in the hand-authored router census`);
|
|
304
|
+
if (PROBES.length !== new Set(PROBES.map((p) => p.key)).size) fail('bijection', 'duplicate probe key');
|
|
305
|
+
if (ROUTER_SURFACE.length !== ROUTER_SURFACE_KEYS.length) fail('bijection', 'the router census and its key list disagree');
|
|
306
|
+
|
|
307
|
+
// ── 2b. THE CENSUS HAS TEETH: every declared branch must actually be REACHED. A branch whose
|
|
308
|
+
// handler was deleted falls through to the router's own not-found, which is what this
|
|
309
|
+
// catches — independently of that endpoint's own probe.
|
|
310
|
+
for (const entry of ROUTER_SURFACE) {
|
|
311
|
+
checksRun++;
|
|
312
|
+
const root = mkdtempSync(join(opts.root ?? tmpdir(), 'togetherai-conf-'));
|
|
313
|
+
try {
|
|
314
|
+
const res = await handleTogetheraiTwinRequest({ method: entry.method, path: entry.path, ...(entry.body === undefined ? {} : { body: JSON.stringify(entry.body) }), root });
|
|
315
|
+
const message = String(((res.body as Body)?.error as Body)?.message ?? '');
|
|
316
|
+
if (message.includes(ROUTER_MISS)) {
|
|
317
|
+
fail('router_surface', `${entry.key} fell through to the router's not-found — the branch is gone (answered ${res.status}: ${message})`);
|
|
318
|
+
}
|
|
319
|
+
if (!ROUTER_SURFACE_KEYS.includes(entry.key)) fail('router_surface', `${entry.key} is exercised but not declared`);
|
|
320
|
+
} finally {
|
|
321
|
+
rmSync(root, { recursive: true, force: true });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── 3. The twin must not serve surface it does not model (or the vendor does not have). ──
|
|
326
|
+
{
|
|
327
|
+
const root = opts.root ?? mkdtempSync(join(tmpdir(), 'togetherai-conf-'));
|
|
328
|
+
try {
|
|
329
|
+
for (const entry of MUST_NOT_SERVE) {
|
|
330
|
+
checksRun++;
|
|
331
|
+
const res = await handleTogetheraiTwinRequest({ method: entry.method, path: entry.path, root });
|
|
332
|
+
if (res.status !== 404) fail('must_not_serve', `${entry.method} ${entry.path} answered ${res.status}, not 404 — ${entry.why}`);
|
|
333
|
+
else if (!isObj(res.body) || !isObj((res.body as Body).error)) fail('must_not_serve', `${entry.method} ${entry.path} 404 body is not the vendor error envelope`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── 4. The error envelope must carry the keys Together's own published schema declares
|
|
337
|
+
// (`ErrorData`: message + type REQUIRED, param + code nullable-with-default-null).
|
|
338
|
+
// The key list is a LITERAL here — asserting against the handler's own constant would
|
|
339
|
+
// be a tautology that could not catch it drifting.
|
|
340
|
+
checksRun++;
|
|
341
|
+
const ERROR_DATA_KEYS = new Set(['message', 'type', 'param', 'code']);
|
|
342
|
+
const bad = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', body: JSON.stringify({ model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', messages: [] }), root });
|
|
343
|
+
const eb = bad.body as Body;
|
|
344
|
+
if (bad.status !== 400 || !isObj(eb?.error)) {
|
|
345
|
+
fail('error.envelope', `empty messages did not yield a 400 with an error envelope (status ${bad.status})`);
|
|
346
|
+
} else {
|
|
347
|
+
const undeclared = Object.keys(eb.error).filter((k) => !ERROR_DATA_KEYS.has(k));
|
|
348
|
+
if (undeclared.length) fail('error.envelope', `error object carries key(s) Together's ErrorData does not declare: ${undeclared.join(', ')}`);
|
|
349
|
+
if (typeof eb.error.message !== 'string' || !eb.error.message) fail('error.envelope', 'error.message is not a non-empty string');
|
|
350
|
+
if (typeof eb.error.type !== 'string' || !eb.error.type) fail('error.envelope', 'error.type is not a non-empty string');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── 4b. Together's OWN status table (docs.together.ai/docs/error-codes, read 2026-09-16):
|
|
354
|
+
// 402 = monthly spending limit; 403 = context length exceeded; 503 = engine overloaded.
|
|
355
|
+
// A twin answering 400/429/500 there would be OpenAI's table, not Together's.
|
|
356
|
+
checksRun++;
|
|
357
|
+
const long = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify({ ...CHAT, max_tokens: 200_000 }) });
|
|
358
|
+
if (long.status !== 403) fail('vendor_status_table', `over-window request answered ${long.status}, not Together's 403`);
|
|
359
|
+
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' } });
|
|
360
|
+
if (forced402.status !== 402) fail('vendor_status_table', `the spending-limit trigger answered ${forced402.status}, not Together's 402`);
|
|
361
|
+
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' } });
|
|
362
|
+
if (forced503.status !== 503) fail('vendor_status_table', `the engine-overloaded trigger answered ${forced503.status}, not Together's 503`);
|
|
363
|
+
|
|
364
|
+
// ── 4c. The fields Together ACCEPTS BUT IGNORES must not change the answer, and the fields
|
|
365
|
+
// it 400s must not be served (docs.together.ai openai-compatibility, read 2026-09-16).
|
|
366
|
+
checksRun++;
|
|
367
|
+
const plain = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify(CHAT) });
|
|
368
|
+
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' } }) });
|
|
369
|
+
if (plain.status !== 200 || decorated.status !== 200) fail('accepted_ignored_fields', `plain=${plain.status} decorated=${decorated.status}`);
|
|
370
|
+
else if ((plain.body as Body).choices[0].message.content !== (decorated.body as Body).choices[0].message.content) {
|
|
371
|
+
fail('accepted_ignored_fields', 'an accepted-but-ignored field changed the answer');
|
|
372
|
+
}
|
|
373
|
+
const n129 = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify({ ...CHAT, n: 129 }) });
|
|
374
|
+
if (n129.status !== 400) fail('rejected_fields', 'n:129 answered ' + n129.status + ', not 400');
|
|
375
|
+
const logprobs21 = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify({ ...CHAT, logprobs: 21 }) });
|
|
376
|
+
if (logprobs21.status !== 400) fail('rejected_fields', 'logprobs:21 answered ' + logprobs21.status + ', not 400');
|
|
377
|
+
const badBehavior = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify({ ...CHAT, context_length_exceeded_behavior: 'shrink' }) });
|
|
378
|
+
if (badBehavior.status !== 400) fail('rejected_fields', 'an off-enum context_length_exceeded_behavior answered ' + badBehavior.status + ', not 400');
|
|
379
|
+
const noSchemaName = await handleTogetheraiTwinRequest({ method: 'POST', path: '/v1/chat/completions', root, body: JSON.stringify({ ...CHAT, response_format: { type: 'json_schema', json_schema: { schema: {} } } }) });
|
|
380
|
+
if (noSchemaName.status !== 400) fail('rejected_fields', 'json_schema without .name answered ' + noSchemaName.status + ', not 400');
|
|
381
|
+
|
|
382
|
+
// ── 5. The streaming chunk sequence: role chunk → content/reasoning/tool deltas →
|
|
383
|
+
// finish_reason chunk → Together's usage-bearing tail (empty choices) → [DONE].
|
|
384
|
+
// Every chunk carries Together's nullable usage + warnings (the schema declares both
|
|
385
|
+
// on ChatCompletionChunk itself — OpenAI puts usage only in an opt-in tail chunk).
|
|
386
|
+
checksRun++;
|
|
387
|
+
const events: SseEvent[] = [];
|
|
388
|
+
await handleTogetheraiTwinRequest({
|
|
389
|
+
method: 'POST', path: '/v1/chat/completions', root,
|
|
390
|
+
body: JSON.stringify({ ...CHAT, stream: true }),
|
|
391
|
+
sseSink: (e) => events.push(e),
|
|
392
|
+
});
|
|
393
|
+
const data = events.filter((e) => !e.done).map((e) => e.data as Body);
|
|
394
|
+
const hasRole = data.some((c) => ((c.choices as Body[])?.[0]?.delta as Body)?.role === 'assistant');
|
|
395
|
+
const doneFrame = events.length > 0 && events[events.length - 1]!.done === true;
|
|
396
|
+
const firstObj = data[0]?.object;
|
|
397
|
+
const tail = data[data.length - 1];
|
|
398
|
+
if (!hasRole) fail('chat.stream', 'no role delta chunk');
|
|
399
|
+
if (!doneFrame) fail('chat.stream', 'stream did not end with [DONE]');
|
|
400
|
+
if (firstObj !== 'chat.completion.chunk') fail('chat.stream', `first chunk object is ${String(firstObj)}`);
|
|
401
|
+
if (!data.every((c) => 'usage' in c && 'warnings' in c)) fail('chat.stream', 'a chunk is missing Together\'s usage/warnings keys');
|
|
402
|
+
if (!tail || !Array.isArray(tail.choices) || tail.choices.length !== 0 || !isObj(tail.usage) || typeof tail.usage.total_tokens !== 'number') {
|
|
403
|
+
fail('chat.stream', "final chunk is not Together's empty-choices usage tail");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── 5b. The LEGACY completions endpoint streams too (the SDK types it
|
|
407
|
+
// Stream<CompletionChunk>): token/text chunks with `object: 'completion.chunk'`,
|
|
408
|
+
// then the finish chunk carrying the real usage, then [DONE].
|
|
409
|
+
checksRun++;
|
|
410
|
+
const compEvents: SseEvent[] = [];
|
|
411
|
+
await handleTogetheraiTwinRequest({
|
|
412
|
+
method: 'POST', path: '/v1/completions', root,
|
|
413
|
+
body: JSON.stringify({ model: CHAT.model, prompt: 'Once', stream: true, max_tokens: 20 }),
|
|
414
|
+
sseSink: (e) => compEvents.push(e),
|
|
415
|
+
});
|
|
416
|
+
const compData = compEvents.filter((e) => !e.done).map((e) => e.data as Body);
|
|
417
|
+
const compTail = compData[compData.length - 1];
|
|
418
|
+
if (compData.length === 0) fail('completions.stream', 'no chunks emitted');
|
|
419
|
+
if (!compData.every((c) => c.object === 'completion.chunk')) fail('completions.stream', "a chunk's object is not 'completion.chunk'");
|
|
420
|
+
if (!compData.every((c) => 'usage' in c)) fail('completions.stream', "a chunk is missing Together's nullable usage key");
|
|
421
|
+
if (!compData.slice(0, -1).every((c) => c.finish_reason === null)) fail('completions.stream', 'a mid-stream chunk carries a finish_reason');
|
|
422
|
+
if (!compTail || compTail.finish_reason === null || !isObj(compTail.usage) || typeof compTail.usage.total_tokens !== 'number') {
|
|
423
|
+
fail('completions.stream', 'the finish chunk does not carry finish_reason + the real usage');
|
|
424
|
+
}
|
|
425
|
+
const compDone = compEvents.length > 0 && compEvents[compEvents.length - 1]!.done === true;
|
|
426
|
+
if (!compDone) fail('completions.stream', 'stream did not end with [DONE]');
|
|
427
|
+
|
|
428
|
+
// ── 6. tool_calls envelope when tools are provided, and the message shape Together's
|
|
429
|
+
// schema declares (reasoning/reasoning_content, NO OpenAI `refusal`).
|
|
430
|
+
checksRun++;
|
|
431
|
+
const tool = await handleTogetheraiTwinRequest({
|
|
432
|
+
method: 'POST', path: '/v1/chat/completions', root,
|
|
433
|
+
body: JSON.stringify({ ...CHAT, tools: [{ type: 'function', function: { name: 'get_weather', parameters: { type: 'object', properties: { city: { type: 'string' } } } } }] }),
|
|
434
|
+
});
|
|
435
|
+
const choice = ((tool.body as Body).choices as Body[])?.[0];
|
|
436
|
+
if (choice?.finish_reason !== 'tool_calls') fail('chat.tool_calls', 'finish_reason not tool_calls');
|
|
437
|
+
const calls = choice?.message?.tool_calls as Body[];
|
|
438
|
+
if (!Array.isArray(calls) || calls[0]?.type !== 'function' || calls[0]?.function?.name !== 'get_weather') fail('chat.tool_calls', 'no get_weather function tool_call');
|
|
439
|
+
if (choice && 'refusal' in (choice.message as Body)) fail('chat.message_shape', "assistant message carries a `refusal` field Together's schema does not define");
|
|
440
|
+
|
|
441
|
+
// ── 7. The deprecated `functions` parameter answers with the deprecated `function_call`
|
|
442
|
+
// response shape, not `tool_calls` (Together's ChatCompletionMessage.function_call +
|
|
443
|
+
// FinishReason 'function_call').
|
|
444
|
+
checksRun++;
|
|
445
|
+
const legacy = await handleTogetheraiTwinRequest({
|
|
446
|
+
method: 'POST', path: '/v1/chat/completions', root,
|
|
447
|
+
body: JSON.stringify({ ...CHAT, functions: [{ name: 'legacy_fn', parameters: { type: 'object', properties: { q: { type: 'string' } } } }] }),
|
|
448
|
+
});
|
|
449
|
+
const lc = ((legacy.body as Body).choices as Body[])?.[0];
|
|
450
|
+
if (lc?.finish_reason !== 'function_call') fail('chat.legacy_functions', `finish_reason is ${String(lc?.finish_reason)}, not function_call`);
|
|
451
|
+
if (lc?.message?.function_call?.name !== 'legacy_fn') fail('chat.legacy_functions', 'no function_call on the assistant message');
|
|
452
|
+
if (lc?.message?.tool_calls !== undefined) fail('chat.legacy_functions', 'legacy functions must not answer with tool_calls');
|
|
453
|
+
} finally {
|
|
454
|
+
if (opts.root === undefined) rmSync(root, { recursive: true, force: true });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return { ok: violations.length === 0, checksRun, probes: PROBES.length, violations };
|
|
459
|
+
}
|