@volter/twin-ai-gateway 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.
Files changed (42) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +176 -0
  3. package/dist/src/ai-gateway-capabilities.d.ts +4 -0
  4. package/dist/src/ai-gateway-capabilities.js +772 -0
  5. package/dist/src/ai-gateway-conformance.d.ts +11 -0
  6. package/dist/src/ai-gateway-conformance.js +72 -0
  7. package/dist/src/ai-gateway-connector.d.ts +50 -0
  8. package/dist/src/ai-gateway-connector.js +97 -0
  9. package/dist/src/ai-gateway-models.d.ts +27 -0
  10. package/dist/src/ai-gateway-models.js +65 -0
  11. package/dist/src/ai-gateway-perform-harness.d.ts +5 -0
  12. package/dist/src/ai-gateway-perform-harness.js +17 -0
  13. package/dist/src/ai-gateway-scenario.d.ts +36 -0
  14. package/dist/src/ai-gateway-scenario.js +125 -0
  15. package/dist/src/ai-gateway-server.d.ts +16 -0
  16. package/dist/src/ai-gateway-server.js +107 -0
  17. package/dist/src/ai-gateway-stub.d.ts +20 -0
  18. package/dist/src/ai-gateway-stub.js +124 -0
  19. package/dist/src/ai-gateway-twin.d.ts +2 -0
  20. package/dist/src/ai-gateway-twin.js +994 -0
  21. package/dist/src/ai-gateway-types.d.ts +94 -0
  22. package/dist/src/ai-gateway-types.js +1 -0
  23. package/dist/src/ai-gateway-v3.d.ts +5 -0
  24. package/dist/src/ai-gateway-v3.js +367 -0
  25. package/dist/src/cli.d.ts +2 -0
  26. package/dist/src/cli.js +24 -0
  27. package/dist/src/index.d.ts +12 -0
  28. package/dist/src/index.js +54 -0
  29. package/package.json +66 -0
  30. package/src/ai-gateway-capabilities.ts +894 -0
  31. package/src/ai-gateway-conformance.ts +77 -0
  32. package/src/ai-gateway-connector.ts +100 -0
  33. package/src/ai-gateway-models.ts +105 -0
  34. package/src/ai-gateway-perform-harness.ts +17 -0
  35. package/src/ai-gateway-scenario.ts +137 -0
  36. package/src/ai-gateway-server.ts +122 -0
  37. package/src/ai-gateway-stub.ts +115 -0
  38. package/src/ai-gateway-twin.ts +1068 -0
  39. package/src/ai-gateway-types.ts +96 -0
  40. package/src/ai-gateway-v3.ts +372 -0
  41. package/src/cli.ts +23 -0
  42. package/src/index.ts +67 -0
@@ -0,0 +1,772 @@
1
+ // Capability manifest — the REAL Vercel AI Gateway surface as the denominator, authored
2
+ // top-down from Vercel's docs (OpenAI Chat Completions API family incl. Advanced Configuration
3
+ // and Structured Outputs, the Responses/Anthropic-Messages/OpenResponses compatibility APIs,
4
+ // the REST API Reference: models/credits/generation/report, provider options/filtering/sorting,
5
+ // usage & billing, authentication & BYOK) PLUS the AI SDK gateway protocol (/v3/ai) that
6
+ // `@ai-sdk/gateway` / the `ai` package's `createGateway` — the AI SDK's default provider path —
7
+ // actually speaks (derived from the shipped SDK: GET /config, POST /language-model with
8
+ // ai-language-model-* headers, POST /embedding-model|image-model|video-model). Coverage is
9
+ // honest and partial: unmodeled surfaces are `todo`; only the genuinely-impossible-for-a-
10
+ // local twin serves deterministically is `done`; anything unserved is `todo`.
11
+ //
12
+ // No UI capabilities: the AI Gateway is an API product — integrators call it; the Vercel
13
+ // dashboard is incidental key/spend tooling, not where the work happens ("Does this vendor get
14
+ // a mirror?" in docs/contributing/adding-a-twin.md). See README ## Coverage → ### No UI mirror.
15
+ //
16
+ // Scenario scripting (ai-gateway-scenario.ts) is deliberately NOT a capability here — it is
17
+ // twin-only test scaffolding outside the vendor surface (anthropic-pack convention).
18
+ import { mkdtempSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { applyTwinWrite, pendingActions, projectResources } from '@volter/world-core';
22
+ import { checkCapabilities } from '@volter/world-tooling';
23
+ import { handleAiGatewayTwinRequest } from "./ai-gateway-twin.js";
24
+ import { pullAiGatewayState, } from "./ai-gateway-connector.js";
25
+ import { performPending } from "./ai-gateway-perform-harness.js";
26
+ async function withRoot(fn) {
27
+ const root = mkdtempSync(join(tmpdir(), 'ai-gateway-cap-'));
28
+ const h = (step) => handleAiGatewayTwinRequest({
29
+ method: step.m,
30
+ path: step.p,
31
+ body: step.b === undefined ? undefined : JSON.stringify(step.b),
32
+ root,
33
+ ...(step.hd ? { headers: step.hd } : {}),
34
+ ...(step.sseSink ? { sseSink: step.sseSink } : {}),
35
+ });
36
+ try {
37
+ return await fn(h);
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ finally {
43
+ rmSync(root, { recursive: true, force: true });
44
+ }
45
+ }
46
+ const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
47
+ const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
48
+ const CHAT = (extra = {}) => ({
49
+ model: 'anthropic/claude-sonnet-4.6',
50
+ messages: [{ role: 'user', content: 'hello ai gateway twin' }],
51
+ ...extra,
52
+ });
53
+ export const AI_GATEWAY_CAPABILITIES = [
54
+ // ── chat completions (POST /v1/chat/completions) ─────────────────────────────────────
55
+ done('ai-gateway.chat.create', 'chat', 'Chat completions: create returns OpenAI-compatible envelope with a gen_ generation id', 'api', 'core', () => withRoot(async (h) => {
56
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() });
57
+ const b = r.body;
58
+ return r.status === 200 && b.object === 'chat.completion' && b.model === 'anthropic/claude-sonnet-4.6' &&
59
+ String(b.id).startsWith('gen_') && b.choices?.[0]?.message?.role === 'assistant' &&
60
+ b.choices?.[0]?.finish_reason === 'stop' && typeof b.created === 'number' &&
61
+ b.usage?.total_tokens === b.usage?.prompt_tokens + b.usage?.completion_tokens;
62
+ })),
63
+ done('ai-gateway.chat.stub_labeled', 'chat', 'Chat output is clearly labeled as a twin stub and echoes the prompt', 'api', 'core', () => withRoot(async (h) => {
64
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() });
65
+ const text = r.body.choices?.[0]?.message?.content;
66
+ return r.status === 200 && text.includes('[twin-stub:ai-gateway') && text.includes('hello ai gateway twin');
67
+ })),
68
+ done('ai-gateway.chat.validation', 'chat', 'Chat rejects missing model / empty or malformed messages with the vendor error envelope', 'api', 'core', () => withRoot(async (h) => {
69
+ const noModel = await h({ m: 'POST', p: '/v1/chat/completions', b: { messages: [{ role: 'user', content: 'x' }] } });
70
+ const empty = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ messages: [] }) });
71
+ const noRole = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ messages: [{ content: 'x' }] }) });
72
+ const badRole = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ messages: [{ role: 'wizard', content: 'x' }] }) });
73
+ const unknownModel = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ model: 'nope/nope' }) });
74
+ return noModel.status === 400 && noModel.body.error?.code === 'missing_parameter' && noModel.body.error?.param === 'model' &&
75
+ empty.status === 400 && noRole.status === 400 && badRole.status === 400 &&
76
+ badRole.body.error?.type === 'invalid_request_error' &&
77
+ unknownModel.status === 404 && unknownModel.body.error?.code === 'model_not_found';
78
+ })),
79
+ done('ai-gateway.chat.system_and_multi_turn', 'chat', 'Chat accepts system/developer turns and multi-turn history; usage counts every turn', 'api', 'core', () => withRoot(async (h) => {
80
+ const single = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() });
81
+ const multi = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
82
+ messages: [
83
+ { role: 'system', content: 'You are a terse assistant.' },
84
+ { role: 'user', content: 'first question' },
85
+ { role: 'assistant', content: 'first answer' },
86
+ { role: 'user', content: 'hello ai gateway twin' },
87
+ ],
88
+ }) });
89
+ const echoed = multi.body.choices?.[0]?.message?.content;
90
+ return single.status === 200 && multi.status === 200 && echoed.includes('hello ai gateway twin') &&
91
+ multi.body.usage.prompt_tokens > single.body.usage.prompt_tokens;
92
+ })),
93
+ done('ai-gateway.chat.max_tokens', 'chat', 'Chat honors max_tokens/max_completion_tokens as a length cap (finish_reason length)', 'api', 'common', () => withRoot(async (h) => {
94
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ max_tokens: 1 }) });
95
+ const alias = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ max_completion_tokens: 1 }) });
96
+ const bad = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ max_tokens: 0 }) });
97
+ return r.status === 200 && r.body.choices?.[0]?.finish_reason === 'length' &&
98
+ alias.status === 200 && alias.body.choices?.[0]?.finish_reason === 'length' &&
99
+ bad.status === 400 && bad.body.error?.param === 'max_tokens';
100
+ })),
101
+ done('ai-gateway.chat.n_choices', 'chat', 'n (OpenAI-spec passthrough) produces multiple choices with stable indexes and n-scaled usage; the twin deterministically repeats the same stub per choice', 'api', 'niche', () => withRoot(async (h) => {
102
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ n: 3 }) });
103
+ const one = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() });
104
+ const choices = r.body.choices;
105
+ const bad = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ n: 0 }) });
106
+ return r.status === 200 && choices?.length === 3 && choices[0].index === 0 && choices[2].index === 2 &&
107
+ r.body.usage?.completion_tokens === 3 * one.body.usage?.completion_tokens &&
108
+ bad.status === 400 && bad.body.error?.param === 'n';
109
+ })),
110
+ done('ai-gateway.chat.sampling_params', 'chat', 'Sampling params (temperature/top_p/penalties/stop) validated to their documented ranges', 'api', 'common', () => withRoot(async (h) => {
111
+ const ok = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ temperature: 0.7, top_p: 0.9, frequency_penalty: 0.1, presence_penalty: -0.1, stop: ['\n'] }) });
112
+ const badTemp = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ temperature: 3 }) });
113
+ const badTopP = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ top_p: 2 }) });
114
+ const badStop = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ stop: 42 }) });
115
+ return ok.status === 200 && badTemp.status === 400 && badTemp.body.error?.param === 'temperature' &&
116
+ badTopP.status === 400 && badStop.status === 400;
117
+ })),
118
+ done('ai-gateway.chat.image_input', 'chat', 'Multimodal messages: image_url parts (base64 data URLs) are accepted and validated', 'api', 'common', () => withRoot(async (h) => {
119
+ const ok = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
120
+ messages: [{ role: 'user', content: [
121
+ { type: 'text', text: 'describe this image' },
122
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,aGVsbG8=', detail: 'auto' } },
123
+ ] }],
124
+ }) });
125
+ const noUrl = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
126
+ messages: [{ role: 'user', content: [{ type: 'image_url', image_url: {} }] }],
127
+ }) });
128
+ const badPart = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
129
+ messages: [{ role: 'user', content: [{ type: 'hologram' }] }],
130
+ }) });
131
+ const text = ok.body.choices?.[0]?.message?.content;
132
+ return ok.status === 200 && text.includes('describe this image') &&
133
+ noUrl.status === 400 && badPart.status === 400 && badPart.body.error?.message?.includes('hologram');
134
+ })),
135
+ done('ai-gateway.chat.file_input', 'chat', 'PDF/file attachments: file parts with base64 data are accepted and validated', 'api', 'common', () => withRoot(async (h) => {
136
+ const ok = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
137
+ messages: [{ role: 'user', content: [
138
+ { type: 'text', text: 'summarize this document' },
139
+ { type: 'file', file: { data: 'JVBERi0=', media_type: 'application/pdf', filename: 'doc.pdf' } },
140
+ ] }],
141
+ }) });
142
+ const noData = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
143
+ messages: [{ role: 'user', content: [{ type: 'file', file: { filename: 'doc.pdf' } }] }],
144
+ }) });
145
+ return ok.status === 200 && String(ok.body.choices?.[0]?.message?.content).includes('summarize this document') &&
146
+ noData.status === 400;
147
+ })),
148
+ done('ai-gateway.chat.provider_metadata', 'chat', 'Responses carry providerMetadata.gateway (routing, decimal-string cost, generationId)', 'api', 'core', () => withRoot(async (h) => {
149
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() });
150
+ const gw = r.body.providerMetadata?.gateway;
151
+ return r.status === 200 && typeof gw?.cost === 'string' && Number(gw.cost) > 0 && gw.generationId === r.body.id &&
152
+ gw.routing?.originalModelId === 'anthropic/claude-sonnet-4.6' && gw.routing?.finalProvider === 'anthropic' &&
153
+ gw.routing?.modelAttempts?.[0]?.providerAttempts?.[0]?.credentialType === 'system';
154
+ })),
155
+ todo('ai-gateway.chat.web_search', 'chat', 'Web search augmentation (billable web search calls, search-augmented responses)', 'api', 'common'),
156
+ todo('ai-gateway.chat.reporting_tags', 'chat', 'Reporting attribution: providerOptions.gateway.{user,tags}, the standard user field, and the ai-reporting-tags/ai-reporting-user headers (validation limits + generation attribution)', 'api', 'niche'),
157
+ todo('ai-gateway.chat.zdr', 'chat', 'Zero Data Retention request option and its surcharge accounting', 'api', 'niche'),
158
+ todo('ai-gateway.limits.rate_limit_429', 'limits', 'Rate-limit 429 Too Many Requests envelope (deterministic twin-armed trigger, figma-style)', 'api', 'common'),
159
+ // ── streaming ────────────────────────────────────────────────────────────────────────
160
+ done('ai-gateway.streaming.chunks', 'streaming', 'Streaming emits chat.completion.chunk SSE events and terminates with [DONE]', 'api', 'core', () => withRoot(async (h) => {
161
+ const events = [];
162
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ stream: true }), sseSink: (event) => events.push(event) });
163
+ return r.status === 200 && events.some((event) => event.data?.object === 'chat.completion.chunk') && events.at(-1)?.done === true;
164
+ })),
165
+ done('ai-gateway.streaming.reconstruct', 'streaming', 'Streaming content deltas reconstruct the final message content', 'api', 'core', () => withRoot(async (h) => {
166
+ const events = [];
167
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ stream: true }), sseSink: (event) => events.push(event) });
168
+ const streamed = events.filter((event) => event.data).map((event) => (event.data.choices[0]?.delta?.content ?? '')).join('');
169
+ const full = r.body.choices?.[0]?.message?.content;
170
+ return streamed === full && streamed.includes('[twin-stub');
171
+ })),
172
+ done('ai-gateway.streaming.generation_id_first_chunk', 'streaming', 'The gen_ generation id rides on the FIRST streamed chunk (documented for stream capture)', 'api', 'common', () => withRoot(async (h) => {
173
+ const events = [];
174
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ stream: true }), sseSink: (event) => events.push(event) });
175
+ const firstId = String(events[0]?.data?.id ?? '');
176
+ return firstId.startsWith('gen_') && firstId === r.body.id;
177
+ })),
178
+ done('ai-gateway.streaming.final_usage', 'streaming', 'The final streamed chunk carries finish_reason and usage', 'api', 'common', () => withRoot(async (h) => {
179
+ const events = [];
180
+ await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ stream: true }), sseSink: (event) => events.push(event) });
181
+ const last = events.filter((e) => e.data).at(-1)?.data;
182
+ return last?.choices?.[0]?.finish_reason === 'stop' && last?.usage?.prompt_tokens > 0;
183
+ })),
184
+ done('ai-gateway.streaming.tool_call_deltas', 'streaming', 'Streaming tool calls arrive as OpenAI tool_calls deltas (id/name first, then arguments)', 'api', 'common', () => withRoot(async (h) => {
185
+ const events = [];
186
+ await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ stream: true, tools: [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object', properties: { q: { type: 'string' } } } } }] }), sseSink: (event) => events.push(event) });
187
+ const deltas = events.filter((e) => e.data).map((e) => e.data.choices[0]?.delta?.tool_calls?.[0]).filter(Boolean);
188
+ const args = deltas.map((d) => d.function?.arguments ?? '').join('');
189
+ return deltas[0]?.function?.name === 'lookup' && JSON.parse(args).q === 'twin stub' &&
190
+ events.filter((e) => e.data).some((e) => e.data.choices[0]?.finish_reason === 'tool_calls');
191
+ })),
192
+ done('ai-gateway.streaming.n_choices_parity', 'streaming', 'Streaming with n>1 emits every choice index the unary body carries and bills — each reconstructs the full content, each finishes, usage rides the final chunk', 'api', 'niche', () => withRoot(async (h) => {
193
+ const events = [];
194
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ stream: true, n: 3 }), sseSink: (event) => events.push(event) });
195
+ const body = r.body;
196
+ const datas = events.filter((e) => e.data).map((e) => e.data);
197
+ const byIndex = (i) => datas.filter((d) => d.choices[0]?.index === i).map((d) => d.choices[0]);
198
+ const contentOf = (i) => byIndex(i).map((c) => c.delta?.content ?? '').join('');
199
+ const finished = (i) => byIndex(i).some((c) => c.finish_reason === 'stop');
200
+ const last = datas.at(-1);
201
+ return r.status === 200 && body.choices.length === 3 &&
202
+ [0, 1, 2].every((i) => contentOf(i) === body.choices[i].message?.content && finished(i)) &&
203
+ last.usage?.completion_tokens === body.usage?.completion_tokens &&
204
+ datas.filter((d) => d.usage).length === 1; // usage exactly once, on the final chunk
205
+ })),
206
+ // ── tool calls ───────────────────────────────────────────────────────────────────────
207
+ done('ai-gateway.tools.tool_call', 'tools', 'Function tools produce a tool_calls envelope with schema-conformant arguments', 'api', 'core', () => withRoot(async (h) => {
208
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
209
+ tools: [{ type: 'function', function: { name: 'get_weather', parameters: { type: 'object', properties: { city: { type: 'string' }, celsius: { type: 'boolean' } } } } }],
210
+ }) });
211
+ const choice = r.body.choices?.[0];
212
+ const call = choice?.message?.tool_calls?.[0];
213
+ const parsedArgs = JSON.parse(String(call?.function?.arguments ?? '{}'));
214
+ return r.status === 200 && choice?.finish_reason === 'tool_calls' && call?.type === 'function' &&
215
+ call?.function?.name === 'get_weather' && String(call?.id).startsWith('call_') &&
216
+ parsedArgs.city === 'twin stub' && parsedArgs.celsius === true && choice?.message?.content === null;
217
+ })),
218
+ done('ai-gateway.tools.tool_choice', 'tools', "tool_choice: 'none' suppresses calls, a named function forces it, unknown names are rejected", 'api', 'common', () => withRoot(async (h) => {
219
+ const tools = [
220
+ { type: 'function', function: { name: 'alpha', parameters: { type: 'object' } } },
221
+ { type: 'function', function: { name: 'beta', parameters: { type: 'object' } } },
222
+ ];
223
+ const none = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ tools, tool_choice: 'none' }) });
224
+ const named = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ tools, tool_choice: { type: 'function', function: { name: 'beta' } } }) });
225
+ const unknown = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ tools, tool_choice: { type: 'function', function: { name: 'gamma' } } }) });
226
+ const badShape = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ tools, tool_choice: 'sometimes' }) });
227
+ return none.body.choices?.[0]?.finish_reason === 'stop' &&
228
+ named.body.choices?.[0]?.message?.tool_calls?.[0]?.function?.name === 'beta' &&
229
+ unknown.status === 400 && badShape.status === 400;
230
+ })),
231
+ done('ai-gateway.tools.tool_result_roundtrip', 'tools', 'Tool role messages (with tool_call_id) complete the round trip back to assistant text', 'api', 'common', () => withRoot(async (h) => {
232
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
233
+ messages: [
234
+ { role: 'user', content: 'what is the weather?' },
235
+ { role: 'assistant', content: null, tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'get_weather', arguments: '{}' } }] },
236
+ { role: 'tool', tool_call_id: 'call_1', content: '{"temp": 21}' },
237
+ ],
238
+ }) });
239
+ const missing = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
240
+ messages: [{ role: 'tool', content: 'orphan result' }],
241
+ }) });
242
+ return r.status === 200 && r.body.choices?.[0]?.message?.role === 'assistant' &&
243
+ r.body.choices?.[0]?.finish_reason === 'stop' &&
244
+ missing.status === 400 && missing.body.error?.message?.includes('tool_call_id');
245
+ })),
246
+ done('ai-gateway.tools.validation', 'tools', 'Malformed tools arrays are rejected with the vendor error envelope', 'api', 'common', () => withRoot(async (h) => {
247
+ const notArray = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ tools: 'lookup' }) });
248
+ const nameless = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ tools: [{ type: 'function', function: {} }] }) });
249
+ return notArray.status === 400 && notArray.body.error?.param === 'tools' && nameless.status === 400;
250
+ })),
251
+ // ── structured outputs ───────────────────────────────────────────────────────────────
252
+ done('ai-gateway.structured.json_schema', 'structured_outputs', 'response_format json_schema (OpenAI format) yields schema-conformant JSON', 'api', 'core', () => withRoot(async (h) => {
253
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
254
+ response_format: { type: 'json_schema', json_schema: { name: 'weather', strict: true, schema: {
255
+ type: 'object', properties: { city: { type: 'string' }, temp: { type: 'number' }, sunny: { type: 'boolean' } },
256
+ } } },
257
+ }) });
258
+ const content = r.body.choices?.[0]?.message?.content;
259
+ let parsed = null;
260
+ try {
261
+ parsed = JSON.parse(content);
262
+ }
263
+ catch {
264
+ parsed = null;
265
+ }
266
+ const noName = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ response_format: { type: 'json_schema', json_schema: {} } }) });
267
+ return r.status === 200 && parsed?.city === 'twin stub' && parsed?.temp === 0 && parsed?.sunny === true &&
268
+ noName.status === 400 && noName.body.error?.param === 'response_format';
269
+ })),
270
+ done('ai-gateway.structured.legacy_json', 'structured_outputs', "Legacy gateway response_format { type: 'json', schema } yields parseable JSON", 'api', 'common', () => withRoot(async (h) => {
271
+ const withSchema = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
272
+ response_format: { type: 'json', schema: { type: 'object', properties: { answer: { type: 'string' } } } },
273
+ }) });
274
+ const bare = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ response_format: { type: 'json' } }) });
275
+ const badType = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ response_format: { type: 'csv' } }) });
276
+ let schemaParsed = null;
277
+ let bareParsed = null;
278
+ try {
279
+ schemaParsed = JSON.parse(withSchema.body.choices?.[0]?.message?.content);
280
+ bareParsed = JSON.parse(bare.body.choices?.[0]?.message?.content);
281
+ }
282
+ catch {
283
+ return false;
284
+ }
285
+ return withSchema.status === 200 && schemaParsed?.answer === 'twin stub' &&
286
+ bare.status === 200 && bareParsed?.twin_stub === true &&
287
+ badType.status === 400 && badType.body.error?.param === 'response_format';
288
+ })),
289
+ done('ai-gateway.structured.json_object', 'structured_outputs', "OpenAI-spec passthrough: response_format { type: 'json_object' } yields parseable JSON", 'api', 'niche', () => withRoot(async (h) => {
290
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ response_format: { type: 'json_object' } }) });
291
+ let parsed = null;
292
+ try {
293
+ parsed = JSON.parse(r.body.choices?.[0]?.message?.content);
294
+ }
295
+ catch {
296
+ return false;
297
+ }
298
+ return r.status === 200 && parsed?.twin_stub === true && String(parsed?.echo).includes('hello ai gateway twin');
299
+ })),
300
+ // ── reasoning ────────────────────────────────────────────────────────────────────────
301
+ done('ai-gateway.reasoning.enabled', 'reasoning', 'reasoning.enabled surfaces message.reasoning + reasoning_details + usage reasoning_tokens', 'api', 'common', () => withRoot(async (h) => {
302
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ reasoning: { enabled: true } }) });
303
+ const msg = r.body.choices?.[0]?.message;
304
+ const details = msg?.reasoning_details;
305
+ const off = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() });
306
+ return r.status === 200 && typeof msg?.reasoning === 'string' &&
307
+ details?.[0]?.type === 'reasoning.text' && details?.[0]?.format === 'anthropic-claude-v1' && typeof details?.[0]?.signature === 'string' &&
308
+ r.body.usage?.completion_tokens_details?.reasoning_tokens > 0 &&
309
+ off.body.choices?.[0]?.message?.reasoning === undefined;
310
+ })),
311
+ done('ai-gateway.reasoning.effort_levels', 'reasoning', 'reasoning.effort levels scale reasoning_tokens; effort+max_tokens are mutually exclusive', 'api', 'common', () => withRoot(async (h) => {
312
+ const low = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ max_tokens: 1000, reasoning: { effort: 'low' } }) });
313
+ const high = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ max_tokens: 1000, reasoning: { effort: 'high' } }) });
314
+ const none = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ reasoning: { effort: 'none' } }) });
315
+ const both = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ reasoning: { effort: 'high', max_tokens: 100 } }) });
316
+ const badEffort = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ reasoning: { effort: 'turbo' } }) });
317
+ const lowTokens = low.body.usage?.completion_tokens_details?.reasoning_tokens;
318
+ const highTokens = high.body.usage?.completion_tokens_details?.reasoning_tokens;
319
+ return lowTokens === 200 && highTokens === 800 &&
320
+ none.body.choices?.[0]?.message?.reasoning === undefined &&
321
+ both.status === 400 && both.body.error?.message?.includes('mutually exclusive') &&
322
+ badEffort.status === 400;
323
+ })),
324
+ done('ai-gateway.reasoning.max_tokens_and_exclude', 'reasoning', 'reasoning.max_tokens budgets tokens exactly; exclude hides content but keeps token accounting', 'api', 'common', () => withRoot(async (h) => {
325
+ const budgeted = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ reasoning: { max_tokens: 64 } }) });
326
+ const excluded = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ reasoning: { max_tokens: 64, exclude: true } }) });
327
+ return budgeted.body.usage?.completion_tokens_details?.reasoning_tokens === 64 &&
328
+ typeof budgeted.body.choices?.[0]?.message?.reasoning === 'string' &&
329
+ excluded.body.choices?.[0]?.message?.reasoning === undefined &&
330
+ excluded.body.usage?.completion_tokens_details?.reasoning_tokens === 64;
331
+ })),
332
+ done('ai-gateway.reasoning.details_by_provider_format', 'reasoning', 'reasoning_details normalize per creator: anthropic text+signature, openai summary+encrypted', 'api', 'niche', () => withRoot(async (h) => {
333
+ const anthropic = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ reasoning: { enabled: true } }) });
334
+ const openai = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ model: 'openai/gpt-5.2', reasoning: { enabled: true } }) });
335
+ const a = anthropic.body.choices?.[0]?.message?.reasoning_details;
336
+ const o = openai.body.choices?.[0]?.message?.reasoning_details;
337
+ return a?.length === 1 && a[0]?.format === 'anthropic-claude-v1' &&
338
+ o?.length === 2 && o[0]?.type === 'reasoning.summary' && o[1]?.type === 'reasoning.encrypted' &&
339
+ o.every((d) => d.format === 'openai-responses-v1');
340
+ })),
341
+ done('ai-gateway.reasoning.streaming', 'reasoning', 'Streaming delivers reasoning incrementally via delta.reasoning before content', 'api', 'niche', () => withRoot(async (h) => {
342
+ const events = [];
343
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ stream: true, reasoning: { enabled: true } }), sseSink: (event) => events.push(event) });
344
+ const datas = events.filter((e) => e.data).map((e) => e.data.choices[0]?.delta ?? {});
345
+ const reasoningIdx = datas.findIndex((d) => typeof d.reasoning === 'string');
346
+ const contentIdx = datas.findIndex((d) => typeof d.content === 'string');
347
+ const streamedReasoning = datas.map((d) => d.reasoning ?? '').join('');
348
+ return reasoningIdx !== -1 && contentIdx !== -1 && reasoningIdx < contentIdx &&
349
+ streamedReasoning === r.body.choices?.[0]?.message?.reasoning;
350
+ })),
351
+ // ── provider routing ─────────────────────────────────────────────────────────────────
352
+ done('ai-gateway.routing.order', 'routing', 'providerOptions.gateway.order promotes providers; routing metadata records the plan', 'api', 'core', () => withRoot(async (h) => {
353
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { order: ['vertex', 'anthropic'] } } }) });
354
+ const routing = r.body.providerMetadata?.gateway?.routing;
355
+ const bad = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { order: [42] } } }) });
356
+ return r.status === 200 && routing?.resolvedProvider === 'vertex' && routing?.finalProvider === 'vertex' &&
357
+ routing?.resolvedProviderApiModelId === 'claude-sonnet-4.6' &&
358
+ JSON.stringify(routing?.fallbacksAvailable) === JSON.stringify(['anthropic', 'bedrock']) &&
359
+ String(routing?.planningReasoning).includes('vertex') && bad.status === 400;
360
+ })),
361
+ done('ai-gateway.routing.only_filter', 'routing', 'only restricts the provider set; an empty intersection fails naming the allowed providers', 'api', 'common', () => withRoot(async (h) => {
362
+ const ok = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { only: ['bedrock'] } } }) });
363
+ const none = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { only: ['groq'] } } }) });
364
+ const combined = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { only: ['anthropic', 'vertex'], order: ['vertex', 'bedrock', 'anthropic'] } } }) });
365
+ return ok.body.providerMetadata?.gateway?.routing?.resolvedProvider === 'bedrock' &&
366
+ none.status === 400 && none.body.error?.code === 'no_available_providers' &&
367
+ none.body.error?.message?.includes('groq') &&
368
+ combined.body.providerMetadata?.gateway?.routing?.resolvedProvider === 'vertex' &&
369
+ JSON.stringify(combined.body.providerMetadata?.gateway?.routing?.fallbacksAvailable) === JSON.stringify(['anthropic']);
370
+ })),
371
+ done('ai-gateway.routing.sort', 'routing', 'sort (cost/ttft/tps) ranks providers and reports sort metadata (option/executionOrder/metrics)', 'api', 'common', () => withRoot(async (h) => {
372
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { sort: 'cost' } } }) });
373
+ const sort = r.body.providerMetadata?.gateway?.routing?.sort;
374
+ const order = sort?.executionOrder;
375
+ const metrics = sort?.metrics;
376
+ const ascending = order?.every((p, i) => i === 0 || metrics[order[i - 1]] <= metrics[p]);
377
+ const bad = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { sort: 'vibes' } } }) });
378
+ // Combined with `order`: promoted providers lead executionOrder, the rest keep sort order,
379
+ // and the resolved provider is executionOrder[0] (documented combination semantics).
380
+ const combined = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { sort: 'cost', order: ['vertex'] } } }) });
381
+ const combinedSort = combined.body.providerMetadata?.gateway?.routing?.sort;
382
+ return r.status === 200 && sort?.option === 'cost' && order?.length === 3 && ascending === true &&
383
+ sort?.deprioritizedProviders?.length === 0 &&
384
+ r.body.providerMetadata?.gateway?.routing?.resolvedProvider === order?.[0] &&
385
+ combinedSort?.executionOrder?.[0] === 'vertex' &&
386
+ combined.body.providerMetadata?.gateway?.routing?.resolvedProvider === 'vertex' &&
387
+ bad.status === 400 && bad.body.error?.param === 'providerOptions.gateway.sort';
388
+ })),
389
+ done('ai-gateway.routing.provider_shorthand', 'routing', "Top-level provider:{sort} shorthand is equivalent; conflicting values fail the request", 'api', 'common', () => withRoot(async (h) => {
390
+ const shorthand = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ provider: { sort: 'tps' } }) });
391
+ const conflict = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ provider: { sort: 'tps' }, providerOptions: { gateway: { sort: 'cost' } } }) });
392
+ const agree = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ provider: { sort: 'cost' }, providerOptions: { gateway: { sort: 'cost' } } }) });
393
+ return shorthand.body.providerMetadata?.gateway?.routing?.sort?.option === 'tps' &&
394
+ conflict.status === 400 && conflict.body.error?.message?.includes('same value') &&
395
+ agree.status === 200;
396
+ })),
397
+ done('ai-gateway.routing.model_fallbacks', 'routing', 'models fallback list (top-level or providerOptions.gateway.models) recovers from a failed primary', 'api', 'common', () => withRoot(async (h) => {
398
+ const top = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ model: 'openai/gpt-99', models: ['anthropic/claude-sonnet-4.6'] }) });
399
+ const routing = top.body.providerMetadata?.gateway?.routing;
400
+ const viaOptions = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ model: 'openai/gpt-99', providerOptions: { gateway: { models: ['anthropic/claude-sonnet-4.6'] } } }) });
401
+ const allUnknown = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ model: 'openai/gpt-99', models: ['nope/nope'] }) });
402
+ return top.status === 200 && top.body.model === 'anthropic/claude-sonnet-4.6' &&
403
+ routing?.originalModelId === 'openai/gpt-99' && routing?.modelAttemptCount === 2 &&
404
+ routing?.modelAttempts?.[0]?.success === false && routing?.modelAttempts?.[1]?.success === true &&
405
+ viaOptions.status === 200 && allUnknown.status === 404 && allUnknown.body.error?.code === 'model_not_found';
406
+ })),
407
+ done('ai-gateway.routing.byok_request_scoped', 'routing', 'Request-scoped BYOK: byok credential attribution, total_cost 0.00 (provider bills), upstream_inference_cost carries the market price', 'api', 'niche', () => withRoot(async (h) => {
408
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { byok: { anthropic: [{ apiKey: 'sk-ant-test' }] } } } }) });
409
+ const attempt = r.body.providerMetadata?.gateway?.routing?.modelAttempts?.at(-1)?.providerAttempts?.[0];
410
+ const gen = (await h({ m: 'GET', p: `/v1/generation?id=${encodeURIComponent(r.body.id)}` })).body;
411
+ const credits = (await h({ m: 'GET', p: '/v1/credits' })).body;
412
+ const bad = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { byok: { anthropic: [] } } } }) });
413
+ // Docs: total_cost "Returns 0.00 for BYOK requests"; upstream_inference_cost is the market
414
+ // price the provider would have charged — so BYOK inference must NOT consume gateway credits.
415
+ return r.status === 200 && attempt?.credentialType === 'byok' && gen.data?.is_byok === true &&
416
+ gen.data?.total_cost === 0 && gen.data?.upstream_inference_cost > 0 &&
417
+ r.body.providerMetadata?.gateway?.cost === '0.00' &&
418
+ Number(r.body.providerMetadata?.gateway?.marketCost) > 0 &&
419
+ credits.total_used === '0.00' && bad.status === 400;
420
+ })),
421
+ todo('ai-gateway.routing.provider_timeouts', 'routing', 'Per-provider timeouts (providerOptions.gateway.providerTimeouts.byok) for fast failover', 'api', 'niche'),
422
+ todo('ai-gateway.routing.health_guardrails', 'routing', 'Sort health interaction: degraded/recovering providers penalized, down providers last (deprioritizedProviders)', 'api', 'niche'),
423
+ // ── prompt caching ───────────────────────────────────────────────────────────────────
424
+ done('ai-gateway.caching.cache_control', 'caching', 'Manual cache_control markers report cached prompt tokens (usage + generation record)', 'api', 'common', () => withRoot(async (h) => {
425
+ const cached = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({
426
+ messages: [
427
+ { role: 'system', content: 'You are a long cached system prompt for the gateway twin.', cache_control: { type: 'ephemeral' } },
428
+ { role: 'user', content: 'hello' },
429
+ ],
430
+ }) });
431
+ const plain = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() });
432
+ const cachedTokens = cached.body.usage?.prompt_tokens_details?.cached_tokens;
433
+ const gen = await h({ m: 'GET', p: `/v1/generation?id=${encodeURIComponent(cached.body.id)}` });
434
+ return cached.status === 200 && cachedTokens > 0 &&
435
+ gen.body.data?.native_tokens_cached === cachedTokens &&
436
+ plain.body.usage?.prompt_tokens_details === undefined;
437
+ })),
438
+ done('ai-gateway.caching.auto_option', 'caching', "providerOptions.gateway.caching: 'auto' is accepted; other values rejected", 'api', 'niche', () => withRoot(async (h) => {
439
+ const ok = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { caching: 'auto' } } }) });
440
+ const bad = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { caching: 'always' } } }) });
441
+ return ok.status === 200 && bad.status === 400 && bad.body.error?.param === 'providerOptions.gateway.caching';
442
+ })),
443
+ // ── models catalog ───────────────────────────────────────────────────────────────────
444
+ done('ai-gateway.models.list', 'models', 'GET /v1/models lists creator/model slugs with gateway fields (name/context_window/type/tags/pricing)', 'api', 'core', () => withRoot(async (h) => {
445
+ const r = await h({ m: 'GET', p: '/v1/models' });
446
+ const b = r.body;
447
+ const sonnet = b.data?.find((m) => m.id === 'anthropic/claude-sonnet-4.6');
448
+ return r.status === 200 && b.object === 'list' && b.data.length >= 10 &&
449
+ sonnet?.object === 'model' && sonnet?.owned_by === 'anthropic' && sonnet?.context_window === 1_000_000 &&
450
+ sonnet?.type === 'language' && typeof sonnet?.pricing?.input === 'string' && typeof sonnet?.pricing?.output === 'string';
451
+ })),
452
+ done('ai-gateway.models.retrieve', 'models', 'GET /v1/models/{creator}/{model} retrieves one model; unknown slugs 404', 'api', 'core', () => withRoot(async (h) => {
453
+ const r = await h({ m: 'GET', p: '/v1/models/xai/grok-4-1' });
454
+ const missing = await h({ m: 'GET', p: '/v1/models/nope/nope' });
455
+ return r.status === 200 && r.body.id === 'xai/grok-4-1' && r.body.owned_by === 'xai' &&
456
+ missing.status === 404 && missing.body.error?.code === 'model_not_found';
457
+ })),
458
+ done('ai-gateway.models.endpoints', 'models', 'GET /v1/models/{id}/endpoints lists per-provider endpoints with pricing/uptime/latency', 'api', 'common', () => withRoot(async (h) => {
459
+ const r = await h({ m: 'GET', p: '/v1/models/openai/gpt-oss-120b/endpoints' });
460
+ const data = r.body.data;
461
+ const missing = await h({ m: 'GET', p: '/v1/models/nope/nope/endpoints' });
462
+ return r.status === 200 && data?.id === 'openai/gpt-oss-120b' &&
463
+ data?.endpoints?.length === 3 && data?.endpoints?.[0]?.provider_name === 'groq' &&
464
+ typeof data?.endpoints?.[0]?.pricing?.prompt === 'string' && data?.endpoints?.[0]?.status === 0 &&
465
+ typeof data?.endpoints?.[0]?.latency_last_1h?.p50 === 'number' && missing.status === 404;
466
+ })),
467
+ done('ai-gateway.models.embedding_type', 'models', 'The catalog distinguishes model types (language vs embedding) per the documented type field', 'api', 'niche', () => withRoot(async (h) => {
468
+ const r = await h({ m: 'GET', p: '/v1/models/openai/text-embedding-3-small' });
469
+ return r.status === 200 && r.body.type === 'embedding' && r.body.max_tokens === 0;
470
+ })),
471
+ todo('ai-gateway.models.tiered_pricing', 'models', 'Tiered pricing (input_tiers/output_tiers with min/max token bounds)', 'api', 'niche'),
472
+ todo('ai-gateway.models.filtering', 'models', 'Model filtering by capability (model-filtering options)', 'api', 'niche'),
473
+ // ── embeddings ───────────────────────────────────────────────────────────────────────
474
+ done('ai-gateway.embeddings.create', 'embeddings', 'POST /v1/embeddings returns deterministic 1536-dim vectors with usage + gateway providerMetadata', 'api', 'core', () => withRoot(async (h) => {
475
+ const r = await h({ m: 'POST', p: '/v1/embeddings', b: { model: 'openai/text-embedding-3-small', input: ['alpha', 'beta'] } });
476
+ const b = r.body;
477
+ const again = await h({ m: 'POST', p: '/v1/embeddings', b: { model: 'openai/text-embedding-3-small', input: 'alpha' } });
478
+ const deterministic = JSON.stringify(again.body.data?.[0]?.embedding) === JSON.stringify(b.data?.[0]?.embedding);
479
+ return r.status === 200 && b.object === 'list' && b.data?.length === 2 && b.data?.[0]?.index === 0 &&
480
+ Array.isArray(b.data?.[0]?.embedding) && b.data[0].embedding.length === 1536 && deterministic &&
481
+ b.usage?.prompt_tokens > 0 && typeof b.providerMetadata?.gateway?.cost === 'string' &&
482
+ b.providerMetadata?.gateway?.routing?.finalProvider === 'openai';
483
+ })),
484
+ done('ai-gateway.embeddings.dimensions', 'embeddings', 'The root-level dimensions parameter sets the vector length; invalid values rejected', 'api', 'common', () => withRoot(async (h) => {
485
+ const r = await h({ m: 'POST', p: '/v1/embeddings', b: { model: 'openai/text-embedding-3-small', input: 'beach', dimensions: 8 } });
486
+ const bad = await h({ m: 'POST', p: '/v1/embeddings', b: { model: 'openai/text-embedding-3-small', input: 'beach', dimensions: 0 } });
487
+ return r.status === 200 && r.body.data?.[0]?.embedding?.length === 8 &&
488
+ bad.status === 400 && bad.body.error?.param === 'dimensions';
489
+ })),
490
+ done('ai-gateway.embeddings.validation', 'embeddings', 'Embeddings reject missing input, non-embedding models, and unknown models like the vendor', 'api', 'common', () => withRoot(async (h) => {
491
+ const noInput = await h({ m: 'POST', p: '/v1/embeddings', b: { model: 'openai/text-embedding-3-small' } });
492
+ const langModel = await h({ m: 'POST', p: '/v1/embeddings', b: { model: 'anthropic/claude-sonnet-4.6', input: 'x' } });
493
+ const unknown = await h({ m: 'POST', p: '/v1/embeddings', b: { model: 'nope/nope', input: 'x' } });
494
+ return noInput.status === 400 && noInput.body.error?.param === 'input' &&
495
+ langModel.status === 400 && unknown.status === 404;
496
+ })),
497
+ done('ai-gateway.embeddings.accounting', 'embeddings', 'Embeddings are billed like the real gateway: each call records a generation (lookup by generationId), credits move, and the spend report counts it', 'api', 'common', () => withRoot(async (h) => {
498
+ const before = (await h({ m: 'GET', p: '/v1/credits' })).body;
499
+ const r = await h({ m: 'POST', p: '/v1/embeddings', b: { model: 'openai/text-embedding-3-small', input: ['alpha', 'beta'] } });
500
+ const gw = r.body.providerMetadata?.gateway;
501
+ const after = (await h({ m: 'GET', p: '/v1/credits' })).body;
502
+ const gen = (await h({ m: 'GET', p: `/v1/generation?id=${encodeURIComponent(String(gw?.generationId))}` })).body;
503
+ const report = (await h({ m: 'GET', p: '/v1/report?start_date=1970-01-01&end_date=1970-01-02&group_by=model' })).body;
504
+ const row = report.results?.find((x) => x.model === 'openai/text-embedding-3-small');
505
+ return r.status === 200 && String(gw?.generationId).startsWith('gen_') && Number(gw?.cost) > 0 &&
506
+ before.total_used === '0.00' && Number(after.total_used) > 0 &&
507
+ Math.abs(Number(after.total_used) - Number(gw?.cost)) < 1e-9 &&
508
+ gen.data?.model === 'openai/text-embedding-3-small' && gen.data?.tokens_prompt > 0 &&
509
+ gen.data?.tokens_completion === 0 && gen.data?.streamed === false &&
510
+ row?.request_count === 1 && row?.input_tokens > 0 && row?.output_tokens === 0;
511
+ })),
512
+ todo('ai-gateway.embeddings.encoding_format', 'embeddings', 'encoding_format base64 for embedding vectors', 'api', 'niche'),
513
+ // ── AI SDK gateway protocol (/v3/ai — @ai-sdk/gateway / `createGateway`) ─────────────
514
+ done('ai-gateway.v3.config', 'ai_sdk_protocol', 'GET /v3/ai/config serves the model config document getAvailableModels() consumes (v3 specification blocks, decimal-string pricing, modelType)', 'api', 'core', () => withRoot(async (h) => {
515
+ const r = await h({ m: 'GET', p: '/v3/ai/config' });
516
+ const models = r.body.models;
517
+ const sonnet = models?.find((m) => m.id === 'anthropic/claude-sonnet-4.6');
518
+ const embed = models?.find((m) => m.id === 'openai/text-embedding-3-small');
519
+ return r.status === 200 && models?.length >= 10 &&
520
+ sonnet?.specification?.specificationVersion === 'v3' && sonnet?.specification?.provider === 'anthropic' &&
521
+ sonnet?.specification?.modelId === 'anthropic/claude-sonnet-4.6' &&
522
+ typeof sonnet?.pricing?.input === 'string' && typeof sonnet?.pricing?.output === 'string' &&
523
+ sonnet?.modelType === 'language' && embed?.modelType === 'embedding';
524
+ })),
525
+ done('ai-gateway.v3.language_model.generate', 'ai_sdk_protocol', 'POST /v3/ai/language-model honors the ai-language-model-id header and returns the LanguageModelV3 result (content parts, unified finishReason, nested usage, providerMetadata.gateway) — recorded in the SAME generation fold as /v1 chats', 'api', 'core', () => withRoot(async (h) => {
526
+ const V3_HD = { 'ai-language-model-id': 'anthropic/claude-sonnet-4.6', 'ai-language-model-specification-version': '3', 'ai-language-model-streaming': 'false' };
527
+ const r = await h({ m: 'POST', p: '/v3/ai/language-model', hd: V3_HD, b: {
528
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello ai gateway twin' }] }],
529
+ } });
530
+ const b = r.body;
531
+ const text = b.content?.find((p) => p.type === 'text')?.text;
532
+ const genId = String(b.providerMetadata?.gateway?.generationId ?? '');
533
+ const gen = (await h({ m: 'GET', p: `/v1/generation?id=${encodeURIComponent(genId)}` })).body;
534
+ const credits = (await h({ m: 'GET', p: '/v1/credits' })).body;
535
+ return r.status === 200 && text?.includes('[twin-stub:ai-gateway') && text?.includes('hello ai gateway twin') &&
536
+ b.finishReason?.unified === 'stop' && b.finishReason?.raw === 'stop' &&
537
+ b.usage?.inputTokens?.total > 0 && b.usage?.outputTokens?.total > 0 &&
538
+ genId.startsWith('gen_') && gen.data?.model === 'anthropic/claude-sonnet-4.6' &&
539
+ gen.data?.total_cost > 0 && Number(credits.total_used) > 0;
540
+ })),
541
+ done('ai-gateway.v3.language_model.stream', 'ai_sdk_protocol', 'The ai-language-model-streaming header switches to LanguageModelV3 stream parts (response-metadata → text deltas → finish with usage), recorded streamed', 'api', 'core', () => withRoot(async (h) => {
542
+ const events = [];
543
+ const r = await h({ m: 'POST', p: '/v3/ai/language-model',
544
+ hd: { 'ai-language-model-id': 'anthropic/claude-sonnet-4.6', 'ai-language-model-streaming': 'true' },
545
+ b: { prompt: [{ role: 'user', content: [{ type: 'text', text: 'stream me' }] }] },
546
+ sseSink: (event) => events.push(event) });
547
+ const datas = events.filter((e) => e.data).map((e) => e.data);
548
+ const streamed = datas.filter((d) => d.type === 'text-delta').map((d) => d.delta).join('');
549
+ const finish = datas.find((d) => d.type === 'finish');
550
+ const meta = datas[0];
551
+ const gen = (await h({ m: 'GET', p: `/v1/generation?id=${encodeURIComponent(String(meta?.id))}` })).body;
552
+ const unaryText = r.body.content?.find((p) => p.type === 'text')?.text;
553
+ return r.status === 200 && meta?.type === 'response-metadata' && String(meta?.id).startsWith('gen_') &&
554
+ streamed === unaryText && streamed.includes('stream me') &&
555
+ finish?.finishReason?.unified === 'stop' && finish?.usage?.inputTokens?.total > 0 &&
556
+ events.at(-1)?.done === true && gen.data?.streamed === true;
557
+ })),
558
+ done('ai-gateway.v3.language_model.tools', 'ai_sdk_protocol', 'V3 function tools (inputSchema) yield a tool-call content part with schema-conformant input and a tool-calls finish; provider-executed tools fail loudly (unmodeled)', 'api', 'common', () => withRoot(async (h) => {
559
+ const V3_HD = { 'ai-language-model-id': 'openai/gpt-5.2', 'ai-language-model-streaming': 'false' };
560
+ const r = await h({ m: 'POST', p: '/v3/ai/language-model', hd: V3_HD, b: {
561
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'weather in Paris?' }] }],
562
+ tools: [{ type: 'function', name: 'get_weather', inputSchema: { type: 'object', properties: { city: { type: 'string' } } } }],
563
+ toolChoice: { type: 'tool', toolName: 'get_weather' },
564
+ } });
565
+ const call = r.body.content?.find((p) => p.type === 'tool-call');
566
+ const providerTool = await h({ m: 'POST', p: '/v3/ai/language-model', hd: V3_HD, b: {
567
+ prompt: [{ role: 'user', content: [{ type: 'text', text: 'search' }] }],
568
+ tools: [{ type: 'provider', id: 'gateway.parallel_search', name: 'parallel_search', args: {} }],
569
+ } });
570
+ return r.status === 200 && call?.toolName === 'get_weather' && String(call?.toolCallId).startsWith('call_') &&
571
+ JSON.parse(String(call?.input)).city === 'twin stub' &&
572
+ r.body.finishReason?.unified === 'tool-calls' &&
573
+ providerTool.status === 400;
574
+ })),
575
+ done('ai-gateway.v3.validation', 'ai_sdk_protocol', 'V3 calls validate the protocol headers, and unknown models fail with the model_not_found envelope the SDK maps to GatewayModelNotFoundError', 'api', 'common', () => withRoot(async (h) => {
576
+ const prompt = { prompt: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }] };
577
+ const noModel = await h({ m: 'POST', p: '/v3/ai/language-model', b: prompt });
578
+ const badSpec = await h({ m: 'POST', p: '/v3/ai/language-model', hd: { 'ai-language-model-id': 'anthropic/claude-sonnet-4.6', 'ai-language-model-specification-version': '2' }, b: prompt });
579
+ const unknown = await h({ m: 'POST', p: '/v3/ai/language-model', hd: { 'ai-language-model-id': 'nope/nope' }, b: prompt });
580
+ const unmodeled = await h({ m: 'POST', p: '/v3/ai/embedding-model', hd: { 'ai-model-id': 'openai/text-embedding-3-small' }, b: { values: ['x'] } });
581
+ return noModel.status === 400 && noModel.body.error?.param === 'ai-language-model-id' &&
582
+ badSpec.status === 400 &&
583
+ unknown.status === 404 && unknown.body.error?.type === 'model_not_found' &&
584
+ unknown.body.error?.param?.modelId === 'nope/nope' &&
585
+ unmodeled.status === 404 && unmodeled.body.error?.code === 'not_found';
586
+ })),
587
+ todo('ai-gateway.v3.embedding_model', 'ai_sdk_protocol', 'POST /v3/ai/embedding-model (the AI SDK EmbeddingModelV3 wire protocol: values + ai-model-id header → embeddings/usage/providerMetadata)', 'api', 'common'),
588
+ todo('ai-gateway.v3.image_model', 'ai_sdk_protocol', 'POST /v3/ai/image-model (the AI SDK ImageModelV3 wire protocol)', 'api', 'niche'),
589
+ todo('ai-gateway.v3.video_model', 'ai_sdk_protocol', 'POST /v3/ai/video-model (the AI SDK VideoModelV3 wire protocol)', 'api', 'niche'),
590
+ todo('ai-gateway.v3.provider_tools', 'ai_sdk_protocol', 'Provider-executed gateway tools over the V3 protocol (gateway.parallel_search / gateway.perplexity_search tool results)', 'api', 'niche'),
591
+ // ── credits / generations / report (REST API Reference) ─────────────────────────────
592
+ done('ai-gateway.credits.get', 'credits', 'GET /v1/credits returns balance and total_used as decimal strings', 'api', 'core', () => withRoot(async (h) => {
593
+ const r = await h({ m: 'GET', p: '/v1/credits' });
594
+ const b = r.body;
595
+ return r.status === 200 && b.balance === '100.00' && b.total_used === '0.00';
596
+ })),
597
+ done('ai-gateway.credits.spend_coupled', 'credits', 'Credits are live accounting: every chat (even identical repeats) increases total_used and decreases balance', 'api', 'common', () => withRoot(async (h) => {
598
+ const before = (await h({ m: 'GET', p: '/v1/credits' })).body;
599
+ const first = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() });
600
+ const second = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() }); // identical request — must still be billed
601
+ const cost = Number(first.body.providerMetadata?.gateway?.cost);
602
+ const after = (await h({ m: 'GET', p: '/v1/credits' })).body;
603
+ return before.total_used === '0.00' && cost > 0 &&
604
+ first.body.id !== second.body.id &&
605
+ Math.abs(Number(after.total_used) - 2 * cost) < 1e-9 &&
606
+ Math.abs(Number(after.balance) - (100 - 2 * cost)) < 1e-9;
607
+ })),
608
+ done('ai-gateway.generation.lookup', 'generations', 'GET /v1/generation?id returns cost/latency/token usage for a recorded generation; ids are unique so lookups never return another request\'s record', 'api', 'core', () => withRoot(async (h) => {
609
+ const chat = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() });
610
+ const id = chat.body.id;
611
+ const r = await h({ m: 'GET', p: `/v1/generation?id=${encodeURIComponent(id)}` });
612
+ const d = r.body.data;
613
+ // Two requests differing ONLY in routing options must mint distinct ids and each lookup
614
+ // must return ITS OWN provider (the §5 subject-id-collision trap).
615
+ const bedrock = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { order: ['bedrock'] } } }) });
616
+ const vertex = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ providerOptions: { gateway: { order: ['vertex'] } } }) });
617
+ const bedrockGen = (await h({ m: 'GET', p: `/v1/generation?id=${encodeURIComponent(bedrock.body.id)}` })).body;
618
+ const vertexGen = (await h({ m: 'GET', p: `/v1/generation?id=${encodeURIComponent(vertex.body.id)}` })).body;
619
+ return r.status === 200 && d?.id === id && d?.model === 'anthropic/claude-sonnet-4.6' &&
620
+ d?.provider_name === 'anthropic' && d?.finish_reason === 'stop' && d?.streamed === false &&
621
+ d?.total_cost > 0 && d?.usage === d?.total_cost && d?.upstream_inference_cost === 0 &&
622
+ d?.tokens_prompt > 0 && d?.tokens_completion > 0 && typeof d?.latency === 'number' &&
623
+ typeof d?.generation_time === 'number' && d?.billable_web_search_calls === 0 &&
624
+ bedrock.body.id !== vertex.body.id &&
625
+ bedrockGen.data?.provider_name === 'bedrock' && vertexGen.data?.provider_name === 'vertex';
626
+ })),
627
+ done('ai-gateway.generation.streamed_flag', 'generations', 'Generation records preserve the streamed flag for streaming completions', 'api', 'niche', () => withRoot(async (h) => {
628
+ const events = [];
629
+ const chat = await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ stream: true }), sseSink: (e) => events.push(e) });
630
+ const r = await h({ m: 'GET', p: `/v1/generation?id=${encodeURIComponent(chat.body.id)}` });
631
+ return r.body.data?.streamed === true;
632
+ })),
633
+ done('ai-gateway.generation.errors', 'generations', 'Generation lookup 400s without an id and 404s for unknown ids', 'api', 'common', () => withRoot(async (h) => {
634
+ const noId = await h({ m: 'GET', p: '/v1/generation' });
635
+ const unknown = await h({ m: 'GET', p: '/v1/generation?id=gen_DOESNOTEXIST' });
636
+ return noId.status === 400 && noId.body.error?.param === 'id' &&
637
+ unknown.status === 404 && unknown.body.error?.code === 'not_found';
638
+ })),
639
+ done('ai-gateway.report.validation', 'report', 'GET /v1/report validates the documented query params (date range, group_by, date_part, credential_type, tags_match)', 'api', 'common', () => withRoot(async (h) => {
640
+ const noRange = await h({ m: 'GET', p: '/v1/report' });
641
+ const badDate = await h({ m: 'GET', p: '/v1/report?start_date=jan-1&end_date=2026-01-31' });
642
+ const badGroup = await h({ m: 'GET', p: '/v1/report?start_date=2026-01-01&end_date=2026-01-31&group_by=vibes' });
643
+ const badPart = await h({ m: 'GET', p: '/v1/report?start_date=2026-01-01&end_date=2026-01-31&date_part=minute' });
644
+ const badCred = await h({ m: 'GET', p: '/v1/report?start_date=2026-01-01&end_date=2026-01-31&credential_type=vault' });
645
+ const badMatch = await h({ m: 'GET', p: '/v1/report?start_date=2026-01-01&end_date=2026-01-31&tags=a&tags_match=some' });
646
+ return noRange.status === 400 && noRange.body.error?.param === 'start_date' &&
647
+ badDate.status === 400 && badGroup.status === 400 && badGroup.body.error?.param === 'group_by' &&
648
+ badPart.status === 400 && badPart.body.error?.param === 'date_part' &&
649
+ badCred.status === 400 && badMatch.status === 400;
650
+ })),
651
+ done('ai-gateway.report.spend_by_model', 'report', 'Spend report serves the documented results rows (grouping field + total/market cost, token counts, request_count) over recorded generations', 'api', 'common', () => withRoot(async (h) => {
652
+ await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() });
653
+ await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT() }); // identical repeat — its own generation
654
+ await h({ m: 'POST', p: '/v1/chat/completions', b: CHAT({ model: 'zai/glm-4.6' }) });
655
+ const byModel = await h({ m: 'GET', p: '/v1/report?start_date=1970-01-01&end_date=1970-01-02&group_by=model' });
656
+ const rows = byModel.body.results;
657
+ const sonnet = rows?.find((r) => r.model === 'anthropic/claude-sonnet-4.6');
658
+ const byDay = await h({ m: 'GET', p: '/v1/report?start_date=1970-01-01&end_date=1970-01-02' });
659
+ const dayRow = byDay.body.results?.[0];
660
+ const byHour = await h({ m: 'GET', p: '/v1/report?start_date=1970-01-01&end_date=1970-01-02&date_part=hour' });
661
+ const hourRow = byHour.body.results?.[0];
662
+ const filtered = await h({ m: 'GET', p: '/v1/report?start_date=1970-01-01&end_date=1970-01-02&group_by=model&model=zai/glm-4.6' });
663
+ const outOfRange = await h({ m: 'GET', p: '/v1/report?start_date=2030-01-01&end_date=2030-01-31' });
664
+ return byModel.status === 200 && rows?.length === 2 &&
665
+ sonnet?.request_count === 2 && sonnet?.total_cost > 0 && sonnet?.market_cost >= sonnet?.total_cost &&
666
+ sonnet?.gateway_cost === 0 && sonnet?.surcharge_cost === 0 &&
667
+ sonnet?.input_tokens > 0 && sonnet?.output_tokens > 0 &&
668
+ sonnet?.cached_input_tokens === 0 && sonnet?.cache_creation_input_tokens === 0 && sonnet?.reasoning_tokens === 0 &&
669
+ dayRow?.day === '1970-01-01' && dayRow?.request_count === 3 &&
670
+ hourRow?.hour === '1970-01-01T00' && hourRow?.request_count === 3 &&
671
+ filtered.body.results?.length === 1 &&
672
+ filtered.body.results?.[0]?.model === 'zai/glm-4.6' &&
673
+ outOfRange.body.results?.length === 0;
674
+ })),
675
+ todo('ai-gateway.report.attribution_groupings', 'report', 'Report groupings and filters over request attribution (user/tag/api_key_name/zero_data_retention) backed by real recorded attribution', 'api', 'niche'),
676
+ // ── honesty / error shapes ───────────────────────────────────────────────────────────
677
+ done('ai-gateway.errors.envelope', 'honesty', 'Errors use the documented envelope: { error: { message, type, param, code } }', 'api', 'core', () => withRoot(async (h) => {
678
+ const r = await h({ m: 'POST', p: '/v1/chat/completions', b: { messages: [{ role: 'user', content: 'x' }] } });
679
+ const e = r.body.error;
680
+ return r.status === 400 && typeof e?.message === 'string' && e?.type === 'invalid_request_error' &&
681
+ e?.param === 'model' && e?.code === 'missing_parameter';
682
+ })),
683
+ done('ai-gateway.readonly.rejects_writes', 'honesty', 'readOnly mode rejects chat/embeddings writes with 405 while reads still serve', 'api', 'common', async () => {
684
+ const root = mkdtempSync(join(tmpdir(), 'ai-gateway-cap-ro-'));
685
+ try {
686
+ const chat = await handleAiGatewayTwinRequest({ method: 'POST', path: '/v1/chat/completions', body: JSON.stringify(CHAT()), readOnly: true, root });
687
+ const embed = await handleAiGatewayTwinRequest({ method: 'POST', path: '/v1/embeddings', body: JSON.stringify({ model: 'openai/text-embedding-3-small', input: 'x' }), readOnly: true, root });
688
+ const models = await handleAiGatewayTwinRequest({ method: 'GET', path: '/v1/models', readOnly: true, root });
689
+ return chat.status === 405 && embed.status === 405 && models.status === 200 && models.body.data.length > 0;
690
+ }
691
+ finally {
692
+ rmSync(root, { recursive: true, force: true });
693
+ }
694
+ }),
695
+ done('ai-gateway.unmodeled.404', 'honesty', 'Unmodeled operations fail with a vendor-shaped 404 instead of fake success', 'api', 'core', () => withRoot(async (h) => {
696
+ const post = await h({ m: 'POST', p: '/v1/totally/unmodeled', b: { x: 1 } });
697
+ const responses = await h({ m: 'POST', p: '/v1/responses', b: { model: 'openai/gpt-5.2', input: 'hi' } });
698
+ return post.status === 404 && post.body.error?.code === 'not_found' &&
699
+ responses.status === 404;
700
+ })),
701
+ todo('ai-gateway.auth.bearer_enforcement', 'auth', 'Reject missing/invalid Authorization bearer with the vendor 401 shape (twin currently fakes auth)', 'api', 'core'),
702
+ // ── compatibility APIs not yet modeled (real gateway surface → honest todos) ─────────
703
+ todo('ai-gateway.responses.create', 'responses_api', 'OpenAI Responses API compatibility (POST /v1/responses)', 'api', 'common'),
704
+ todo('ai-gateway.responses.streaming', 'responses_api', 'Responses API streaming events', 'api', 'common'),
705
+ todo('ai-gateway.responses.cache_anchor', 'responses_api', 'Responses API cache_anchor_items + cache_ttl top-level fields', 'api', 'niche'),
706
+ todo('ai-gateway.anthropic_messages.create', 'anthropic_messages', 'Anthropic Messages API compatibility (POST /v1/messages)', 'api', 'common'),
707
+ todo('ai-gateway.anthropic_messages.streaming', 'anthropic_messages', 'Anthropic Messages API SSE streaming', 'api', 'common'),
708
+ todo('ai-gateway.openresponses.create', 'openresponses', 'OpenResponses API specification support', 'api', 'niche'),
709
+ todo('ai-gateway.images.generation', 'modalities', 'Image generation via multimodal chat completions', 'api', 'common'),
710
+ todo('ai-gateway.video.generation', 'modalities', 'Video generation models', 'api', 'niche'),
711
+ todo('ai-gateway.byok.persistent', 'auth', 'Persistent BYOK credential management (dashboard-configured keys influencing routing)', 'api', 'niche'),
712
+ // ── connector ────────────────────────────────────────────────────────────────────────
713
+ done('ai-gateway.connector.pull', 'connector', 'Connector pulls the model catalog + credits through an injected client idempotently', 'connector', 'common', async () => {
714
+ const root = mkdtempSync(join(tmpdir(), 'ai-gateway-connector-cap-'));
715
+ try {
716
+ let calls = 0;
717
+ const execute = async (req) => {
718
+ calls++;
719
+ if (req.path === '/v1/models')
720
+ return { status: 200, data: { object: 'list', data: [{ id: 'vendor/model-a', object: 'model', owned_by: 'vendor' }] } };
721
+ if (req.path === '/v1/credits')
722
+ return { status: 200, data: { balance: '42.00', total_used: '8.00' } };
723
+ return { status: 404, data: {} };
724
+ };
725
+ await pullAiGatewayState(execute, { root });
726
+ await pullAiGatewayState(execute, { root });
727
+ const models = projectResources('ai-gateway', root).filter((r) => r.type === 'model');
728
+ const credits = projectResources('ai-gateway', root).filter((r) => r.type === 'credits');
729
+ return calls === 4 && models.length === 1 && models[0]?.id === 'model_vendor/model-a' &&
730
+ credits.length === 1 && credits[0]?.balance === '42.00';
731
+ }
732
+ finally {
733
+ rmSync(root, { recursive: true, force: true });
734
+ }
735
+ }),
736
+ done('ai-gateway.connector.push', 'connector', 'Connector reconciles pending generation records via GET /v1/generation through an injected client idempotently', 'connector', 'niche', async () => {
737
+ const root = mkdtempSync(join(tmpdir(), 'ai-gateway-connector-cap-'));
738
+ try {
739
+ await applyTwinWrite('ai-gateway', {
740
+ operation: 'generation.record',
741
+ subjectType: 'generation',
742
+ subjectId: 'gen_CAPTEST',
743
+ fields: { id: 'gen_CAPTEST', model: 'anthropic/claude-sonnet-4.6', total_cost: 0.001 },
744
+ occurredAt: '1970-01-01T00:00:00.000Z',
745
+ actor: { kind: 'system' },
746
+ }, root);
747
+ const paths = [];
748
+ const execute = async (req) => {
749
+ paths.push(req.path);
750
+ return { status: 200, data: { data: { id: 'gen_CAPTEST' } } };
751
+ };
752
+ const first = await performPending(execute, { root });
753
+ const second = await performPending(execute, { root });
754
+ return first === 1 && second === 0 && pendingActions('ai-gateway', root).length === 0 &&
755
+ paths.length === 1 && paths[0] === '/v1/generation?id=gen_CAPTEST';
756
+ }
757
+ finally {
758
+ rmSync(root, { recursive: true, force: true });
759
+ }
760
+ }),
761
+ // Pull-surface coverage audit gap (TWIN-46 / G2) — filed as a manifest todo so
762
+ // the demand-ordered build list is drawn from manifest todos. See pull-audit.json.
763
+ todo('ai-gateway.connector.pull_report', 'connector', 'Connector: pull aggregated spend (GET /v1/report) from the real account', 'connector', 'common'),
764
+ ];
765
+ // The repo's aggregate-manifest convention (scripts/mutation-test.ts META-CHECK 1) derives the
766
+ // export name mechanically as `<vendor>.toUpperCase() + '_CAPABILITIES'` — for the hyphenated
767
+ // `ai-gateway` vendor id that is the (non-identifier) name "AI-GATEWAY_CAPABILITIES", exported
768
+ // here via an ES2022 arbitrary module namespace name alias.
769
+ export { AI_GATEWAY_CAPABILITIES as 'AI-GATEWAY_CAPABILITIES' };
770
+ export async function aiGatewayCapabilities() {
771
+ return checkCapabilities('ai-gateway', AI_GATEWAY_CAPABILITIES);
772
+ }