@ottocode/server 0.1.247 → 0.1.249

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.247",
3
+ "version": "0.1.249",
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.247",
53
- "@ottocode/sdk": "0.1.247",
52
+ "@ottocode/database": "0.1.249",
53
+ "@ottocode/sdk": "0.1.249",
54
54
  "ai-sdk-ollama": "^3.8.3",
55
55
  "drizzle-orm": "^0.44.5",
56
56
  "hono": "^4.9.9",
@@ -25,6 +25,7 @@ import {
25
25
  import { execFileSync, spawnSync } from 'node:child_process';
26
26
  import { logger } from '@ottocode/sdk';
27
27
  import { serializeError } from '../runtime/errors/api-error.ts';
28
+ import { getProviderDetails } from './config/utils.ts';
28
29
 
29
30
  const oauthVerifiers = new Map<
30
31
  string,
@@ -235,6 +236,7 @@ export function registerAuthRoutes(app: Hono) {
235
236
  supportsOAuth: boolean;
236
237
  supportsToken?: boolean;
237
238
  supportsGhImport?: boolean;
239
+ custom?: boolean;
238
240
  modelCount: number;
239
241
  costRange?: { min: number; max: number };
240
242
  }
@@ -267,6 +269,19 @@ export function registerAuthRoutes(app: Hono) {
267
269
  };
268
270
  }
269
271
 
272
+ const providerDetails = await getProviderDetails(undefined, cfg);
273
+ for (const detail of providerDetails) {
274
+ if (!detail.custom || providers[detail.id]) continue;
275
+ providers[detail.id] = {
276
+ configured: detail.authorized,
277
+ type: detail.authType,
278
+ label: detail.label,
279
+ supportsOAuth: false,
280
+ custom: true,
281
+ modelCount: detail.modelCount,
282
+ };
283
+ }
284
+
270
285
  return c.json({
271
286
  onboardingComplete,
272
287
  ottorouter: ottorouterWallet
@@ -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,129 @@ 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 = 5 * 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
+ contextWindow?: number;
42
+ maxOutputTokens?: number;
43
+ };
44
+
45
+ type UiProviderModels = {
46
+ label: string;
47
+ authType?: 'api' | 'oauth' | 'wallet';
48
+ allowAnyModel?: boolean;
49
+ dynamicModels?: boolean;
50
+ models: UiModel[];
51
+ };
52
+
53
+ const remoteCatalogRefreshes = new Set<string>();
54
+ const providerModelRefreshes = new Set<string>();
55
+ const providerModelRefreshAt = new Map<string, number>();
56
+ let remoteCatalogRefreshAt = 0;
57
+
58
+ function toUiModel(model: ModelInfo): UiModel {
59
+ return {
60
+ id: model.id,
61
+ label: model.label || model.id,
62
+ toolCall: model.toolCall,
63
+ reasoningText: model.reasoningText,
64
+ vision: model.modalities?.input?.includes('image') ?? false,
65
+ attachment: model.attachment ?? false,
66
+ free: model.cost?.input === 0 && model.cost?.output === 0,
67
+ contextWindow: model.limit?.context,
68
+ maxOutputTokens: model.limit?.output,
69
+ };
70
+ }
71
+
72
+ function getRemoteCatalogUrl(): string {
73
+ return (
74
+ process.env.OTTO_MODEL_CATALOG_URL?.trim() ||
75
+ DEFAULT_REMOTE_MODEL_CATALOG_URL
76
+ );
77
+ }
78
+
79
+ async function refreshRemoteCatalogInBackground(): Promise<void> {
80
+ const url = getRemoteCatalogUrl();
81
+ const now = Date.now();
82
+ if (now - remoteCatalogRefreshAt < REMOTE_CATALOG_REFRESH_TTL_MS) return;
83
+ const cachedCatalog = await readCachedModelCatalog();
84
+ const cachedAt = cachedCatalog ? Date.parse(cachedCatalog.updatedAt) : 0;
85
+ if (Number.isFinite(cachedAt)) {
86
+ remoteCatalogRefreshAt = Math.max(remoteCatalogRefreshAt, cachedAt);
87
+ if (now - remoteCatalogRefreshAt < REMOTE_CATALOG_REFRESH_TTL_MS) return;
88
+ }
89
+ if (remoteCatalogRefreshes.has(url)) return;
90
+ remoteCatalogRefreshes.add(url);
91
+ remoteCatalogRefreshAt = now;
92
+ try {
93
+ const response = await fetch(url);
94
+ if (!response.ok) {
95
+ throw new Error(`${response.status} ${response.statusText}`);
96
+ }
97
+ const providers = normalizeModelCatalogPayload(await response.json());
98
+ if (Object.keys(providers).length > 0) {
99
+ await mergeCachedModelCatalog(providers);
100
+ logger.debug('Refreshed remote model catalog', {
101
+ url,
102
+ providers: Object.keys(providers).length,
103
+ });
104
+ }
105
+ } catch (error) {
106
+ logger.debug('Failed to refresh remote model catalog', {
107
+ url,
108
+ error: error instanceof Error ? error.message : String(error),
109
+ });
110
+ } finally {
111
+ remoteCatalogRefreshes.delete(url);
112
+ }
113
+ }
114
+
115
+ async function refreshProviderModelsInBackground(args: {
116
+ provider: ProviderId;
117
+ providerDefinition: NonNullable<ReturnType<typeof getProviderDefinition>>;
118
+ projectRoot: string;
119
+ }): Promise<void> {
120
+ const refreshKey = `${args.projectRoot}:${args.provider}`;
121
+ const now = Date.now();
122
+ const lastRefresh = providerModelRefreshAt.get(refreshKey) ?? 0;
123
+ if (now - lastRefresh < PROVIDER_MODEL_REFRESH_TTL_MS) return;
124
+ if (providerModelRefreshes.has(refreshKey)) return;
125
+ providerModelRefreshes.add(refreshKey);
126
+ providerModelRefreshAt.set(refreshKey, now);
127
+ try {
128
+ const { provider, providerDefinition, projectRoot } = args;
129
+ const discoveredModels = await discoverProviderModels({
130
+ provider,
131
+ providerDefinition,
132
+ projectRoot,
133
+ });
134
+ const models =
135
+ discoveredModels ??
136
+ getConfiguredProviderModels(await loadConfig(projectRoot), provider);
137
+ await mergeCachedModelCatalog({
138
+ [provider]: {
139
+ id: provider,
140
+ label: providerDefinition.label,
141
+ models,
142
+ },
143
+ });
144
+ } catch (error) {
145
+ logger.debug('Failed to refresh provider model cache', {
146
+ provider: args.provider,
147
+ error: error instanceof Error ? error.message : String(error),
148
+ });
149
+ } finally {
150
+ providerModelRefreshes.delete(refreshKey);
151
+ }
152
+ }
26
153
 
27
154
  function filterCopilotAvailability<T extends { id: string }>(
28
155
  provider: ProviderId,
@@ -91,6 +218,7 @@ async function discoverProviderModels(args: {
91
218
  }): Promise<ModelInfo[] | undefined> {
92
219
  const { provider, providerDefinition, projectRoot } = args;
93
220
  if (
221
+ providerDefinition.source !== 'custom' ||
94
222
  providerDefinition.compatibility !== 'ollama' ||
95
223
  !providerDefinition.baseURL
96
224
  ) {
@@ -106,7 +234,7 @@ async function discoverProviderModels(args: {
106
234
  const discovered = await discoverOllamaModels({
107
235
  baseURL: providerDefinition.baseURL,
108
236
  apiKey,
109
- includeDetails: false,
237
+ includeDetails: true,
110
238
  });
111
239
  return discovered.models;
112
240
  } catch (error) {
@@ -122,11 +250,31 @@ function shouldLazyLoadProviderModels(
122
250
  providerDefinition: NonNullable<ReturnType<typeof getProviderDefinition>>,
123
251
  ): boolean {
124
252
  return (
125
- providerDefinition.compatibility === 'ollama' ||
126
- providerDefinition.source === 'custom'
253
+ providerDefinition.source === 'custom' &&
254
+ providerDefinition.compatibility === 'ollama'
127
255
  );
128
256
  }
129
257
 
258
+ function getCachedOrConfiguredModels(args: {
259
+ models: ModelInfo[] | undefined;
260
+ cfg: Awaited<ReturnType<typeof loadConfig>>;
261
+ provider: ProviderId;
262
+ }): ModelInfo[] {
263
+ const cachedModels = args.models;
264
+ return cachedModels && cachedModels.length > 0
265
+ ? cachedModels
266
+ : getConfiguredProviderModels(args.cfg, args.provider);
267
+ }
268
+
269
+ function getUiProviderLabel(
270
+ providerDefinition: NonNullable<ReturnType<typeof getProviderDefinition>>,
271
+ ): string {
272
+ if (providerDefinition.source !== 'custom') return providerDefinition.label;
273
+ return providerDefinition.label.includes('(custom)')
274
+ ? providerDefinition.label
275
+ : `${providerDefinition.label} (custom)`;
276
+ }
277
+
130
278
  export function registerModelsRoutes(app: Hono) {
131
279
  app.get('/v1/config/providers/:provider/models', async (c) => {
132
280
  try {
@@ -151,33 +299,38 @@ export function registerModelsRoutes(app: Hono) {
151
299
  return c.json({ error: 'Provider not authorized' }, 403);
152
300
  }
153
301
 
154
- const providerCatalog = catalog[provider as keyof typeof catalog];
302
+ const cachedCatalog = await readCachedModelCatalog();
303
+ const providerCatalog =
304
+ cachedCatalog?.providers[provider] ??
305
+ catalog[provider as keyof typeof catalog];
155
306
  const providerDefinition = getProviderDefinition(cfg, provider);
156
307
  if (!providerDefinition) {
157
308
  logger.warn('Provider not found in catalog', { provider });
158
309
  return c.json({ error: 'Provider not found' }, 404);
159
310
  }
311
+ void refreshRemoteCatalogInBackground();
160
312
 
161
313
  const authType = await getAuthTypeForProvider(
162
314
  embeddedConfig,
163
315
  provider,
164
316
  projectRoot,
165
317
  );
166
- const discoveredModels = await discoverProviderModels({
167
- provider,
168
- providerDefinition,
169
- projectRoot,
170
- });
171
- const filteredModels =
172
- providerDefinition.compatibility === 'ollama'
173
- ? (discoveredModels ?? [])
174
- : providerCatalog
175
- ? filterModelsForAuthType(
176
- provider,
177
- providerCatalog.models,
178
- authType,
179
- )
180
- : getConfiguredProviderModels(cfg, provider);
318
+ if (shouldLazyLoadProviderModels(providerDefinition)) {
319
+ void refreshProviderModelsInBackground({
320
+ provider,
321
+ providerDefinition,
322
+ projectRoot,
323
+ });
324
+ }
325
+ const filteredModels = shouldLazyLoadProviderModels(providerDefinition)
326
+ ? getCachedOrConfiguredModels({
327
+ models: providerCatalog?.models,
328
+ cfg,
329
+ provider,
330
+ })
331
+ : providerCatalog
332
+ ? filterModelsForAuthType(provider, providerCatalog.models, authType)
333
+ : getConfiguredProviderModels(cfg, provider);
181
334
  const copilotAllowedModels =
182
335
  provider === 'copilot'
183
336
  ? await getAuthorizedCopilotModels(projectRoot)
@@ -190,22 +343,14 @@ export function registerModelsRoutes(app: Hono) {
190
343
  );
191
344
 
192
345
  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
- })),
346
+ models: availableModels.map(toUiModel),
202
347
  default: getDefault(
203
348
  embeddedConfig?.model,
204
349
  embeddedConfig?.defaults?.model,
205
350
  cfg.defaults.model,
206
351
  ),
207
352
  allowAnyModel: providerAllowsAnyModel(cfg, provider),
208
- label: providerDefinition.label,
353
+ label: getUiProviderLabel(providerDefinition),
209
354
  });
210
355
  } catch (error) {
211
356
  logger.error('Failed to get provider models', error);
@@ -230,22 +375,15 @@ export function registerModelsRoutes(app: Hono) {
230
375
  cfg,
231
376
  );
232
377
 
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
- > = {};
378
+ const cachedCatalog = await readCachedModelCatalog();
379
+ void refreshRemoteCatalogInBackground();
380
+
381
+ const modelsMap: Record<string, UiProviderModels> = {};
246
382
 
247
383
  for (const provider of authorizedProviders) {
248
- const providerCatalog = catalog[provider as keyof typeof catalog];
384
+ const providerCatalog =
385
+ cachedCatalog?.providers[provider] ??
386
+ catalog[provider as keyof typeof catalog];
249
387
  const providerDefinition = getProviderDefinition(cfg, provider);
250
388
  if (providerDefinition) {
251
389
  const dynamicModels =
@@ -255,8 +393,19 @@ export function registerModelsRoutes(app: Hono) {
255
393
  provider,
256
394
  projectRoot,
257
395
  );
396
+ if (dynamicModels) {
397
+ void refreshProviderModelsInBackground({
398
+ provider,
399
+ providerDefinition,
400
+ projectRoot,
401
+ });
402
+ }
258
403
  const filteredModels = dynamicModels
259
- ? getConfiguredProviderModels(cfg, provider)
404
+ ? getCachedOrConfiguredModels({
405
+ models: providerCatalog?.models,
406
+ cfg,
407
+ provider,
408
+ })
260
409
  : providerCatalog
261
410
  ? filterModelsForAuthType(
262
411
  provider,
@@ -265,19 +414,11 @@ export function registerModelsRoutes(app: Hono) {
265
414
  )
266
415
  : getConfiguredProviderModels(cfg, provider);
267
416
  modelsMap[provider] = {
268
- label: providerDefinition.label,
417
+ label: getUiProviderLabel(providerDefinition),
269
418
  authType,
270
419
  allowAnyModel: providerDefinition.allowAnyModel,
271
420
  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
- })),
421
+ models: filteredModels.map(toUiModel),
281
422
  };
282
423
  }
283
424
  }
@@ -3,7 +3,9 @@ import {
3
3
  loadConfig,
4
4
  removeProviderSettings,
5
5
  writeProviderSettings,
6
+ discoverOllamaModels,
6
7
  isBuiltInProviderId,
8
+ type ModelInfo,
7
9
  type ProviderCompatibility,
8
10
  type ProviderPromptFamily,
9
11
  type ProviderId,
@@ -32,6 +34,25 @@ type ProviderMutationBody = {
32
34
  scope?: 'global' | 'local';
33
35
  };
34
36
 
37
+ type ProviderDiscoveryBody = {
38
+ compatibility?: ProviderCompatibility;
39
+ baseURL?: string;
40
+ apiKey?: string;
41
+ };
42
+
43
+ function toDiscoveredModel(model: ModelInfo) {
44
+ return {
45
+ id: model.id,
46
+ label: model.label || model.id,
47
+ toolCall: model.toolCall,
48
+ reasoningText: model.reasoningText,
49
+ vision: model.modalities?.input?.includes('image') ?? false,
50
+ attachment: model.attachment ?? false,
51
+ contextWindow: model.limit?.context,
52
+ maxOutputTokens: model.limit?.output,
53
+ };
54
+ }
55
+
35
56
  export function registerProvidersRoute(app: Hono) {
36
57
  app.get('/v1/config/providers', async (c) => {
37
58
  try {
@@ -87,6 +108,49 @@ export function registerProvidersRoute(app: Hono) {
87
108
  }
88
109
  });
89
110
 
111
+ app.post('/v1/config/providers/discover-models', async (c) => {
112
+ try {
113
+ const embeddedConfig = (
114
+ c as unknown as {
115
+ get: (key: 'embeddedConfig') => EmbeddedAppConfig | undefined;
116
+ }
117
+ ).get('embeddedConfig');
118
+ if (embeddedConfig && Object.keys(embeddedConfig).length > 0) {
119
+ return c.json({ error: 'Embedded config cannot be modified' }, 400);
120
+ }
121
+
122
+ const body = await c.req.json<ProviderDiscoveryBody>();
123
+ const compatibility = body.compatibility || 'openai-compatible';
124
+ const baseURL = body.baseURL?.trim();
125
+ const apiKey = body.apiKey?.trim() || undefined;
126
+ if (!baseURL) return c.json({ error: 'Base URL is required' }, 400);
127
+
128
+ if (compatibility !== 'ollama') {
129
+ return c.json({
130
+ models: [],
131
+ unsupported: true,
132
+ message:
133
+ 'Model discovery is currently available for Ollama providers.',
134
+ });
135
+ }
136
+
137
+ const discovered = await discoverOllamaModels({
138
+ baseURL,
139
+ apiKey,
140
+ includeDetails: true,
141
+ });
142
+
143
+ return c.json({
144
+ baseURL: discovered.baseURL,
145
+ models: discovered.models.map(toDiscoveredModel),
146
+ });
147
+ } catch (error) {
148
+ logger.error('Failed to discover provider models', error);
149
+ const errorResponse = serializeError(error);
150
+ return c.json(errorResponse, errorResponse.error.status || 500);
151
+ }
152
+ });
153
+
90
154
  app.put('/v1/config/providers/:provider', async (c) => {
91
155
  try {
92
156
  const embeddedConfig = (