@ottocode/server 0.1.247 → 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.247",
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.247",
53
- "@ottocode/sdk": "0.1.247",
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",
@@ -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);