@ottocode/server 0.1.246 → 0.1.248

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ottocode/server",
3
- "version": "0.1.246",
3
+ "version": "0.1.248",
4
4
  "description": "HTTP API server for ottocode",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -49,8 +49,8 @@
49
49
  "typecheck": "tsc --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@ottocode/database": "0.1.246",
53
- "@ottocode/sdk": "0.1.246",
52
+ "@ottocode/database": "0.1.248",
53
+ "@ottocode/sdk": "0.1.248",
54
54
  "ai-sdk-ollama": "^3.8.3",
55
55
  "drizzle-orm": "^0.44.5",
56
56
  "hono": "^4.9.9",
package/src/presets.ts CHANGED
@@ -68,7 +68,7 @@ export const BUILTIN_TOOLS = [
68
68
  'tree',
69
69
  'pwd',
70
70
  'cd',
71
- 'bash',
71
+ 'shell',
72
72
  'terminal',
73
73
  'ripgrep',
74
74
  'glob',
@@ -1,5 +1,6 @@
1
1
  import type { Hono } from 'hono';
2
2
  import {
3
+ DEFAULT_REMOTE_MODEL_CATALOG_URL,
3
4
  discoverOllamaModels,
4
5
  loadConfig,
5
6
  catalog,
@@ -12,6 +13,9 @@ import {
12
13
  type ModelInfo,
13
14
  type ProviderId,
14
15
  filterModelsForAuthType,
16
+ mergeCachedModelCatalog,
17
+ normalizeModelCatalogPayload,
18
+ readCachedModelCatalog,
15
19
  } from '@ottocode/sdk';
16
20
  import type { EmbeddedAppConfig } from '../../index.ts';
17
21
  import { serializeError } from '../../runtime/errors/api-error.ts';
@@ -23,6 +27,121 @@ import {
23
27
  } from './utils.ts';
24
28
 
25
29
  const COPILOT_MODELS_URL = 'https://api.githubcopilot.com/models';
30
+ const REMOTE_CATALOG_REFRESH_TTL_MS = 6 * 60 * 60 * 1000;
31
+ const PROVIDER_MODEL_REFRESH_TTL_MS = 60 * 1000;
32
+
33
+ type UiModel = {
34
+ id: string;
35
+ label: string;
36
+ toolCall?: boolean;
37
+ reasoningText?: boolean;
38
+ vision?: boolean;
39
+ attachment?: boolean;
40
+ free?: boolean;
41
+ };
42
+
43
+ type UiProviderModels = {
44
+ label: string;
45
+ authType?: 'api' | 'oauth' | 'wallet';
46
+ allowAnyModel?: boolean;
47
+ dynamicModels?: boolean;
48
+ models: UiModel[];
49
+ };
50
+
51
+ const remoteCatalogRefreshes = new Set<string>();
52
+ const providerModelRefreshes = new Set<string>();
53
+ const providerModelRefreshAt = new Map<string, number>();
54
+ let remoteCatalogRefreshAt = 0;
55
+
56
+ function toUiModel(model: ModelInfo): UiModel {
57
+ return {
58
+ id: model.id,
59
+ label: model.label || model.id,
60
+ toolCall: model.toolCall,
61
+ reasoningText: model.reasoningText,
62
+ vision: model.modalities?.input?.includes('image') ?? false,
63
+ attachment: model.attachment ?? false,
64
+ free: model.cost?.input === 0 && model.cost?.output === 0,
65
+ };
66
+ }
67
+
68
+ function getRemoteCatalogUrl(): string {
69
+ return (
70
+ process.env.OTTO_MODEL_CATALOG_URL?.trim() ||
71
+ DEFAULT_REMOTE_MODEL_CATALOG_URL
72
+ );
73
+ }
74
+
75
+ async function refreshRemoteCatalogInBackground(): Promise<void> {
76
+ const url = getRemoteCatalogUrl();
77
+ const now = Date.now();
78
+ if (now - remoteCatalogRefreshAt < REMOTE_CATALOG_REFRESH_TTL_MS) return;
79
+ const cachedCatalog = await readCachedModelCatalog();
80
+ const cachedAt = cachedCatalog ? Date.parse(cachedCatalog.updatedAt) : 0;
81
+ if (Number.isFinite(cachedAt)) {
82
+ remoteCatalogRefreshAt = Math.max(remoteCatalogRefreshAt, cachedAt);
83
+ if (now - remoteCatalogRefreshAt < REMOTE_CATALOG_REFRESH_TTL_MS) return;
84
+ }
85
+ if (remoteCatalogRefreshes.has(url)) return;
86
+ remoteCatalogRefreshes.add(url);
87
+ remoteCatalogRefreshAt = now;
88
+ try {
89
+ const response = await fetch(url);
90
+ if (!response.ok) {
91
+ throw new Error(`${response.status} ${response.statusText}`);
92
+ }
93
+ const providers = normalizeModelCatalogPayload(await response.json());
94
+ if (Object.keys(providers).length > 0) {
95
+ await mergeCachedModelCatalog(providers);
96
+ }
97
+ } catch (error) {
98
+ logger.debug('Failed to refresh remote model catalog', {
99
+ url,
100
+ error: error instanceof Error ? error.message : String(error),
101
+ });
102
+ } finally {
103
+ remoteCatalogRefreshes.delete(url);
104
+ }
105
+ }
106
+
107
+ async function refreshProviderModelsInBackground(args: {
108
+ provider: ProviderId;
109
+ providerDefinition: NonNullable<ReturnType<typeof getProviderDefinition>>;
110
+ projectRoot: string;
111
+ }): Promise<void> {
112
+ const refreshKey = `${args.projectRoot}:${args.provider}`;
113
+ const now = Date.now();
114
+ const lastRefresh = providerModelRefreshAt.get(refreshKey) ?? 0;
115
+ if (now - lastRefresh < PROVIDER_MODEL_REFRESH_TTL_MS) return;
116
+ if (providerModelRefreshes.has(refreshKey)) return;
117
+ providerModelRefreshes.add(refreshKey);
118
+ providerModelRefreshAt.set(refreshKey, now);
119
+ try {
120
+ const { provider, providerDefinition, projectRoot } = args;
121
+ const discoveredModels = await discoverProviderModels({
122
+ provider,
123
+ providerDefinition,
124
+ projectRoot,
125
+ });
126
+ const models =
127
+ discoveredModels ??
128
+ getConfiguredProviderModels(await loadConfig(projectRoot), provider);
129
+ await mergeCachedModelCatalog({
130
+ [provider]: {
131
+ id: provider,
132
+ label: providerDefinition.label,
133
+ models,
134
+ },
135
+ });
136
+ } catch (error) {
137
+ logger.debug('Failed to refresh provider model cache', {
138
+ provider: args.provider,
139
+ error: error instanceof Error ? error.message : String(error),
140
+ });
141
+ } finally {
142
+ providerModelRefreshes.delete(refreshKey);
143
+ }
144
+ }
26
145
 
27
146
  function filterCopilotAvailability<T extends { id: string }>(
28
147
  provider: ProviderId,
@@ -127,6 +246,26 @@ function shouldLazyLoadProviderModels(
127
246
  );
128
247
  }
129
248
 
249
+ function getCachedOrConfiguredModels(args: {
250
+ models: ModelInfo[] | undefined;
251
+ cfg: Awaited<ReturnType<typeof loadConfig>>;
252
+ provider: ProviderId;
253
+ }): ModelInfo[] {
254
+ const cachedModels = args.models;
255
+ return cachedModels && cachedModels.length > 0
256
+ ? cachedModels
257
+ : getConfiguredProviderModels(args.cfg, args.provider);
258
+ }
259
+
260
+ function getUiProviderLabel(
261
+ providerDefinition: NonNullable<ReturnType<typeof getProviderDefinition>>,
262
+ ): string {
263
+ if (providerDefinition.source !== 'custom') return providerDefinition.label;
264
+ return providerDefinition.label.includes('(custom)')
265
+ ? providerDefinition.label
266
+ : `${providerDefinition.label} (custom)`;
267
+ }
268
+
130
269
  export function registerModelsRoutes(app: Hono) {
131
270
  app.get('/v1/config/providers/:provider/models', async (c) => {
132
271
  try {
@@ -151,26 +290,36 @@ export function registerModelsRoutes(app: Hono) {
151
290
  return c.json({ error: 'Provider not authorized' }, 403);
152
291
  }
153
292
 
154
- const providerCatalog = catalog[provider as keyof typeof catalog];
293
+ const cachedCatalog = await readCachedModelCatalog();
294
+ const providerCatalog =
295
+ cachedCatalog?.providers[provider] ??
296
+ catalog[provider as keyof typeof catalog];
155
297
  const providerDefinition = getProviderDefinition(cfg, provider);
156
298
  if (!providerDefinition) {
157
299
  logger.warn('Provider not found in catalog', { provider });
158
300
  return c.json({ error: 'Provider not found' }, 404);
159
301
  }
302
+ void refreshRemoteCatalogInBackground();
160
303
 
161
304
  const authType = await getAuthTypeForProvider(
162
305
  embeddedConfig,
163
306
  provider,
164
307
  projectRoot,
165
308
  );
166
- const discoveredModels = await discoverProviderModels({
167
- provider,
168
- providerDefinition,
169
- projectRoot,
170
- });
309
+ if (shouldLazyLoadProviderModels(providerDefinition)) {
310
+ void refreshProviderModelsInBackground({
311
+ provider,
312
+ providerDefinition,
313
+ projectRoot,
314
+ });
315
+ }
171
316
  const filteredModels =
172
317
  providerDefinition.compatibility === 'ollama'
173
- ? (discoveredModels ?? [])
318
+ ? getCachedOrConfiguredModels({
319
+ models: providerCatalog?.models,
320
+ cfg,
321
+ provider,
322
+ })
174
323
  : providerCatalog
175
324
  ? filterModelsForAuthType(
176
325
  provider,
@@ -190,22 +339,14 @@ export function registerModelsRoutes(app: Hono) {
190
339
  );
191
340
 
192
341
  return c.json({
193
- models: availableModels.map((m) => ({
194
- id: m.id,
195
- label: m.label || m.id,
196
- toolCall: m.toolCall,
197
- reasoningText: m.reasoningText,
198
- vision: m.modalities?.input?.includes('image') ?? false,
199
- attachment: m.attachment ?? false,
200
- free: m.cost?.input === 0 && m.cost?.output === 0,
201
- })),
342
+ models: availableModels.map(toUiModel),
202
343
  default: getDefault(
203
344
  embeddedConfig?.model,
204
345
  embeddedConfig?.defaults?.model,
205
346
  cfg.defaults.model,
206
347
  ),
207
348
  allowAnyModel: providerAllowsAnyModel(cfg, provider),
208
- label: providerDefinition.label,
349
+ label: getUiProviderLabel(providerDefinition),
209
350
  });
210
351
  } catch (error) {
211
352
  logger.error('Failed to get provider models', error);
@@ -230,22 +371,16 @@ export function registerModelsRoutes(app: Hono) {
230
371
  cfg,
231
372
  );
232
373
 
233
- const modelsMap: Record<
234
- string,
235
- {
236
- label: string;
237
- authType?: 'api' | 'oauth' | 'wallet';
238
- models: Array<{
239
- id: string;
240
- label: string;
241
- toolCall?: boolean;
242
- reasoningText?: boolean;
243
- }>;
244
- }
245
- > = {};
374
+ const cachedCatalog = await readCachedModelCatalog();
375
+ void refreshRemoteCatalogInBackground();
376
+
377
+ const modelsMap: Record<string, UiProviderModels> = {};
378
+ const cacheUpdates: Parameters<typeof mergeCachedModelCatalog>[0] = {};
246
379
 
247
380
  for (const provider of authorizedProviders) {
248
- const providerCatalog = catalog[provider as keyof typeof catalog];
381
+ const providerCatalog =
382
+ cachedCatalog?.providers[provider] ??
383
+ catalog[provider as keyof typeof catalog];
249
384
  const providerDefinition = getProviderDefinition(cfg, provider);
250
385
  if (providerDefinition) {
251
386
  const dynamicModels =
@@ -255,8 +390,19 @@ export function registerModelsRoutes(app: Hono) {
255
390
  provider,
256
391
  projectRoot,
257
392
  );
393
+ if (dynamicModels) {
394
+ void refreshProviderModelsInBackground({
395
+ provider,
396
+ providerDefinition,
397
+ projectRoot,
398
+ });
399
+ }
258
400
  const filteredModels = dynamicModels
259
- ? getConfiguredProviderModels(cfg, provider)
401
+ ? getCachedOrConfiguredModels({
402
+ models: providerCatalog?.models,
403
+ cfg,
404
+ provider,
405
+ })
260
406
  : providerCatalog
261
407
  ? filterModelsForAuthType(
262
408
  provider,
@@ -265,23 +411,24 @@ export function registerModelsRoutes(app: Hono) {
265
411
  )
266
412
  : getConfiguredProviderModels(cfg, provider);
267
413
  modelsMap[provider] = {
268
- label: providerDefinition.label,
414
+ label: getUiProviderLabel(providerDefinition),
269
415
  authType,
270
416
  allowAnyModel: providerDefinition.allowAnyModel,
271
417
  dynamicModels,
272
- models: filteredModels.map((m) => ({
273
- id: m.id,
274
- label: m.label || m.id,
275
- toolCall: m.toolCall,
276
- reasoningText: m.reasoningText,
277
- vision: m.modalities?.input?.includes('image') ?? false,
278
- attachment: m.attachment ?? false,
279
- free: m.cost?.input === 0 && m.cost?.output === 0,
280
- })),
418
+ models: filteredModels.map(toUiModel),
419
+ };
420
+ cacheUpdates[provider] = {
421
+ id: provider,
422
+ label: providerDefinition.label,
423
+ models: filteredModels,
281
424
  };
282
425
  }
283
426
  }
284
427
 
428
+ if (Object.keys(cacheUpdates).length > 0) {
429
+ void mergeCachedModelCatalog(cacheUpdates);
430
+ }
431
+
285
432
  return c.json(modelsMap);
286
433
  } catch (error) {
287
434
  logger.error('Failed to get all models', error);
@@ -126,7 +126,7 @@ const defaultToolExtras: Record<string, string[]> = {
126
126
  'write',
127
127
  'ls',
128
128
  'tree',
129
- 'bash',
129
+ 'shell',
130
130
  'update_todos',
131
131
  'glob',
132
132
  'ripgrep',
@@ -144,7 +144,7 @@ const defaultToolExtras: Record<string, string[]> = {
144
144
  'write',
145
145
  'ls',
146
146
  'tree',
147
- 'bash',
147
+ 'shell',
148
148
  'ripgrep',
149
149
  'glob',
150
150
  'websearch',
@@ -158,7 +158,7 @@ const defaultToolExtras: Record<string, string[]> = {
158
158
  'write',
159
159
  'ls',
160
160
  'tree',
161
- 'bash',
161
+ 'shell',
162
162
  'update_todos',
163
163
  'glob',
164
164
  'ripgrep',
@@ -122,6 +122,14 @@ const MODEL_FAMILY_EDIT_TOOL_POLICY_AGENTS = new Set([
122
122
  'init',
123
123
  ]);
124
124
 
125
+ function normalizeToolName(toolName: string): string {
126
+ return toolName === 'bash' ? 'shell' : toolName;
127
+ }
128
+
129
+ function normalizeToolNames(toolNames: string[]): string[] {
130
+ return Array.from(new Set(toolNames.map(normalizeToolName)));
131
+ }
132
+
125
133
  export function applyModelFamilyEditToolPolicy(
126
134
  agent: string,
127
135
  tools: string[],
@@ -129,6 +137,7 @@ export function applyModelFamilyEditToolPolicy(
129
137
  model: string,
130
138
  cfg?: OttoConfig,
131
139
  ): string[] {
140
+ tools = normalizeToolNames(tools);
132
141
  if (!MODEL_FAMILY_EDIT_TOOL_POLICY_AGENTS.has(agent)) return tools;
133
142
 
134
143
  const family = cfg
@@ -328,7 +337,10 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
328
337
  opts.provider,
329
338
  opts.model,
330
339
  );
331
- const allowedNames = new Set([...allowedToolNames, 'finish']);
340
+ const allowedNames = new Set([
341
+ ...normalizeToolNames(allowedToolNames),
342
+ 'finish',
343
+ ]);
332
344
  const gated = allTools.filter(
333
345
  (tool) => allowedNames.has(tool.name) || tool.name === 'load_mcp_tools',
334
346
  );
@@ -3,6 +3,7 @@ import { publish } from '../../events/bus.ts';
3
3
  export type ToolApprovalMode = 'auto' | 'dangerous' | 'all' | 'yolo';
4
4
 
5
5
  export const DANGEROUS_TOOLS = new Set([
6
+ 'shell',
6
7
  'bash',
7
8
  'edit',
8
9
  'multiedit',
@@ -17,8 +17,9 @@ export function guardToolCall(
17
17
  const a = (args ?? {}) as Record<string, unknown>;
18
18
 
19
19
  switch (toolName) {
20
+ case 'shell':
20
21
  case 'bash':
21
- return guardBashCommand(String(a.cmd ?? ''));
22
+ return guardShellCommand(String(a.cmd ?? ''));
22
23
  case 'terminal':
23
24
  return guardTerminal(a);
24
25
  case 'read':
@@ -30,7 +31,7 @@ export function guardToolCall(
30
31
  }
31
32
  }
32
33
 
33
- function guardBashCommand(cmd: string): GuardAction {
34
+ function guardShellCommand(cmd: string): GuardAction {
34
35
  const n = cmd.trim();
35
36
  if (!n) return { type: 'allow' };
36
37
 
@@ -104,7 +105,7 @@ function checkApprovalCommand(cmd: string): string | null {
104
105
  function guardTerminal(args: Record<string, unknown>): GuardAction {
105
106
  const op = String(args.operation ?? '');
106
107
  if (op === 'start' && typeof args.command === 'string') {
107
- return guardBashCommand(args.command);
108
+ return guardShellCommand(args.command);
108
109
  }
109
110
  return { type: 'allow' };
110
111
  }
@@ -30,7 +30,8 @@ export const CANONICAL_TO_PASCAL: Record<string, string> = {
30
30
  ripgrep: 'Grep',
31
31
 
32
32
  // Execution
33
- bash: 'Bash',
33
+ shell: 'Shell',
34
+ bash: 'Shell',
34
35
  terminal: 'Terminal',
35
36
 
36
37
  // Git operations
@@ -70,7 +71,8 @@ export const PASCAL_TO_CANONICAL: Record<string, string> = {
70
71
  Grep: 'ripgrep', // Maps back to ripgrep (primary search tool)
71
72
 
72
73
  // Execution
73
- Bash: 'bash',
74
+ Shell: 'shell',
75
+ Bash: 'shell',
74
76
  Terminal: 'terminal',
75
77
 
76
78
  // Git operations
@@ -134,8 +134,8 @@ export function adaptTools(
134
134
  const stepStates = ctx.stepExecution.states;
135
135
 
136
136
  // Anthropic allows max 4 cache_control blocks
137
- // Cache only the most frequently used tools: read, write, bash
138
- const cacheableTools = new Set(['read', 'write', 'bash']);
137
+ // Cache only the most frequently used tools: read, write, shell
138
+ const cacheableTools = new Set(['read', 'write', 'shell']);
139
139
  let cachedToolCount = 0;
140
140
 
141
141
  for (const { name: canonicalName, tool } of tools) {
@@ -502,7 +502,7 @@ export function adaptTools(
502
502
  } as ToolExecuteInput;
503
503
  // biome-ignore lint/suspicious/noExplicitAny: AI SDK types are complex
504
504
  res = base.execute?.(nextInput, options as any);
505
- } else if (name === 'bash') {
505
+ } else if (name === 'shell' || name === 'bash') {
506
506
  const needsCwd =
507
507
  !input ||
508
508
  typeof (input as Record<string, unknown>).cwd !== 'string';