@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 +3 -3
- package/src/routes/config/models.ts +189 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ottocode/server",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
53
|
-
"@ottocode/sdk": "0.1.
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
? (
|
|
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(
|
|
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
|
|
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
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 =
|
|
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
|
-
?
|
|
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
|
|
414
|
+
label: getUiProviderLabel(providerDefinition),
|
|
269
415
|
authType,
|
|
270
416
|
allowAnyModel: providerDefinition.allowAnyModel,
|
|
271
417
|
dynamicModels,
|
|
272
|
-
models: filteredModels.map(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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);
|