@ottocode/server 0.1.228 → 0.1.231

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.
@@ -0,0 +1,291 @@
1
+ import {
2
+ catalog,
3
+ getModelNpmBinding,
4
+ getUnderlyingProviderKey,
5
+ modelSupportsReasoning,
6
+ type ProviderId,
7
+ type ReasoningLevel,
8
+ } from '@ottocode/sdk';
9
+
10
+ const THINKING_BUDGET = 16000;
11
+
12
+ export type ReasoningConfigResult = {
13
+ providerOptions: Record<string, unknown>;
14
+ effectiveMaxOutputTokens: number | undefined;
15
+ enabled: boolean;
16
+ };
17
+
18
+ function normalizeReasoningLevel(
19
+ level: ReasoningLevel | undefined,
20
+ ): Exclude<ReasoningLevel, 'xhigh'> {
21
+ if (!level) return 'high';
22
+ if (level === 'xhigh') return 'high';
23
+ return level;
24
+ }
25
+
26
+ function toAnthropicEffort(
27
+ level: ReasoningLevel | undefined,
28
+ ): 'low' | 'medium' | 'high' | 'max' {
29
+ switch (level) {
30
+ case 'minimal':
31
+ case 'low':
32
+ return 'low';
33
+ case 'medium':
34
+ return 'medium';
35
+ case 'max':
36
+ case 'xhigh':
37
+ return 'max';
38
+ case 'high':
39
+ default:
40
+ return 'high';
41
+ }
42
+ }
43
+
44
+ function toOpenAIEffort(
45
+ level: ReasoningLevel | undefined,
46
+ ): 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' {
47
+ switch (level) {
48
+ case 'minimal':
49
+ return 'minimal';
50
+ case 'low':
51
+ return 'low';
52
+ case 'medium':
53
+ return 'medium';
54
+ case 'max':
55
+ case 'xhigh':
56
+ return 'xhigh';
57
+ case 'high':
58
+ default:
59
+ return 'high';
60
+ }
61
+ }
62
+
63
+ function toGoogleThinkingLevel(
64
+ level: ReasoningLevel | undefined,
65
+ ): 'minimal' | 'low' | 'medium' | 'high' {
66
+ switch (level) {
67
+ case 'minimal':
68
+ return 'minimal';
69
+ case 'low':
70
+ return 'low';
71
+ case 'medium':
72
+ return 'medium';
73
+ case 'max':
74
+ case 'xhigh':
75
+ case 'high':
76
+ default:
77
+ return 'high';
78
+ }
79
+ }
80
+
81
+ function toThinkingBudget(
82
+ level: ReasoningLevel | undefined,
83
+ maxOutputTokens: number | undefined,
84
+ ): number {
85
+ const cap = maxOutputTokens
86
+ ? Math.max(maxOutputTokens, THINKING_BUDGET)
87
+ : THINKING_BUDGET;
88
+ switch (level) {
89
+ case 'minimal':
90
+ return Math.min(2048, cap);
91
+ case 'low':
92
+ return Math.min(4096, cap);
93
+ case 'medium':
94
+ return Math.min(8192, cap);
95
+ case 'max':
96
+ case 'xhigh':
97
+ return Math.min(24000, cap);
98
+ case 'high':
99
+ default:
100
+ return Math.min(16000, cap);
101
+ }
102
+ }
103
+
104
+ function toCamelCaseKey(value: string): string {
105
+ return value
106
+ .replace(/[^a-zA-Z0-9]+/g, ' ')
107
+ .trim()
108
+ .split(/\s+/)
109
+ .map((segment, index) => {
110
+ const lower = segment.toLowerCase();
111
+ if (index === 0) return lower;
112
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
113
+ })
114
+ .join('');
115
+ }
116
+
117
+ function getOpenAICompatibleProviderOptionKeys(provider: ProviderId): string[] {
118
+ const entry = catalog[provider];
119
+ const keys = new Set<string>(['openaiCompatible', toCamelCaseKey(provider)]);
120
+ if (entry?.label) {
121
+ keys.add(toCamelCaseKey(entry.label));
122
+ }
123
+ return Array.from(keys).filter(Boolean);
124
+ }
125
+
126
+ function buildSharedProviderOptions(
127
+ provider: ProviderId,
128
+ options: Record<string, unknown>,
129
+ ): Record<string, unknown> {
130
+ const keys = getOpenAICompatibleProviderOptionKeys(provider);
131
+ return Object.fromEntries(keys.map((key) => [key, options]));
132
+ }
133
+
134
+ function usesAdaptiveAnthropicThinking(model: string): boolean {
135
+ const lower = model.toLowerCase();
136
+ return (
137
+ lower.includes('claude-opus-4-6') ||
138
+ lower.includes('claude-opus-4.6') ||
139
+ lower.includes('claude-sonnet-4-6') ||
140
+ lower.includes('claude-sonnet-4.6')
141
+ );
142
+ }
143
+
144
+ function getReasoningProviderTarget(
145
+ provider: ProviderId,
146
+ model: string,
147
+ ):
148
+ | 'anthropic'
149
+ | 'openai'
150
+ | 'google'
151
+ | 'openai-compatible'
152
+ | 'openrouter'
153
+ | null {
154
+ if (provider === 'openrouter') return 'openrouter';
155
+ if (
156
+ provider === 'moonshot' ||
157
+ provider === 'zai' ||
158
+ provider === 'zai-coding'
159
+ ) {
160
+ return 'openai-compatible';
161
+ }
162
+ if (provider === 'minimax') return 'anthropic';
163
+
164
+ const npmBinding = getModelNpmBinding(provider, model);
165
+ if (npmBinding === '@ai-sdk/anthropic') return 'anthropic';
166
+ if (npmBinding === '@ai-sdk/openai') return 'openai';
167
+ if (npmBinding === '@ai-sdk/google') return 'google';
168
+ if (npmBinding === '@ai-sdk/openai-compatible') return 'openai-compatible';
169
+ if (npmBinding === '@openrouter/ai-sdk-provider') return 'openrouter';
170
+
171
+ const underlyingProvider = getUnderlyingProviderKey(provider, model);
172
+ if (underlyingProvider === 'anthropic') return 'anthropic';
173
+ if (underlyingProvider === 'openai') return 'openai';
174
+ if (underlyingProvider === 'google') return 'google';
175
+ if (underlyingProvider === 'openai-compatible') return 'openai-compatible';
176
+ return null;
177
+ }
178
+
179
+ export function buildReasoningConfig(args: {
180
+ provider: ProviderId;
181
+ model: string;
182
+ reasoningText?: boolean;
183
+ reasoningLevel?: ReasoningLevel;
184
+ maxOutputTokens: number | undefined;
185
+ }): ReasoningConfigResult {
186
+ const { provider, model, reasoningText, reasoningLevel, maxOutputTokens } =
187
+ args;
188
+ if (!reasoningText || !modelSupportsReasoning(provider, model)) {
189
+ return {
190
+ providerOptions: {},
191
+ effectiveMaxOutputTokens: maxOutputTokens,
192
+ enabled: false,
193
+ };
194
+ }
195
+
196
+ const reasoningTarget = getReasoningProviderTarget(provider, model);
197
+ if (reasoningTarget === 'anthropic') {
198
+ if (usesAdaptiveAnthropicThinking(model)) {
199
+ return {
200
+ providerOptions: {
201
+ anthropic: {
202
+ thinking: { type: 'adaptive' },
203
+ effort: toAnthropicEffort(reasoningLevel),
204
+ },
205
+ },
206
+ effectiveMaxOutputTokens: maxOutputTokens,
207
+ enabled: true,
208
+ };
209
+ }
210
+
211
+ const thinkingBudget = toThinkingBudget(reasoningLevel, maxOutputTokens);
212
+
213
+ return {
214
+ providerOptions: {
215
+ anthropic: {
216
+ thinking: { type: 'enabled', budgetTokens: thinkingBudget },
217
+ },
218
+ },
219
+ effectiveMaxOutputTokens:
220
+ maxOutputTokens && maxOutputTokens > thinkingBudget
221
+ ? maxOutputTokens - thinkingBudget
222
+ : maxOutputTokens,
223
+ enabled: true,
224
+ };
225
+ }
226
+
227
+ if (reasoningTarget === 'openai') {
228
+ return {
229
+ providerOptions: {
230
+ openai: {
231
+ reasoningEffort: toOpenAIEffort(reasoningLevel),
232
+ reasoningSummary: 'auto',
233
+ },
234
+ },
235
+ effectiveMaxOutputTokens: maxOutputTokens,
236
+ enabled: true,
237
+ };
238
+ }
239
+
240
+ if (reasoningTarget === 'google') {
241
+ const isGemini3 = model.includes('gemini-3');
242
+ return {
243
+ providerOptions: {
244
+ google: {
245
+ thinkingConfig: isGemini3
246
+ ? {
247
+ thinkingLevel: toGoogleThinkingLevel(reasoningLevel),
248
+ includeThoughts: true,
249
+ }
250
+ : {
251
+ thinkingBudget: toThinkingBudget(
252
+ reasoningLevel,
253
+ maxOutputTokens,
254
+ ),
255
+ includeThoughts: true,
256
+ },
257
+ },
258
+ },
259
+ effectiveMaxOutputTokens: maxOutputTokens,
260
+ enabled: true,
261
+ };
262
+ }
263
+
264
+ if (reasoningTarget === 'openrouter') {
265
+ return {
266
+ providerOptions: {
267
+ openrouter: {
268
+ reasoning: { effort: normalizeReasoningLevel(reasoningLevel) },
269
+ },
270
+ },
271
+ effectiveMaxOutputTokens: maxOutputTokens,
272
+ enabled: true,
273
+ };
274
+ }
275
+
276
+ if (reasoningTarget === 'openai-compatible') {
277
+ return {
278
+ providerOptions: buildSharedProviderOptions(provider, {
279
+ reasoningEffort: normalizeReasoningLevel(reasoningLevel),
280
+ }),
281
+ effectiveMaxOutputTokens: maxOutputTokens,
282
+ enabled: true,
283
+ };
284
+ }
285
+
286
+ return {
287
+ providerOptions: {},
288
+ effectiveMaxOutputTokens: maxOutputTokens,
289
+ enabled: false,
290
+ };
291
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ProviderName } from '../provider/index.ts';
2
2
  import { publish } from '../../events/bus.ts';
3
3
  import type { ToolApprovalMode } from '../tools/approval.ts';
4
+ import type { ReasoningLevel } from '@ottocode/sdk';
4
5
 
5
6
  export type RunOpts = {
6
7
  sessionId: string;
@@ -12,6 +13,7 @@ export type RunOpts = {
12
13
  oneShot?: boolean;
13
14
  userContext?: string;
14
15
  reasoningText?: boolean;
16
+ reasoningLevel?: ReasoningLevel;
15
17
  abortSignal?: AbortSignal;
16
18
  isCompactCommand?: boolean;
17
19
  compactionContext?: string;
@@ -1,9 +1,19 @@
1
+ import { resolve as resolvePath } from 'node:path';
2
+
1
3
  export type GuardAction =
2
4
  | { type: 'block'; reason: string }
3
5
  | { type: 'approve'; reason: string }
4
6
  | { type: 'allow' };
5
7
 
6
- export function guardToolCall(toolName: string, args: unknown): GuardAction {
8
+ export type GuardContext = {
9
+ projectRoot?: string;
10
+ };
11
+
12
+ export function guardToolCall(
13
+ toolName: string,
14
+ args: unknown,
15
+ context: GuardContext = {},
16
+ ): GuardAction {
7
17
  const a = (args ?? {}) as Record<string, unknown>;
8
18
 
9
19
  switch (toolName) {
@@ -12,7 +22,7 @@ export function guardToolCall(toolName: string, args: unknown): GuardAction {
12
22
  case 'terminal':
13
23
  return guardTerminal(a);
14
24
  case 'read':
15
- return guardReadPath(String(a.path ?? ''));
25
+ return guardReadPath(String(a.path ?? ''), context.projectRoot);
16
26
  case 'write':
17
27
  case 'edit':
18
28
  case 'multiedit':
@@ -118,7 +128,42 @@ const SENSITIVE_READ_PATHS: Array<{ pattern: RegExp; reason: string }> = [
118
128
  { pattern: /^~?\/?\.docker\/config\.json$/, reason: 'Docker credentials' },
119
129
  ];
120
130
 
121
- function guardReadPath(path: string): GuardAction {
131
+ function normalizeForComparison(value: string): string {
132
+ const withForwardSlashes = value.replace(/\\/g, '/');
133
+ return process.platform === 'win32'
134
+ ? withForwardSlashes.toLowerCase()
135
+ : withForwardSlashes;
136
+ }
137
+
138
+ function expandTilde(path: string): string {
139
+ const home = process.env.HOME || process.env.USERPROFILE || '';
140
+ if (!home) return path;
141
+ if (path === '~') return home;
142
+ if (path.startsWith('~/')) return `${home}/${path.slice(2)}`;
143
+ return path;
144
+ }
145
+
146
+ function isAbsoluteLike(path: string): boolean {
147
+ return (
148
+ path.startsWith('/') || path.startsWith('~') || /^[A-Za-z]:[\\/]/.test(path)
149
+ );
150
+ }
151
+
152
+ function isPathInProject(path: string, projectRoot?: string): boolean {
153
+ if (!projectRoot || !isAbsoluteLike(path)) return false;
154
+ const root = resolvePath(projectRoot);
155
+ const target = resolvePath(expandTilde(path));
156
+ const rootNorm = (() => {
157
+ const normalized = normalizeForComparison(root);
158
+ if (normalized === '/') return '/';
159
+ return normalized.replace(/[\\/]+$/, '');
160
+ })();
161
+ const targetNorm = normalizeForComparison(target);
162
+ const rootWithSlash = rootNorm === '/' ? '/' : `${rootNorm}/`;
163
+ return targetNorm === rootNorm || targetNorm.startsWith(rootWithSlash);
164
+ }
165
+
166
+ function guardReadPath(path: string, projectRoot?: string): GuardAction {
122
167
  if (!path) return { type: 'allow' };
123
168
  const p = path.trim();
124
169
 
@@ -128,7 +173,10 @@ function guardReadPath(path: string): GuardAction {
128
173
  for (const { pattern, reason } of SENSITIVE_READ_PATHS) {
129
174
  if (pattern.test(p)) return { type: 'approve', reason };
130
175
  }
131
- if (p.startsWith('/') || p.startsWith('~')) {
176
+ if (isPathInProject(p, projectRoot)) {
177
+ return { type: 'allow' };
178
+ }
179
+ if (isAbsoluteLike(p)) {
132
180
  return { type: 'approve', reason: 'Reading path outside project root' };
133
181
  }
134
182
  return { type: 'allow' };
@@ -18,6 +18,7 @@ import {
18
18
  requestApproval,
19
19
  } from '../runtime/tools/approval.ts';
20
20
  import { guardToolCall } from '../runtime/tools/guards.ts';
21
+ import { debugLog } from '../runtime/debug/index.ts';
21
22
 
22
23
  export type { ToolAdapterContext } from '../runtime/tools/context.ts';
23
24
 
@@ -55,6 +56,37 @@ function getPendingQueue(
55
56
  return queue;
56
57
  }
57
58
 
59
+ function extractToolCallId(options: unknown): string | undefined {
60
+ return (options as { toolCallId?: string } | undefined)?.toolCallId;
61
+ }
62
+
63
+ const DEFAULT_TRACED_TOOL_INPUTS = new Set(['write', 'apply_patch']);
64
+
65
+ function shouldTraceToolInput(name: string): boolean {
66
+ const raw = process.env.OTTO_DEBUG_TOOL_INPUT?.trim();
67
+ if (!raw) return false;
68
+ const normalized = raw.toLowerCase();
69
+ if (['1', 'true', 'yes', 'on', 'all'].includes(normalized)) {
70
+ return DEFAULT_TRACED_TOOL_INPUTS.has(name);
71
+ }
72
+ const tokens = raw
73
+ .split(/[\s,]+/)
74
+ .map((token) => token.trim().toLowerCase())
75
+ .filter(Boolean);
76
+ return tokens.includes('all') || tokens.includes(name.toLowerCase());
77
+ }
78
+
79
+ function summarizeTraceValue(value: unknown, max = 160): string {
80
+ try {
81
+ const json = JSON.stringify(value);
82
+ if (typeof json === 'string') {
83
+ return json.length > max ? `${json.slice(0, max)}…` : json;
84
+ }
85
+ } catch {}
86
+ const fallback = String(value);
87
+ return fallback.length > max ? `${fallback.slice(0, max)}…` : fallback;
88
+ }
89
+
58
90
  function unwrapDoubleWrappedArgs(
59
91
  input: unknown,
60
92
  expectedName: string,
@@ -199,12 +231,18 @@ export function adaptTools(
199
231
  ...base,
200
232
  ...(providerOptions ? { providerOptions } : {}),
201
233
  async onInputStart(options: unknown) {
234
+ const sdkCallId = extractToolCallId(options);
202
235
  const queue = getPendingQueue(pendingCalls, name);
203
236
  queue.push({
204
- callId: crypto.randomUUID(),
237
+ callId: sdkCallId || crypto.randomUUID(),
205
238
  startTs: Date.now(),
206
239
  stepIndex: ctx.stepIndex,
207
240
  });
241
+ if (shouldTraceToolInput(name)) {
242
+ debugLog(
243
+ `[TOOL_INPUT_TRACE][adapter] onInputStart tool=${name} callId=${sdkCallId ?? queue[queue.length - 1]?.callId ?? 'unknown'} step=${ctx.stepIndex}`,
244
+ );
245
+ }
208
246
  if (typeof base.onInputStart === 'function')
209
247
  // biome-ignore lint/suspicious/noExplicitAny: AI SDK types are complex
210
248
  await base.onInputStart(options as any);
@@ -212,8 +250,14 @@ export function adaptTools(
212
250
  async onInputDelta(options: unknown) {
213
251
  const delta = (options as { inputTextDelta?: string } | undefined)
214
252
  ?.inputTextDelta;
253
+ const sdkCallId = extractToolCallId(options);
215
254
  const queue = pendingCalls.get(name);
216
255
  const meta = queue?.length ? queue[queue.length - 1] : undefined;
256
+ if (shouldTraceToolInput(name)) {
257
+ debugLog(
258
+ `[TOOL_INPUT_TRACE][adapter] onInputDelta tool=${name} callId=${sdkCallId ?? meta?.callId ?? 'unknown'} step=${meta?.stepIndex ?? ctx.stepIndex} delta=${summarizeTraceValue(delta ?? '')}`,
259
+ );
260
+ }
217
261
  // Stream tool argument deltas as events if needed
218
262
  publish({
219
263
  type: 'tool.delta',
@@ -233,21 +277,30 @@ export function adaptTools(
233
277
  },
234
278
  async onInputAvailable(options: unknown) {
235
279
  const args = (options as { input?: unknown } | undefined)?.input;
280
+ const sdkCallId = extractToolCallId(options);
236
281
  const queue = getPendingQueue(pendingCalls, name);
237
282
  let meta = queue.length ? queue[queue.length - 1] : undefined;
238
283
  if (!meta) {
239
284
  meta = {
240
- callId: crypto.randomUUID(),
285
+ callId: sdkCallId || crypto.randomUUID(),
241
286
  startTs: Date.now(),
242
287
  stepIndex: ctx.stepIndex,
243
288
  };
244
289
  queue.push(meta);
245
290
  }
291
+ if (sdkCallId && meta.callId !== sdkCallId) {
292
+ meta.callId = sdkCallId;
293
+ }
246
294
  meta.stepIndex = ctx.stepIndex;
247
295
  meta.args = args;
248
296
  const callId = meta.callId;
249
297
  const callPartId = crypto.randomUUID();
250
298
  const startTs = meta.startTs;
299
+ if (shouldTraceToolInput(name)) {
300
+ debugLog(
301
+ `[TOOL_INPUT_TRACE][adapter] onInputAvailable tool=${name} callId=${callId} step=${ctx.stepIndex} input=${summarizeTraceValue(args)}`,
302
+ );
303
+ }
251
304
 
252
305
  if (
253
306
  !firstToolCallReported &&
@@ -340,7 +393,9 @@ export function adaptTools(
340
393
  args,
341
394
  );
342
395
  }
343
- const guard = guardToolCall(name, args);
396
+ const guard = guardToolCall(name, args, {
397
+ projectRoot: ctx.projectRoot,
398
+ });
344
399
  if (guard.type === 'block') {
345
400
  meta.blocked = true;
346
401
  meta.blockReason = guard.reason;
@@ -360,10 +415,11 @@ export function adaptTools(
360
415
  },
361
416
  async execute(input: ToolExecuteInput, options: ToolExecuteOptions) {
362
417
  input = unwrapDoubleWrappedArgs(input, name);
418
+ const sdkCallId = extractToolCallId(options);
363
419
  const queue = pendingCalls.get(name);
364
420
  const meta = queue?.shift();
365
421
  if (queue && queue.length === 0) pendingCalls.delete(name);
366
- const callIdFromQueue = meta?.callId;
422
+ const callIdFromQueue = sdkCallId || meta?.callId;
367
423
  const startTsFromQueue = meta?.startTs;
368
424
  const stepIndexForEvent = meta?.stepIndex ?? ctx.stepIndex;
369
425
 
@@ -462,23 +518,46 @@ export function adaptTools(
462
518
  // If tool returns an async iterable, stream deltas while accumulating
463
519
  if (res && typeof res === 'object' && Symbol.asyncIterator in res) {
464
520
  const chunks: unknown[] = [];
521
+ let streamedResult: unknown = null;
465
522
  for await (const chunk of res as AsyncIterable<unknown>) {
466
523
  chunks.push(chunk);
524
+ if (chunk && typeof chunk === 'object' && 'result' in chunk) {
525
+ streamedResult = (chunk as { result: unknown }).result;
526
+ continue;
527
+ }
528
+ const delta =
529
+ typeof chunk === 'string'
530
+ ? chunk
531
+ : chunk &&
532
+ typeof chunk === 'object' &&
533
+ 'delta' in chunk &&
534
+ typeof (chunk as { delta?: unknown }).delta === 'string'
535
+ ? ((chunk as { delta: string }).delta ?? '')
536
+ : null;
537
+ if (!delta) continue;
538
+ const channel =
539
+ chunk &&
540
+ typeof chunk === 'object' &&
541
+ 'channel' in chunk &&
542
+ typeof (chunk as { channel?: unknown }).channel === 'string'
543
+ ? ((chunk as { channel: string }).channel ?? 'output')
544
+ : 'output';
467
545
  publish({
468
546
  type: 'tool.delta',
469
547
  sessionId: ctx.sessionId,
470
548
  payload: {
471
549
  name,
472
- channel: 'output',
473
- delta: chunk,
550
+ channel,
551
+ delta,
474
552
  stepIndex: stepIndexForEvent,
475
553
  callId: callIdFromQueue,
476
554
  messageId: ctx.messageId,
477
555
  },
478
556
  });
479
557
  }
480
- // Prefer the last chunk as the result if present, otherwise the entire array
481
- result = chunks.length > 0 ? chunks[chunks.length - 1] : null;
558
+ result =
559
+ streamedResult ??
560
+ (chunks.length > 0 ? chunks[chunks.length - 1] : null);
482
561
  } else {
483
562
  // Await promise or passthrough value
484
563
  result = await Promise.resolve(res as ToolExecuteReturn);