@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
@@ -1,4 +1,4 @@
1
- import type { ToolCall } from '~/core/agents/state.js'
1
+ import type { ReasoningDetails, ToolCall } from '~/core/agents/state.js'
2
2
  import type { ImageProcessor } from '~/core/image/types.js'
3
3
  import type { ChatMessageContentItem } from '~/core/llm/llm-log-types.js'
4
4
  import { ToolCallId } from '~/core/tools/schema.js'
@@ -14,6 +14,7 @@ import type {
14
14
  LLMMetrics,
15
15
  LLMProvider,
16
16
  OpenRouterProviderRouting,
17
+ OpenRouterReasoningOptions,
17
18
  ProviderHttpRequest,
18
19
  RawInferenceRequest,
19
20
  } from './provider.js'
@@ -52,6 +53,9 @@ interface OpenRouterChoice {
52
53
  message: {
53
54
  content: string | null | Array<{ type: string; text?: string }>
54
55
  tool_calls?: OpenRouterToolCall[]
56
+ /** Plaintext chain of thought, when the model exposes one. Display only — the round trip goes through reasoning_details. */
57
+ reasoning?: string
58
+ reasoning_details?: ReasoningDetails
55
59
  }
56
60
  finish_reason: string | null
57
61
  }
@@ -107,6 +111,7 @@ interface OpenRouterMessage {
107
111
  content: string | OpenRouterContentItem[]
108
112
  tool_calls?: OpenRouterToolCall[]
109
113
  tool_call_id?: string
114
+ reasoning_details?: ReasoningDetails
110
115
  }
111
116
 
112
117
  /**
@@ -160,6 +165,8 @@ interface OpenRouterRequestBody {
160
165
  stream_options: { include_usage: true }
161
166
  /** Provider routing preferences */
162
167
  provider?: OpenRouterProviderRouting
168
+ /** Reasoning budget */
169
+ reasoning?: OpenRouterReasoningOptions
163
170
  /** Routing strategy */
164
171
  route?: 'fallback'
165
172
  /** Model transforms */
@@ -273,6 +280,9 @@ export class OpenRouterProvider implements LLMProvider {
273
280
  finishReason: this.mapFinishReason(choice.finish_reason),
274
281
  metrics,
275
282
  providerRequestId: data.id,
283
+ reasoning: choice.message.reasoning || undefined,
284
+ // Normalized to undefined here so an empty array never reaches the wire on the way back.
285
+ reasoningDetails: choice.message.reasoning_details?.length ? choice.message.reasoning_details : undefined,
276
286
  })
277
287
  } catch (error) {
278
288
  return Err(this.mapError(error))
@@ -317,6 +327,9 @@ export class OpenRouterProvider implements LLMProvider {
317
327
  if (orOpts.providers) {
318
328
  body.provider = orOpts.providers
319
329
  }
330
+ if (orOpts.reasoning) {
331
+ body.reasoning = orOpts.reasoning
332
+ }
320
333
  if (orOpts.route) {
321
334
  body.route = orOpts.route
322
335
  }
@@ -366,19 +379,22 @@ export class OpenRouterProvider implements LLMProvider {
366
379
  tool_call_id: msg.toolCallId,
367
380
  }
368
381
  }
369
- case 'assistant':
382
+ case 'assistant': {
383
+ const mapped: OpenRouterMessage = { role: 'assistant', content: msg.content }
370
384
  if (msg.toolCalls?.length) {
371
- return {
372
- role: 'assistant',
373
- content: msg.content,
374
- tool_calls: msg.toolCalls.map((tc): OpenRouterToolCall => ({
375
- id: tc.id,
376
- type: 'function',
377
- function: { name: tc.name, arguments: JSON.stringify(tc.input) },
378
- })),
379
- }
385
+ mapped.tool_calls = msg.toolCalls.map((tc): OpenRouterToolCall => ({
386
+ id: tc.id,
387
+ type: 'function',
388
+ function: { name: tc.name, arguments: JSON.stringify(tc.input) },
389
+ }))
380
390
  }
381
- return { role: 'assistant', content: msg.content }
391
+ // Echoed verbatim and only when present: the provider rejects an edited or reordered
392
+ // sequence, and adding the key for a non-reasoning model would rewrite the cached prefix.
393
+ if (msg.reasoningDetails?.length) {
394
+ mapped.reasoning_details = msg.reasoningDetails
395
+ }
396
+ return mapped
397
+ }
382
398
  case 'system':
383
399
  return { role: 'system', content: msg.content }
384
400
  case 'user':
@@ -1,4 +1,13 @@
1
- import type { AssistantLLMMessage, LLMMessage, SystemLLMMessage, ToolCall, ToolLLMMessage, UserLLMMessage } from '~/core/agents/state.js'
1
+ import type {
2
+ AssistantLLMMessage,
3
+ LLMMessage,
4
+ ReasoningDetails,
5
+ SystemLLMMessage,
6
+ ThinkingBlocks,
7
+ ToolCall,
8
+ ToolLLMMessage,
9
+ UserLLMMessage,
10
+ } from '~/core/agents/state.js'
2
11
  import type { FileStore } from '~/core/file-store/types.js'
3
12
  import type { ToolResultContent } from '~/core/llm/llm-log-types.js'
4
13
  import type { ToolDefinition } from '~/core/tools/definition.js'
@@ -7,7 +16,7 @@ import type { Result } from '~/lib/utils/result.js'
7
16
  import { ModelId } from './schema.js'
8
17
 
9
18
  // Re-export LLMMessage types from agents/state for backwards compatibility
10
- export type { AssistantLLMMessage, LLMMessage, SystemLLMMessage, ToolCall, ToolLLMMessage, UserLLMMessage }
19
+ export type { AssistantLLMMessage, LLMMessage, ReasoningDetails, SystemLLMMessage, ThinkingBlocks, ToolCall, ToolLLMMessage, UserLLMMessage }
11
20
 
12
21
  // ============================================================================
13
22
  // Request types
@@ -41,9 +50,37 @@ export interface OpenRouterProviderRouting {
41
50
  quantizations?: string[]
42
51
  }
43
52
 
53
+ /**
54
+ * Reasoning controls.
55
+ *
56
+ * Without these the model's own default applies, and several of the defaults are expensive:
57
+ * `high` on Claude Sonnet 5, Opus 5 and Grok 4.5, `max` on Kimi K3. Reasoning is also
58
+ * `mandatory` on Gemini 3.6 Flash, Grok 4.5 and Kimi K2.7 Code — those cannot be turned off,
59
+ * only turned down.
60
+ *
61
+ * **Which efforts a model accepts differs per model.** The catalog's
62
+ * `reasoning.supported_efforts` is the authority: Kimi K3 has no `medium`, `minimal` exists
63
+ * only on Gemini, `xhigh`/`none` only on the OpenAI and newer Anthropic models. The union here
64
+ * is every value seen across them, not a set any single model takes.
65
+ *
66
+ * @see https://openrouter.ai/docs/use-cases/reasoning-tokens
67
+ */
68
+ export interface OpenRouterReasoningOptions {
69
+ /** Relative budget. Mutually exclusive with `max_tokens` — set one. */
70
+ effort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
71
+ /** Absolute token budget, for models that take one instead of an effort. */
72
+ max_tokens?: number
73
+ /** Explicitly enable reasoning at the model's default effort. */
74
+ enabled?: boolean
75
+ /** Reason, but omit the blocks from the response. Note this breaks the round trip. */
76
+ exclude?: boolean
77
+ }
78
+
44
79
  export interface OpenRouterRequestOptions {
45
80
  /** Provider routing preferences */
46
81
  providers?: OpenRouterProviderRouting
82
+ /** Reasoning budget. Omitted: the model's own default, which is often `high`. */
83
+ reasoning?: OpenRouterReasoningOptions
47
84
  /** Routing strategy */
48
85
  route?: 'fallback'
49
86
  /** Model transforms */
@@ -141,8 +178,12 @@ export interface InferenceResponse {
141
178
  metrics: LLMMetrics
142
179
  /** Provider-specific request ID (e.g., OpenRouter generation ID for fetching stats) */
143
180
  providerRequestId?: string
144
- /** Extended thinking / reasoning content (for models that support it) */
181
+ /** Extended thinking / reasoning content (for models that support it) — human-readable, for logs and the debug UI. */
145
182
  reasoning?: string
183
+ /** Opaque reasoning blocks to echo back on the next request. Absent for models that do not reason. */
184
+ reasoningDetails?: ReasoningDetails
185
+ /** Anthropic thinking blocks to replay on the next request. Absent unless extended thinking is enabled. */
186
+ thinkingBlocks?: ThinkingBlocks
146
187
  }
147
188
 
148
189
  /**
@@ -0,0 +1,177 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { LLMMessage } from '~/core/agents/state.js'
3
+ import { isOk } from '~/lib/utils/result.js'
4
+ import { ToolCallId } from '~/core/tools/schema.js'
5
+ import { OpenRouterProvider } from './openrouter.js'
6
+ import type { InferenceRequest, RawInferenceRequest } from './provider.js'
7
+ import { ModelId } from './schema.js'
8
+
9
+ // ============================================================================
10
+ // reasoning_details round trip
11
+ // ============================================================================
12
+ //
13
+ // OpenRouter returns `reasoning_details` on an assistant message and expects the
14
+ // exact sequence back on the following requests, or a reasoning model loses its
15
+ // chain of thought at every tool call. The blocks are opaque, so what matters is
16
+ // that they come back byte-identical — and that a model which never reasons
17
+ // keeps producing the same request bytes, since the prompt cache is keyed on them.
18
+
19
+ const testModel = ModelId('openai/gpt-5.6-luna')
20
+
21
+ /** Shape taken from the OpenRouter docs; treated as opaque everywhere in the SDK. */
22
+ const REASONING_DETAILS = [
23
+ {
24
+ type: 'reasoning.summary',
25
+ summary: 'Analyzed the problem by breaking it into components',
26
+ id: 'reasoning-summary-1',
27
+ format: 'anthropic-claude-v1',
28
+ index: 0,
29
+ },
30
+ {
31
+ type: 'reasoning.encrypted',
32
+ data: 'EroBCkYIBRgCKkCr0hZ0opaque',
33
+ id: 'reasoning-encrypted-1',
34
+ format: 'openai-responses-v1',
35
+ index: 1,
36
+ },
37
+ ]
38
+
39
+ type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
40
+
41
+ const createProvider = (fetchFn?: FetchFn) =>
42
+ new OpenRouterProvider({
43
+ apiKey: 'test-key',
44
+ imageProcessor: { resolveContent: async (content) => content },
45
+ defaultModel: String(testModel),
46
+ fetch: fetchFn,
47
+ })
48
+
49
+ const respondWith = (message: Record<string, unknown>): FetchFn => async () =>
50
+ new Response(
51
+ JSON.stringify({
52
+ id: 'gen-1',
53
+ model: String(testModel),
54
+ choices: [{ message, finish_reason: 'stop' }],
55
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
56
+ }),
57
+ { status: 200, headers: { 'content-type': 'application/json' } },
58
+ )
59
+
60
+ const inferenceRequest = (): InferenceRequest => ({
61
+ model: testModel,
62
+ systemPrompt: 'You are helpful.',
63
+ messages: [{ role: 'user', content: 'What is the weather?' }],
64
+ })
65
+
66
+ const rawRequest = (messages: LLMMessage[]): RawInferenceRequest => ({
67
+ model: testModel,
68
+ systemPrompt: 'You are helpful.',
69
+ messages,
70
+ })
71
+
72
+ describe('OpenRouter reasoning_details capture', () => {
73
+ test('captures the blocks and the plaintext reasoning from a response', async () => {
74
+ const provider = createProvider(respondWith({
75
+ content: 'It is sunny.',
76
+ reasoning: 'The user asked about weather, so I check the tool first.',
77
+ reasoning_details: REASONING_DETAILS,
78
+ }))
79
+
80
+ const result = await provider.inference(inferenceRequest())
81
+
82
+ expect(isOk(result)).toBe(true)
83
+ if (!isOk(result)) return
84
+ expect(result.value.reasoningDetails).toEqual(REASONING_DETAILS)
85
+ expect(result.value.reasoning).toBe('The user asked about weather, so I check the tool first.')
86
+ })
87
+
88
+ test('leaves both fields unset for a model that does not reason', async () => {
89
+ const provider = createProvider(respondWith({ content: 'It is sunny.' }))
90
+
91
+ const result = await provider.inference(inferenceRequest())
92
+
93
+ expect(isOk(result)).toBe(true)
94
+ if (!isOk(result)) return
95
+ expect(result.value.reasoningDetails).toBeUndefined()
96
+ expect(result.value.reasoning).toBeUndefined()
97
+ })
98
+
99
+ test('normalizes an empty block list to undefined, so nothing is echoed back', async () => {
100
+ const provider = createProvider(respondWith({ content: 'It is sunny.', reasoning_details: [] }))
101
+
102
+ const result = await provider.inference(inferenceRequest())
103
+
104
+ expect(isOk(result)).toBe(true)
105
+ if (!isOk(result)) return
106
+ expect(result.value.reasoningDetails).toBeUndefined()
107
+ })
108
+ })
109
+
110
+ describe('OpenRouter reasoning_details echo', () => {
111
+ test('sends the blocks back verbatim on the assistant message', async () => {
112
+ const http = await createProvider().buildHttpRequest(rawRequest([
113
+ { role: 'user', content: 'What is the weather?' },
114
+ {
115
+ role: 'assistant',
116
+ content: '',
117
+ toolCalls: [{ id: ToolCallId('call_1'), name: 'get_weather', input: { city: 'Prague' } }],
118
+ reasoningDetails: REASONING_DETAILS,
119
+ },
120
+ { role: 'tool', toolCallId: ToolCallId('call_1'), content: 'Sunny' },
121
+ ]))
122
+
123
+ // Serialized comparison, because "verbatim" is a claim about the bytes on the
124
+ // wire — a deep-equal check would pass even if the blocks were rebuilt.
125
+ const assistant = '{"role":"assistant","content":"","tool_calls":['
126
+ + '{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\\"city\\":\\"Prague\\"}"}}'
127
+ + `],"reasoning_details":${JSON.stringify(REASONING_DETAILS)}}`
128
+ expect(JSON.stringify(http.body)).toContain(assistant)
129
+ })
130
+
131
+ test('echoes the blocks on an assistant message that made no tool call', async () => {
132
+ const http = await createProvider().buildHttpRequest(rawRequest([
133
+ { role: 'user', content: 'Think about it.' },
134
+ { role: 'assistant', content: 'Thought about it.', reasoningDetails: REASONING_DETAILS },
135
+ { role: 'user', content: 'And now?' },
136
+ ]))
137
+
138
+ expect(JSON.stringify(http.body)).toContain(
139
+ `{"role":"assistant","content":"Thought about it.","reasoning_details":${JSON.stringify(REASONING_DETAILS)}}`,
140
+ )
141
+ })
142
+ })
143
+
144
+ describe('OpenRouter request bytes without reasoning', () => {
145
+ // The prompt cache is keyed on the serialized prefix, so a key that appears
146
+ // even as `null`/`undefined` for a non-reasoning model would rewrite it.
147
+ test('adds nothing to a plain assistant message', async () => {
148
+ const http = await createProvider().buildHttpRequest(rawRequest([
149
+ { role: 'user', content: 'What is 2+2?' },
150
+ { role: 'assistant', content: '4' },
151
+ { role: 'user', content: 'And 3+3?' },
152
+ ]))
153
+
154
+ const body = JSON.stringify(http.body)
155
+ expect(body).toContain('{"role":"assistant","content":"4"}')
156
+ expect(body).not.toContain('reasoning')
157
+ })
158
+
159
+ test('adds nothing to an assistant message with tool calls', async () => {
160
+ const http = await createProvider().buildHttpRequest(rawRequest([
161
+ { role: 'user', content: 'What is the weather?' },
162
+ {
163
+ role: 'assistant',
164
+ content: '',
165
+ toolCalls: [{ id: ToolCallId('call_1'), name: 'get_weather', input: { city: 'Prague' } }],
166
+ },
167
+ { role: 'tool', toolCallId: ToolCallId('call_1'), content: 'Sunny' },
168
+ ]))
169
+
170
+ const body = JSON.stringify(http.body)
171
+ expect(body).toContain(
172
+ '{"role":"assistant","content":"","tool_calls":['
173
+ + '{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\\"city\\":\\"Prague\\"}"}}]}',
174
+ )
175
+ expect(body).not.toContain('reasoning')
176
+ })
177
+ })
@@ -48,6 +48,9 @@ export type LLMMetrics = {
48
48
  // LLM events
49
49
  // ============================================================================
50
50
 
51
+ /** Opaque by design — provider chain-of-thought blocks are stored and echoed verbatim, never inspected. */
52
+ const opaqueBlocksSchema = z4.array(z4.unknown())
53
+
51
54
  const llmMetricsSchema = z4.object({
52
55
  promptTokens: z4.number(),
53
56
  completionTokens: z4.number(),
@@ -77,6 +80,15 @@ export const llmEvents = createEventsFactory({
77
80
  name: z4.string(),
78
81
  input: z4.unknown(),
79
82
  })),
83
+ /**
84
+ * Opaque provider reasoning blocks (see ReasoningDetails). Carried by the
85
+ * event, not just by the live response, because a session rebuilt from the
86
+ * log would otherwise resume without the chain of thought the provider
87
+ * expects to get back.
88
+ */
89
+ reasoningDetails: opaqueBlocksSchema.optional(),
90
+ /** Anthropic thinking blocks (see ThinkingBlocks). Carried for the same reason. */
91
+ thinkingBlocks: opaqueBlocksSchema.optional(),
80
92
  }),
81
93
  metrics: llmMetricsSchema,
82
94
  llmCallId: llmCallIdSchema.optional(),