@roj-ai/sdk 0.1.35 → 0.1.36

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 (61) hide show
  1. package/dist/core/agents/agent.d.ts.map +1 -1
  2. package/dist/core/agents/agent.js +7 -3
  3. package/dist/core/agents/agent.js.map +1 -1
  4. package/dist/core/agents/state.d.ts +24 -0
  5. package/dist/core/agents/state.d.ts.map +1 -1
  6. package/dist/core/agents/state.js.map +1 -1
  7. package/dist/core/context/state.d.ts.map +1 -1
  8. package/dist/core/context/state.js +4 -0
  9. package/dist/core/context/state.js.map +1 -1
  10. package/dist/core/llm/anthropic.d.ts.map +1 -1
  11. package/dist/core/llm/anthropic.js +49 -8
  12. package/dist/core/llm/anthropic.js.map +1 -1
  13. package/dist/core/llm/model-shortlist-live.test.d.ts +35 -0
  14. package/dist/core/llm/model-shortlist-live.test.d.ts.map +1 -0
  15. package/dist/core/llm/model-shortlist-live.test.js +452 -0
  16. package/dist/core/llm/model-shortlist-live.test.js.map +1 -0
  17. package/dist/core/llm/openrouter-mapping.test.js +18 -0
  18. package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
  19. package/dist/core/llm/openrouter.d.ts.map +1 -1
  20. package/dist/core/llm/openrouter.js +20 -11
  21. package/dist/core/llm/openrouter.js.map +1 -1
  22. package/dist/core/llm/provider.d.ts +34 -3
  23. package/dist/core/llm/provider.d.ts.map +1 -1
  24. package/dist/core/llm/provider.js.map +1 -1
  25. package/dist/core/llm/reasoning-details.test.d.ts +2 -0
  26. package/dist/core/llm/reasoning-details.test.d.ts.map +1 -0
  27. package/dist/core/llm/reasoning-details.test.js +144 -0
  28. package/dist/core/llm/reasoning-details.test.js.map +1 -0
  29. package/dist/core/llm/state.d.ts +2 -0
  30. package/dist/core/llm/state.d.ts.map +1 -1
  31. package/dist/core/llm/state.js +11 -0
  32. package/dist/core/llm/state.js.map +1 -1
  33. package/dist/core/llm/thinking-blocks.test.d.ts +2 -0
  34. package/dist/core/llm/thinking-blocks.test.d.ts.map +1 -0
  35. package/dist/core/llm/thinking-blocks.test.js +291 -0
  36. package/dist/core/llm/thinking-blocks.test.js.map +1 -0
  37. package/dist/core/reasoning-round-trip.integration.test.d.ts +2 -0
  38. package/dist/core/reasoning-round-trip.integration.test.d.ts.map +1 -0
  39. package/dist/core/reasoning-round-trip.integration.test.js +150 -0
  40. package/dist/core/reasoning-round-trip.integration.test.js.map +1 -0
  41. package/dist/core/sessions/state.d.ts.map +1 -1
  42. package/dist/core/sessions/state.js +5 -0
  43. package/dist/core/sessions/state.js.map +1 -1
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js.map +1 -1
  47. package/package.json +3 -3
  48. package/src/core/agents/agent.ts +9 -3
  49. package/src/core/agents/state.ts +26 -0
  50. package/src/core/context/state.ts +4 -0
  51. package/src/core/llm/anthropic.ts +71 -10
  52. package/src/core/llm/model-shortlist-live.test.ts +577 -0
  53. package/src/core/llm/openrouter-mapping.test.ts +24 -0
  54. package/src/core/llm/openrouter.ts +28 -12
  55. package/src/core/llm/provider.ts +44 -3
  56. package/src/core/llm/reasoning-details.test.ts +177 -0
  57. package/src/core/llm/state.ts +12 -0
  58. package/src/core/llm/thinking-blocks.test.ts +345 -0
  59. package/src/core/reasoning-round-trip.integration.test.ts +188 -0
  60. package/src/core/sessions/state.ts +5 -0
  61. package/src/index.ts +1 -0
@@ -0,0 +1,577 @@
1
+ /**
2
+ * Live verification of the webmaster model shortlist against the real OpenRouter API.
3
+ *
4
+ * The list mirrors `AGENT_MODEL_OPTIONS` in webmaster `packages/api/feature-flags.ts` — the
5
+ * models an org admin can pin an edit-session agent to. Every entry there had only ever been
6
+ * read off a screenshot; this exercises each one through the SDK's real path:
7
+ *
8
+ * SessionManager → Session → Agent → OpenRouterProvider → openrouter.ai
9
+ *
10
+ * so a pass means *our runtime* works with the model, not merely that the model exists. The
11
+ * agent loop is forced through a tool call whose answer cannot be guessed (a made-up
12
+ * identifier), then through `tell_user`, so the assertions cover: tool call with correct
13
+ * arguments → tool result consumed → final user-visible answer containing the looked-up value.
14
+ *
15
+ * Every outbound HTTP body is recorded, which is what makes the secondary checks real rather
16
+ * than assumed:
17
+ *
18
+ * - `cache_control` — `buildHttpRequest` attaches Anthropic-shaped markers unconditionally.
19
+ * Recorded per request, and on a 4xx the same body is replayed with the markers stripped to
20
+ * prove whether they were the cause.
21
+ * - `reasoning_details` — the branch echoes them back on the next request. Proven by finding
22
+ * the key in a *later* outbound body, not by trusting the code path.
23
+ * - `stop` — the agent always sends `['<message']`; several shortlist models do not list
24
+ * `stop` in `supported_parameters`.
25
+ * - vision — separate one-shot requests with real PNG bytes, asserting the model names the
26
+ * colour rather than merely that the request shape was accepted.
27
+ *
28
+ * Opt-in, so a plain `bun test` stays hermetic and offline:
29
+ *
30
+ * LIVE_TESTS=1 OPENROUTER_API_KEY=… bun test model-shortlist-live
31
+ *
32
+ * `LIVE_MODELS` (comma-separated slugs) narrows the run to a subset.
33
+ */
34
+
35
+ import { describe, expect, test } from 'bun:test'
36
+ import z4 from 'zod/v4'
37
+ import { MemoryEventStore } from '~/core/events/memory.js'
38
+ import { SessionFileStore } from '~/core/file-store/file-store.js'
39
+ import type { ImageProcessor } from '~/core/image/types.js'
40
+ import { SessionManager } from '~/core/sessions/session-manager.js'
41
+ import { createTool } from '~/core/tools/definition.js'
42
+ import { ToolExecutor } from '~/core/tools/executor.js'
43
+ import { silentLogger } from '~/lib/logger/logger.js'
44
+ import { agentStatusPlugin } from '~/plugins/agent-status/plugin.js'
45
+ import { agentsPlugin } from '~/plugins/agents/plugin.js'
46
+ import { filesystemPlugin } from '~/plugins/filesystem/index.js'
47
+ import { gitStatusPlugin } from '~/plugins/git-status/index.js'
48
+ import { llmDebugPlugin } from '~/plugins/llm-debug/plugin.js'
49
+ import { logsPlugin } from '~/plugins/logs/index.js'
50
+ import { mailboxPlugin } from '~/plugins/mailbox/plugin.js'
51
+ import { resourcesPlugin } from '~/plugins/resources/plugin.js'
52
+ import { servicePlugin } from '~/plugins/services/plugin.js'
53
+ import { presetsPlugin, sessionLifecyclePlugin } from '~/plugins/session-lifecycle/index.js'
54
+ import { sessionStatePlugin } from '~/plugins/session-state/plugin.js'
55
+ import { sessionStatsPlugin } from '~/plugins/session-stats/index.js'
56
+ import { uploadsPlugin } from '~/plugins/uploads/plugin.js'
57
+ import { userChatPlugin } from '~/plugins/user-chat/plugin.js'
58
+ import { createNodePlatform } from '~/testing/node-platform.js'
59
+ import { createTestPreset } from '~/testing/preset-helpers.js'
60
+ import { waitForAllAgentsIdle } from '~/testing/wait-helpers.js'
61
+ import { OpenRouterProvider } from './openrouter.js'
62
+ import { ModelId } from './schema.js'
63
+
64
+ // ============================================================================
65
+ // Gating
66
+ // ============================================================================
67
+
68
+ const liveEnabled = process.env.LIVE_TESTS === '1'
69
+ const openRouterApiKey = liveEnabled ? process.env.OPENROUTER_API_KEY : undefined
70
+
71
+ /** Mirrors webmaster `AGENT_MODEL_OPTIONS`. Keep in sync by hand — the repos do not share code. */
72
+ const SHORTLIST = [
73
+ 'anthropic/claude-haiku-4.5',
74
+ 'anthropic/claude-sonnet-5',
75
+ 'anthropic/claude-sonnet-4.6',
76
+ 'anthropic/claude-opus-5',
77
+ 'openai/gpt-5.6-luna',
78
+ 'openai/gpt-5.6-terra',
79
+ 'openai/gpt-5.6-sol',
80
+ 'google/gemini-3.6-flash',
81
+ 'x-ai/grok-4.5',
82
+ 'moonshotai/kimi-k2.7-code',
83
+ 'moonshotai/kimi-k3',
84
+ ]
85
+
86
+ const selectedModels = process.env.LIVE_MODELS ? process.env.LIVE_MODELS.split(',').map((s) => s.trim()).filter(Boolean) : SHORTLIST
87
+
88
+ /**
89
+ * The constant from provider-integration.test.ts, where it is commented "1x1 red pixel". It is
90
+ * actually a 1x1 RGBA pixel of #FFFF00 at alpha 127. Kept because xAI rejects it outright
91
+ * (min 8x8), which is worth knowing before someone reuses it as a vision fixture.
92
+ */
93
+ const TINY_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
94
+
95
+ /**
96
+ * 32x32 opaque #0000FF — unambiguous to name, and above every provider's minimum. xAI is the
97
+ * binding constraint: it rejects anything under 8px per side or under 512 total pixels, so 16x16
98
+ * (256 px) is still too small.
99
+ */
100
+ const BLUE_PNG_32 = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAJklEQVR42u3NsQkAAAjAsP7/tF7hIASyp5pjAoFAIBAIBAKB4EmwOkv8Lom8x/sAAAAASUVORK5CYII='
101
+
102
+ const noopImageProcessor: ImageProcessor = {
103
+ resolveContent: async (content) => content,
104
+ }
105
+
106
+ // ============================================================================
107
+ // Recorded HTTP exchanges
108
+ // ============================================================================
109
+
110
+ interface RecordedContentItem {
111
+ type: string
112
+ text?: string
113
+ cache_control?: { type: string; ttl?: string }
114
+ }
115
+
116
+ interface RecordedMessage {
117
+ role: string
118
+ content: string | RecordedContentItem[]
119
+ tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }>
120
+ tool_call_id?: string
121
+ reasoning_details?: unknown[]
122
+ }
123
+
124
+ interface RecordedRequestBody {
125
+ model: string
126
+ messages: RecordedMessage[]
127
+ stop?: string[]
128
+ max_tokens?: number
129
+ temperature?: number
130
+ tools?: Array<{ function: { name: string } }>
131
+ }
132
+
133
+ interface RecordedResponseBody {
134
+ model?: string
135
+ choices?: Array<{
136
+ message?: { content?: string | null; reasoning_details?: unknown[]; tool_calls?: unknown[] }
137
+ finish_reason?: string | null
138
+ }>
139
+ usage?: {
140
+ prompt_tokens?: number
141
+ completion_tokens?: number
142
+ cost?: number
143
+ prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }
144
+ completion_tokens_details?: { reasoning_tokens?: number }
145
+ }
146
+ error?: { message?: string; code?: string | number; metadata?: unknown }
147
+ }
148
+
149
+ interface Exchange {
150
+ request: RecordedRequestBody
151
+ status: number
152
+ rawResponse: string
153
+ response?: RecordedResponseBody
154
+ }
155
+
156
+ /** Wraps real fetch and keeps the parsed request/response pair. Auth headers are never stored. */
157
+ const createRecordingFetch = (sink: Exchange[]) => {
158
+ return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
159
+ const requestBody: RecordedRequestBody = JSON.parse(typeof init?.body === 'string' ? init.body : '{}')
160
+ const response = await globalThis.fetch(input, init)
161
+ const rawResponse = await response.text()
162
+ let parsed: RecordedResponseBody | undefined
163
+ try {
164
+ parsed = JSON.parse(rawResponse)
165
+ } catch {
166
+ parsed = undefined
167
+ }
168
+ sink.push({ request: requestBody, status: response.status, rawResponse, response: parsed })
169
+ return new Response(rawResponse, { status: response.status, statusText: response.statusText, headers: response.headers })
170
+ }
171
+ }
172
+
173
+ const hasCacheControl = (body: RecordedRequestBody): boolean =>
174
+ body.messages.some((m) => Array.isArray(m.content) && m.content.some((c) => c.cache_control !== undefined))
175
+
176
+ /** Same body with every `cache_control` marker removed — the control for a 4xx. */
177
+ const stripCacheControl = (body: RecordedRequestBody): RecordedRequestBody => ({
178
+ ...body,
179
+ messages: body.messages.map((m) => ({
180
+ ...m,
181
+ content: Array.isArray(m.content) ? m.content.map(({ cache_control: _dropped, ...rest }) => rest) : m.content,
182
+ })),
183
+ })
184
+
185
+ // ============================================================================
186
+ // The forced-tool-call scenario
187
+ // ============================================================================
188
+
189
+ /**
190
+ * A made-up registry. No model can know that QX-88231 maps to ZK-7719, so a final answer
191
+ * containing the code proves the tool was called AND its result was read.
192
+ */
193
+ const GLORP_REGISTRY: Record<string, string> = { 'QX-88231': 'ZK-7719' }
194
+
195
+ const USER_PROMPT =
196
+ 'Look up the glorp code for artifact QX-88231 with the lookup_glorp_code tool, then tell me the code. Keep the reply under ten words.'
197
+
198
+ /** Padding so the cacheable prefix clears Anthropic\'s 1024-token minimum and a cache write can register. */
199
+ const SYSTEM_PADDING = Array.from(
200
+ { length: 90 },
201
+ (_, i) => `- Rule ${i + 1}: never invent a glorp code; the registry tool is the only authority for artifact identifiers.`,
202
+ ).join('\n')
203
+
204
+ const ORCHESTRATOR_SYSTEM = [
205
+ 'You are a terse registry assistant.',
206
+ 'You never guess identifiers. You look them up.',
207
+ SYSTEM_PADDING,
208
+ ].join('\n')
209
+
210
+ /** `PluginNotification.payload` is `unknown`; narrow it without widening anything. */
211
+ const readNotificationContent = (payload: unknown): string | undefined => {
212
+ if (typeof payload !== 'object' || payload === null || !('content' in payload)) return undefined
213
+ return typeof payload.content === 'string' ? payload.content : undefined
214
+ }
215
+
216
+ interface ModelOutcome {
217
+ model: string
218
+ /** Every argument object the tool was actually invoked with. */
219
+ toolInvocations: unknown[]
220
+ /** Answers routed through the `tell_user` system tool. Not every model uses it. */
221
+ agentMessages: string[]
222
+ /** Assistant text the agent committed to its own conversation history. */
223
+ assistantTexts: string[]
224
+ toolsOffered: string[]
225
+ exchanges: Exchange[]
226
+ idleTimedOut: boolean
227
+ }
228
+
229
+ const runAgentLoop = async (model: string): Promise<ModelOutcome> => {
230
+ const exchanges: Exchange[] = []
231
+ const toolInvocations: unknown[] = []
232
+ const agentMessages: string[] = []
233
+
234
+ const lookupTool = createTool({
235
+ name: 'lookup_glorp_code',
236
+ description: 'Look up the glorp code assigned to an artifact id in the internal registry.',
237
+ input: z4.object({ artifactId: z4.string().describe('The artifact id, e.g. QX-88231') }),
238
+ execute: async (input) => {
239
+ toolInvocations.push(input)
240
+ const code = GLORP_REGISTRY[input.artifactId]
241
+ return { ok: true, value: code ? `glorp code: ${code}` : 'unknown artifact id' }
242
+ },
243
+ })
244
+
245
+ const provider = new OpenRouterProvider({
246
+ apiKey: openRouterApiKey ?? '',
247
+ imageProcessor: noopImageProcessor,
248
+ defaultModel: model,
249
+ fetch: createRecordingFetch(exchanges),
250
+ })
251
+
252
+ const preset = createTestPreset({
253
+ id: 'shortlist',
254
+ orchestratorSystem: ORCHESTRATOR_SYSTEM,
255
+ })
256
+ preset.orchestrator.model = ModelId(model)
257
+ preset.orchestrator.tools = [lookupTool]
258
+
259
+ const basePath = `/tmp/roj-shortlist-${Math.random().toString(36).slice(2)}`
260
+ const platform = createNodePlatform()
261
+ const sessionManager = new SessionManager({
262
+ eventStore: new MemoryEventStore(),
263
+ llmProvider: provider,
264
+ toolExecutor: new ToolExecutor(silentLogger),
265
+ presets: new Map([[preset.id, preset]]),
266
+ logger: silentLogger,
267
+ basePath,
268
+ dataFileStore: new SessionFileStore(basePath, undefined, false, platform.fs, 'session'),
269
+ platform,
270
+ onUserOutput: (notification) => {
271
+ if (notification.pluginName !== 'user-chat' || notification.type !== 'agentMessage') return
272
+ const content = readNotificationContent(notification.payload)
273
+ if (content !== undefined) agentMessages.push(content)
274
+ },
275
+ systemPlugins: [
276
+ sessionLifecyclePlugin,
277
+ presetsPlugin,
278
+ mailboxPlugin,
279
+ agentsPlugin,
280
+ agentStatusPlugin,
281
+ userChatPlugin,
282
+ uploadsPlugin,
283
+ resourcesPlugin,
284
+ llmDebugPlugin,
285
+ servicePlugin,
286
+ filesystemPlugin,
287
+ logsPlugin,
288
+ sessionStatsPlugin,
289
+ sessionStatePlugin,
290
+ gitStatusPlugin,
291
+ ],
292
+ })
293
+
294
+ const created = await sessionManager.createSession(preset.id)
295
+ if (!created.ok) throw new Error(`createSession failed: ${created.error.type} — ${created.error.message}`)
296
+ const session = created.value
297
+
298
+ const entryAgentId = session.getEntryAgentId()
299
+ if (!entryAgentId) throw new Error('no entry agent')
300
+
301
+ const sent = await session.callPluginMethod('user-chat.sendMessage', {
302
+ sessionId: String(session.id),
303
+ content: USER_PROMPT,
304
+ agentId: String(entryAgentId),
305
+ })
306
+ if (!sent.ok) throw new Error(`sendMessage failed: ${sent.error.type} — ${sent.error.message}`)
307
+
308
+ let idleTimedOut = false
309
+ try {
310
+ await waitForAllAgentsIdle(session, { timeoutMs: 150_000 })
311
+ } catch {
312
+ // A non-retryable 4xx parks the agent in `error`, which never becomes idle. The recorded
313
+ // exchanges are the evidence either way, so this is reported rather than thrown.
314
+ idleTimedOut = true
315
+ }
316
+
317
+ const agentState = session.getAgent(entryAgentId)?.state
318
+ const assistantTexts = (agentState?.conversationHistory ?? [])
319
+ .filter((m) => m.role === 'assistant')
320
+ .map((m) => (typeof m.content === 'string' ? m.content : ''))
321
+ .filter((text) => text.length > 0)
322
+
323
+ await sessionManager.shutdown()
324
+
325
+ const toolsOffered = (exchanges[0]?.request.tools ?? []).map((t) => t.function.name)
326
+
327
+ return { model, toolInvocations, agentMessages, assistantTexts, toolsOffered, exchanges, idleTimedOut }
328
+ }
329
+
330
+ // ============================================================================
331
+ // Reporting
332
+ // ============================================================================
333
+
334
+ /** Stable short identity for a reasoning_details array, for matching returned blocks to echoed ones. */
335
+ const reasoningFingerprint = (details: unknown[] | undefined): string | null => {
336
+ if (!details?.length) return null
337
+ const serialized = JSON.stringify(details)
338
+ return `${details.length}b/${serialized.length}c/${serialized.slice(0, 24)}`
339
+ }
340
+
341
+ /** One machine-readable line per model, so the run can be turned into a matrix afterwards. */
342
+ const reportOutcome = (outcome: ModelOutcome): void => {
343
+ const { exchanges } = outcome
344
+ const failures = exchanges.filter((e) => e.status >= 400)
345
+ const reasoningTurns = exchanges.filter((e) => (e.response?.choices?.[0]?.message?.reasoning_details?.length ?? 0) > 0).length
346
+ const echoTurns = exchanges.filter((e) => e.request.messages.some((m) => m.role === 'assistant' && m.reasoning_details !== undefined)).length
347
+ const assistantTurns = exchanges.flatMap((e) => e.request.messages.filter((m) => m.role === 'assistant')).length
348
+ const cost = exchanges.reduce((sum, e) => sum + (e.response?.usage?.cost ?? 0), 0)
349
+ const cached = exchanges.reduce((sum, e) => sum + (e.response?.usage?.prompt_tokens_details?.cached_tokens ?? 0), 0)
350
+ const cacheWrites = exchanges.reduce((sum, e) => sum + (e.response?.usage?.prompt_tokens_details?.cache_write_tokens ?? 0), 0)
351
+
352
+ console.log(
353
+ `RESULT ${JSON.stringify({
354
+ model: outcome.model,
355
+ requests: exchanges.length,
356
+ httpFailures: failures.map((f) => ({ status: f.status, body: f.rawResponse.slice(0, 400) })),
357
+ cacheControlSent: exchanges.every((e) => hasCacheControl(e.request)),
358
+ stopSent: exchanges[0]?.request.stop,
359
+ promptTokens: exchanges.map((e) => e.response?.usage?.prompt_tokens ?? 0),
360
+ cachedTokens: cached,
361
+ cacheWriteTokens: cacheWrites,
362
+ reasoningReturnedOnTurns: reasoningTurns,
363
+ assistantMessagesSentBack: assistantTurns,
364
+ assistantMessagesCarryingReasoningDetails: echoTurns,
365
+ reasoningTrace: exchanges.map((e, i) => ({
366
+ turn: i,
367
+ returned: reasoningFingerprint(e.response?.choices?.[0]?.message?.reasoning_details),
368
+ sentBack: e.request.messages.filter((m) => m.role === 'assistant').map((m) => reasoningFingerprint(m.reasoning_details)),
369
+ })),
370
+ toolInvocations: outcome.toolInvocations,
371
+ tellUserOffered: outcome.toolsOffered.includes('tell_user'),
372
+ usedTellUser: outcome.agentMessages.length > 0,
373
+ agentMessages: outcome.agentMessages,
374
+ assistantTexts: outcome.assistantTexts,
375
+ idleTimedOut: outcome.idleTimedOut,
376
+ cost,
377
+ })}`,
378
+ )
379
+ }
380
+
381
+ // ============================================================================
382
+ // Tests
383
+ // ============================================================================
384
+
385
+ const describeLive = (name: string, fn: () => void) => {
386
+ if (!openRouterApiKey) {
387
+ describe.skip(`${name} (skipped — LIVE_TESTS=1 + OPENROUTER_API_KEY required)`, fn)
388
+ return
389
+ }
390
+ describe(name, fn)
391
+ }
392
+
393
+ describeLive('OpenRouter shortlist — agent loop', () => {
394
+ for (const model of selectedModels) {
395
+ test(`${model} runs a tool-calling agent turn`, async () => {
396
+ const outcome = await runAgentLoop(model)
397
+ reportOutcome(outcome)
398
+
399
+ const failed = outcome.exchanges.filter((e) => e.status >= 400)
400
+ if (failed.length > 0) {
401
+ // Isolate the cause: replay the same body without the cache_control markers.
402
+ const first = failed[0]
403
+ const control = await globalThis.fetch('https://openrouter.ai/api/v1/chat/completions', {
404
+ method: 'POST',
405
+ headers: { 'content-type': 'application/json', 'Authorization': `Bearer ${openRouterApiKey}` },
406
+ body: JSON.stringify(stripCacheControl(first.request)),
407
+ })
408
+ const controlBody = await control.text()
409
+ console.log(`CONTROL ${JSON.stringify({ model, withoutCacheControl: control.status, body: controlBody.slice(0, 400) })}`)
410
+ }
411
+
412
+ expect(failed.map((f) => `${f.status} ${f.rawResponse.slice(0, 300)}`)).toEqual([])
413
+
414
+ // The agent loop actually ran: correct tool, correct argument, result used in the answer.
415
+ // The answer counts whether it came back as assistant text or through `tell_user` —
416
+ // which one a model picks is instruction-following, not runtime compatibility.
417
+ expect(outcome.toolInvocations).toContainEqual({ artifactId: 'QX-88231' })
418
+ expect([...outcome.assistantTexts, ...outcome.agentMessages].join('\n')).toContain('ZK-7719')
419
+
420
+ // cache_control travelled on every request — the point of the exercise.
421
+ for (const exchange of outcome.exchanges) {
422
+ expect(hasCacheControl(exchange.request)).toBe(true)
423
+ }
424
+
425
+ // The agent always sends stop sequences, whatever the model advertises.
426
+ expect(outcome.exchanges[0].request.stop).toEqual(['<message'])
427
+
428
+ // reasoning_details round trip. The negative control comes first: a model that never
429
+ // reasons must produce no `reasoning_details` key anywhere on the wire, because the prompt
430
+ // cache is keyed on the serialized prefix and the pre-fix bytes had no such key.
431
+ const producedReasoning = outcome.exchanges.some((e) => (e.response?.choices?.[0]?.message?.reasoning_details?.length ?? 0) > 0)
432
+ const echoed = outcome.exchanges.some((e) => e.request.messages.some((m) => m.role === 'assistant' && m.reasoning_details !== undefined))
433
+ if (!producedReasoning) {
434
+ expect(echoed).toBe(false)
435
+ return
436
+ }
437
+
438
+ // Verbatim, on the wire: every block set that goes back out must be byte-identical to one
439
+ // the provider returned earlier — nothing invented, reordered or edited, which is exactly
440
+ // what OpenRouter rejects. All these requests came back 200 (asserted above), so the echo
441
+ // was also accepted.
442
+ //
443
+ // The converse does not hold and must not be asserted: an auxiliary inference
444
+ // (`runAuxiliaryInference`) and an empty-response retry both discard their response
445
+ // without committing it to history, so some returned blocks legitimately never come back.
446
+ const returnedFingerprints = new Set<string>()
447
+ let verbatimEchoes = 0
448
+ for (const exchange of outcome.exchanges) {
449
+ for (const message of exchange.request.messages) {
450
+ if (message.reasoning_details === undefined) continue
451
+ expect(returnedFingerprints).toContain(JSON.stringify(message.reasoning_details))
452
+ verbatimEchoes++
453
+ }
454
+ const returned = exchange.response?.choices?.[0]?.message?.reasoning_details
455
+ if (returned?.length) returnedFingerprints.add(JSON.stringify(returned))
456
+ }
457
+ console.log(`ROUNDTRIP ${JSON.stringify({ model, verbatimEchoes, roundTripProven: verbatimEchoes > 0 })}`)
458
+ }, 200_000)
459
+ }
460
+ })
461
+
462
+ /**
463
+ * `stop` is sent on every agent inference (`agent.ts` hardcodes `['<message']` to stop the model
464
+ * hallucinating message tags), but several shortlist models do not list `stop` in
465
+ * `supported_parameters`. OpenRouter drops unsupported parameters instead of erroring, so the
466
+ * question is not "does the request fail" but "is the guard actually in force". Asking the model
467
+ * to emit text that straddles the stop sequence answers it: truncated → honored, intact → dropped.
468
+ */
469
+ describeLive('OpenRouter shortlist — stop sequences', () => {
470
+ for (const model of selectedModels) {
471
+ test(`${model} stop sequence behaviour`, async () => {
472
+ const exchanges: Exchange[] = []
473
+ const provider = new OpenRouterProvider({
474
+ apiKey: openRouterApiKey ?? '',
475
+ imageProcessor: noopImageProcessor,
476
+ defaultModel: model,
477
+ fetch: createRecordingFetch(exchanges),
478
+ })
479
+
480
+ const result = await provider.inference({
481
+ model: ModelId(model),
482
+ systemPrompt: 'You echo text literally. No preamble, no commentary, no markdown.',
483
+ messages: [{ role: 'user', content: 'Repeat this line exactly, and nothing else: ALPHA<message>BRAVO' }],
484
+ stopSequences: ['<message'],
485
+ // Generous: a reasoning model spends most of its budget before the first visible token.
486
+ maxTokens: 512,
487
+ })
488
+
489
+ const content = result.ok ? (result.value.content ?? '') : ''
490
+ const reasoning = result.ok ? (result.value.reasoning ?? '') : ''
491
+
492
+ // `honored_killed_turn` is the dangerous one: the sequence also applies to the reasoning
493
+ // stream, so the model stops mid-thought and the turn yields no content at all.
494
+ const verdict = content.includes('ALPHA') && !content.includes('BRAVO')
495
+ ? 'honored'
496
+ : content.includes('ALPHA') && content.includes('BRAVO')
497
+ ? 'ignored'
498
+ : content === '' && reasoning.includes('ALPHA') && !reasoning.includes('BRAVO')
499
+ ? 'honored_killed_turn'
500
+ : 'inconclusive'
501
+
502
+ console.log(
503
+ `STOP ${JSON.stringify({
504
+ model,
505
+ status: exchanges[0]?.status,
506
+ stopInRequest: exchanges[0]?.request.stop,
507
+ verdict,
508
+ content,
509
+ reasoning: reasoning.slice(0, 200),
510
+ finishReason: result.ok ? result.value.finishReason : undefined,
511
+ error: result.ok ? undefined : result.error,
512
+ cost: exchanges[0]?.response?.usage?.cost,
513
+ })}`,
514
+ )
515
+
516
+ // The only hard requirement: sending `stop` must never break the request.
517
+ expect(exchanges[0]?.request.stop).toEqual(['<message'])
518
+ expect(exchanges[0]?.status).toBe(200)
519
+ }, 120_000)
520
+ }
521
+ })
522
+
523
+ interface VisionProbe {
524
+ status: number | undefined
525
+ content: string | null | undefined
526
+ error: unknown
527
+ body: string | undefined
528
+ cost: number | undefined
529
+ }
530
+
531
+ const probeVision = async (model: string, imageBase64: string, maxTokens: number): Promise<VisionProbe> => {
532
+ const exchanges: Exchange[] = []
533
+ const provider = new OpenRouterProvider({
534
+ apiKey: openRouterApiKey ?? '',
535
+ imageProcessor: noopImageProcessor,
536
+ defaultModel: model,
537
+ fetch: createRecordingFetch(exchanges),
538
+ })
539
+
540
+ const result = await provider.inference({
541
+ model: ModelId(model),
542
+ systemPrompt: 'You describe images. Answer with a single colour word and nothing else.',
543
+ messages: [{
544
+ role: 'user',
545
+ content: [
546
+ { type: 'text', text: 'What colour fills this image? Answer with one colour word.' },
547
+ { type: 'image_url', imageUrl: { url: `data:image/png;base64,${imageBase64}` } },
548
+ ],
549
+ }],
550
+ maxTokens,
551
+ })
552
+
553
+ return {
554
+ status: exchanges[0]?.status,
555
+ content: result.ok ? result.value.content : undefined,
556
+ error: result.ok ? undefined : result.error,
557
+ body: result.ok ? undefined : exchanges[0]?.rawResponse.slice(0, 300),
558
+ cost: exchanges[0]?.response?.usage?.cost,
559
+ }
560
+ }
561
+
562
+ describeLive('OpenRouter shortlist — vision', () => {
563
+ for (const model of selectedModels) {
564
+ test(`${model} accepts an inline image`, async () => {
565
+ // Two fixtures: the existing 1x1 constant (some providers reject it on size), then a
566
+ // 32x32 solid blue that clears every minimum. Answering "blue" is behavioural proof the
567
+ // pixels arrived; a 200 with empty content only proves the request shape was accepted.
568
+ const tiny = await probeVision(model, TINY_PNG, 64)
569
+ const blue = await probeVision(model, BLUE_PNG_32, 512)
570
+
571
+ console.log(`VISION ${JSON.stringify({ model, tiny1x1: tiny, blue32x32: blue, sawBlue: (blue.content ?? '').toLowerCase().includes('blue') })}`)
572
+
573
+ expect(blue.status).toBe(200)
574
+ expect((blue.content ?? '').toLowerCase()).toContain('blue')
575
+ }, 180_000)
576
+ }
577
+ })
@@ -208,3 +208,27 @@ describe('OpenRouterProvider buildHttpRequest', () => {
208
208
  }
209
209
  })
210
210
  })
211
+
212
+ // ============================================================================
213
+ // Reasoning budget
214
+ // ============================================================================
215
+
216
+ describe('OpenRouterProvider reasoning budget', () => {
217
+ test('forwards the reasoning budget', async () => {
218
+ const request = buildRequest([{ role: 'user', content: 'Hello' }])
219
+ request.openrouter = { reasoning: { effort: 'low' } }
220
+
221
+ const http = await createProvider().buildHttpRequest(request)
222
+
223
+ expect(http.body).toEqual(expect.objectContaining({ reasoning: { effort: 'low' } }))
224
+ })
225
+
226
+ // Omitted, not defaulted: the model's own default applies, and adding the key would
227
+ // rewrite the cached prefix for every request that does not want to set it. Asserted on
228
+ // the serialized body — an undefined-valued key is still a present key on the object.
229
+ test('omits reasoning when unset', async () => {
230
+ const http = await createProvider().buildHttpRequest(buildRequest([{ role: 'user', content: 'Hello' }]))
231
+
232
+ expect(JSON.stringify(http.body)).not.toContain('"reasoning"')
233
+ })
234
+ })