@ottocode/server 0.1.245 → 0.1.246

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.
@@ -1,10 +1,36 @@
1
1
  import type { Hono } from 'hono';
2
- import { loadConfig } from '@ottocode/sdk';
3
- import type { ProviderId } from '@ottocode/sdk';
2
+ import {
3
+ loadConfig,
4
+ removeProviderSettings,
5
+ writeProviderSettings,
6
+ isBuiltInProviderId,
7
+ type ProviderCompatibility,
8
+ type ProviderPromptFamily,
9
+ type ProviderId,
10
+ type ProviderSettingsEntry,
11
+ } from '@ottocode/sdk';
4
12
  import type { EmbeddedAppConfig } from '../../index.ts';
5
13
  import { logger } from '@ottocode/sdk';
6
14
  import { serializeError } from '../../runtime/errors/api-error.ts';
7
- import { getAuthorizedProviders, getDefault } from './utils.ts';
15
+ import {
16
+ getAuthorizedProviders,
17
+ getDefault,
18
+ getProviderDetails,
19
+ } from './utils.ts';
20
+
21
+ type ProviderMutationBody = {
22
+ enabled?: boolean;
23
+ custom?: boolean;
24
+ label?: string;
25
+ compatibility?: ProviderCompatibility;
26
+ family?: ProviderPromptFamily;
27
+ baseURL?: string | null;
28
+ apiKey?: string | null;
29
+ apiKeyEnv?: string | null;
30
+ models?: string[];
31
+ allowAnyModel?: boolean;
32
+ scope?: 'global' | 'local';
33
+ };
8
34
 
9
35
  export function registerProvidersRoute(app: Hono) {
10
36
  app.get('/v1/config/providers', async (c) => {
@@ -15,7 +41,7 @@ export function registerProvidersRoute(app: Hono) {
15
41
  }
16
42
  ).get('embeddedConfig');
17
43
 
18
- if (embeddedConfig) {
44
+ if (embeddedConfig && Object.keys(embeddedConfig).length > 0) {
19
45
  const providers = embeddedConfig.auth
20
46
  ? (Object.keys(embeddedConfig.auth) as ProviderId[])
21
47
  : embeddedConfig.provider
@@ -24,6 +50,17 @@ export function registerProvidersRoute(app: Hono) {
24
50
 
25
51
  return c.json({
26
52
  providers,
53
+ details: providers.map((provider) => ({
54
+ id: provider,
55
+ label: provider,
56
+ source: 'built-in',
57
+ enabled: true,
58
+ authorized: true,
59
+ custom: false,
60
+ hasApiKey: false,
61
+ allowAnyModel: false,
62
+ modelCount: 0,
63
+ })),
27
64
  default: getDefault(
28
65
  embeddedConfig.provider,
29
66
  embeddedConfig.defaults?.provider,
@@ -36,9 +73,11 @@ export function registerProvidersRoute(app: Hono) {
36
73
  const cfg = await loadConfig(projectRoot);
37
74
 
38
75
  const authorizedProviders = await getAuthorizedProviders(undefined, cfg);
76
+ const details = await getProviderDetails(undefined, cfg);
39
77
 
40
78
  return c.json({
41
79
  providers: authorizedProviders,
80
+ details,
42
81
  default: cfg.defaults.provider,
43
82
  });
44
83
  } catch (error) {
@@ -47,4 +86,98 @@ export function registerProvidersRoute(app: Hono) {
47
86
  return c.json(errorResponse, errorResponse.error.status || 500);
48
87
  }
49
88
  });
89
+
90
+ app.put('/v1/config/providers/:provider', async (c) => {
91
+ try {
92
+ const embeddedConfig = (
93
+ c as unknown as {
94
+ get: (key: 'embeddedConfig') => EmbeddedAppConfig | undefined;
95
+ }
96
+ ).get('embeddedConfig');
97
+ if (embeddedConfig && Object.keys(embeddedConfig).length > 0) {
98
+ return c.json({ error: 'Embedded config cannot be modified' }, 400);
99
+ }
100
+
101
+ const projectRoot = c.req.query('project') || process.cwd();
102
+ const provider = c.req.param('provider').trim();
103
+ const body = await c.req.json<ProviderMutationBody>();
104
+ const scope = body.scope || 'local';
105
+ if (!provider) return c.json({ error: 'Provider is required' }, 400);
106
+
107
+ const updates: ProviderSettingsEntry = {
108
+ enabled: body.enabled ?? true,
109
+ custom: isBuiltInProviderId(provider)
110
+ ? body.custom
111
+ : (body.custom ?? true),
112
+ };
113
+
114
+ if (body.label !== undefined)
115
+ updates.label = body.label.trim() || undefined;
116
+ if (body.compatibility !== undefined) {
117
+ updates.compatibility = body.compatibility;
118
+ }
119
+ if (body.family !== undefined) updates.family = body.family;
120
+ if (body.baseURL !== undefined) {
121
+ updates.baseURL = body.baseURL?.trim() || undefined;
122
+ }
123
+ if (body.apiKey !== undefined)
124
+ updates.apiKey = body.apiKey?.trim() || undefined;
125
+ if (body.apiKeyEnv !== undefined) {
126
+ updates.apiKeyEnv = body.apiKeyEnv?.trim() || undefined;
127
+ }
128
+ if (body.models !== undefined) {
129
+ updates.models = body.models
130
+ .map((model) => model.trim())
131
+ .filter(Boolean);
132
+ }
133
+ if (body.allowAnyModel !== undefined) {
134
+ updates.allowAnyModel = body.allowAnyModel;
135
+ }
136
+
137
+ if (!isBuiltInProviderId(provider) && !updates.compatibility) {
138
+ return c.json({ error: 'Custom providers require compatibility' }, 400);
139
+ }
140
+
141
+ await writeProviderSettings(scope, provider, updates, projectRoot);
142
+ const cfg = await loadConfig(projectRoot);
143
+ const details = await getProviderDetails(undefined, cfg);
144
+ return c.json({
145
+ success: true,
146
+ provider,
147
+ details,
148
+ });
149
+ } catch (error) {
150
+ logger.error('Failed to update provider settings', error);
151
+ const errorResponse = serializeError(error);
152
+ return c.json(errorResponse, errorResponse.error.status || 500);
153
+ }
154
+ });
155
+
156
+ app.delete('/v1/config/providers/:provider', async (c) => {
157
+ try {
158
+ const embeddedConfig = (
159
+ c as unknown as {
160
+ get: (key: 'embeddedConfig') => EmbeddedAppConfig | undefined;
161
+ }
162
+ ).get('embeddedConfig');
163
+ if (embeddedConfig && Object.keys(embeddedConfig).length > 0) {
164
+ return c.json({ error: 'Embedded config cannot be modified' }, 400);
165
+ }
166
+
167
+ const projectRoot = c.req.query('project') || process.cwd();
168
+ const provider = c.req.param('provider').trim();
169
+ const scope =
170
+ (c.req.query('scope') as 'global' | 'local' | undefined) || 'local';
171
+ if (!provider) return c.json({ error: 'Provider is required' }, 400);
172
+
173
+ await removeProviderSettings(scope, provider, projectRoot);
174
+ const cfg = await loadConfig(projectRoot);
175
+ const details = await getProviderDetails(undefined, cfg);
176
+ return c.json({ success: true, provider, details });
177
+ } catch (error) {
178
+ logger.error('Failed to remove provider settings', error);
179
+ const errorResponse = serializeError(error);
180
+ return c.json(errorResponse, errorResponse.error.status || 500);
181
+ }
182
+ });
50
183
  }
@@ -1,5 +1,9 @@
1
1
  import {
2
- catalog,
2
+ getConfiguredProviderApiKey,
3
+ getConfiguredProviderIds,
4
+ getConfiguredProviderModels,
5
+ getProviderDefinition,
6
+ getProviderSettings,
3
7
  type ProviderId,
4
8
  isProviderAuthorized,
5
9
  getGlobalAgentsDir,
@@ -11,6 +15,23 @@ import type { EmbeddedAppConfig } from '../../index.ts';
11
15
  import type { OttoConfig } from '@ottocode/sdk';
12
16
  import { loadAgentsConfig } from '../../runtime/agent/registry.ts';
13
17
 
18
+ export type ProviderDetail = {
19
+ id: string;
20
+ label: string;
21
+ source: 'built-in' | 'custom';
22
+ enabled: boolean;
23
+ authorized: boolean;
24
+ custom: boolean;
25
+ compatibility?: string;
26
+ family?: string;
27
+ baseURL?: string;
28
+ apiKeyEnv?: string;
29
+ hasApiKey: boolean;
30
+ allowAnyModel: boolean;
31
+ modelCount: number;
32
+ authType?: 'api' | 'oauth' | 'wallet';
33
+ };
34
+
14
35
  export async function isProviderAuthorizedHybrid(
15
36
  embeddedConfig: EmbeddedAppConfig | undefined,
16
37
  fileConfig: OttoConfig,
@@ -31,7 +52,7 @@ export async function getAuthorizedProviders(
31
52
  embeddedConfig: EmbeddedAppConfig | undefined,
32
53
  fileConfig: OttoConfig,
33
54
  ): Promise<ProviderId[]> {
34
- const allProviders = Object.keys(catalog) as ProviderId[];
55
+ const allProviders = getConfiguredProviderIds(fileConfig);
35
56
  const authorizedProviders: ProviderId[] = [];
36
57
 
37
58
  for (const provider of allProviders) {
@@ -48,6 +69,55 @@ export async function getAuthorizedProviders(
48
69
  return authorizedProviders;
49
70
  }
50
71
 
72
+ export async function getProviderDetails(
73
+ embeddedConfig: EmbeddedAppConfig | undefined,
74
+ fileConfig: OttoConfig,
75
+ ): Promise<ProviderDetail[]> {
76
+ const providers = Array.from(
77
+ new Set<ProviderId>([
78
+ ...getConfiguredProviderIds(fileConfig, { includeDisabled: true }),
79
+ ...(embeddedConfig?.provider ? [embeddedConfig.provider] : []),
80
+ ...((embeddedConfig?.auth
81
+ ? (Object.keys(embeddedConfig.auth) as ProviderId[])
82
+ : []) as ProviderId[]),
83
+ ]),
84
+ );
85
+ const details = await Promise.all(
86
+ providers.map(async (provider) => {
87
+ const definition = getProviderDefinition(fileConfig, provider);
88
+ if (!definition) return null;
89
+ const settings = getProviderSettings(fileConfig, provider);
90
+ const authorized = await isProviderAuthorizedHybrid(
91
+ embeddedConfig,
92
+ fileConfig,
93
+ provider,
94
+ );
95
+ const authType = await getAuthTypeForProvider(
96
+ embeddedConfig,
97
+ provider,
98
+ fileConfig.projectRoot,
99
+ );
100
+ return {
101
+ id: provider,
102
+ label: definition.label,
103
+ source: definition.source,
104
+ enabled: settings?.enabled !== false,
105
+ authorized,
106
+ custom: definition.source === 'custom',
107
+ compatibility: definition.compatibility,
108
+ family: definition.family,
109
+ baseURL: definition.baseURL,
110
+ apiKeyEnv: definition.apiKeyEnv,
111
+ hasApiKey: Boolean(getConfiguredProviderApiKey(fileConfig, provider)),
112
+ allowAnyModel: definition.allowAnyModel,
113
+ modelCount: getConfiguredProviderModels(fileConfig, provider).length,
114
+ authType,
115
+ } satisfies ProviderDetail;
116
+ }),
117
+ );
118
+ return details.filter((detail): detail is ProviderDetail => Boolean(detail));
119
+ }
120
+
51
121
  export function getDefault<T>(
52
122
  embeddedValue: T | undefined,
53
123
  embeddedDefaultValue: T | undefined,
@@ -2,7 +2,10 @@ import type { Hono } from 'hono';
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import {
4
4
  readConfig,
5
- isAuthorized,
5
+ isProviderAuthorized,
6
+ getConfiguredProviderApiKey,
7
+ getConfiguredProviderEnvVar,
8
+ getConfiguredProviderIds,
6
9
  buildFsTools,
7
10
  buildGitTools,
8
11
  getSecureAuthPath,
@@ -11,27 +14,8 @@ import {
11
14
  getGlobalCommandsDir,
12
15
  logger,
13
16
  } from '@ottocode/sdk';
14
- import type { ProviderId } from '@ottocode/sdk';
15
17
  import { serializeError } from '../runtime/errors/api-error.ts';
16
18
 
17
- const PROVIDERS: ProviderId[] = [
18
- 'openai',
19
- 'anthropic',
20
- 'google',
21
- 'openrouter',
22
- 'opencode',
23
- 'ottorouter',
24
- ];
25
-
26
- function providerEnvVar(p: ProviderId): string | null {
27
- if (p === 'openai') return 'OPENAI_API_KEY';
28
- if (p === 'anthropic') return 'ANTHROPIC_API_KEY';
29
- if (p === 'google') return 'GOOGLE_GENERATIVE_AI_API_KEY';
30
- if (p === 'opencode') return 'OPENCODE_API_KEY';
31
- if (p === 'ottorouter') return 'OTTOROUTER_PRIVATE_KEY';
32
- return null;
33
- }
34
-
35
19
  async function fileExists(path: string | null): Promise<boolean> {
36
20
  if (!path) return false;
37
21
  try {
@@ -66,11 +50,14 @@ export function registerDoctorRoutes(app: Hono) {
66
50
  try {
67
51
  const projectRoot = c.req.query('project') || process.cwd();
68
52
  const { cfg, auth } = await readConfig(projectRoot);
53
+ const configuredProviders = getConfiguredProviderIds(cfg, {
54
+ includeDisabled: true,
55
+ });
69
56
 
70
57
  const providers = await Promise.all(
71
- PROVIDERS.map(async (id) => {
72
- const ok = await isAuthorized(id, projectRoot);
73
- const envVar = providerEnvVar(id);
58
+ configuredProviders.map(async (id) => {
59
+ const ok = await isProviderAuthorized(cfg, id);
60
+ const envVar = getConfiguredProviderEnvVar(cfg, id) ?? null;
74
61
  const envConfigured = envVar ? !!process.env[envVar] : false;
75
62
 
76
63
  const globalAuthPath = getSecureAuthPath();
@@ -104,7 +91,8 @@ export function registerDoctorRoutes(app: Hono) {
104
91
  envConfigured ||
105
92
  hasGlobalAuth ||
106
93
  cfg.defaults.provider === id ||
107
- hasStoredSecret;
94
+ hasStoredSecret ||
95
+ Boolean(getConfiguredProviderApiKey(cfg, id));
108
96
 
109
97
  return { id, ok, configured, sources };
110
98
  }),
@@ -114,9 +102,9 @@ export function registerDoctorRoutes(app: Hono) {
114
102
  agent: cfg.defaults.agent,
115
103
  provider: cfg.defaults.provider,
116
104
  model: cfg.defaults.model,
117
- providerAuthorized: await isAuthorized(
118
- cfg.defaults.provider as ProviderId,
119
- projectRoot,
105
+ providerAuthorized: await isProviderAuthorized(
106
+ cfg,
107
+ cfg.defaults.provider,
120
108
  ),
121
109
  };
122
110
 
@@ -4,7 +4,12 @@ import { promisify } from 'node:util';
4
4
  import { generateText, streamText } from 'ai';
5
5
  import { eq } from 'drizzle-orm';
6
6
  import type { ProviderId } from '@ottocode/sdk';
7
- import { loadConfig, getAuth, getFastModelForAuth } from '@ottocode/sdk';
7
+ import {
8
+ loadConfig,
9
+ getAuth,
10
+ getFastModelForAuth,
11
+ getProviderDefinition,
12
+ } from '@ottocode/sdk';
8
13
  import { getDb } from '@ottocode/database';
9
14
  import { sessions } from '@ottocode/database/schema';
10
15
  import { gitCommitSchema, gitGenerateCommitMessageSchema } from './schemas.ts';
@@ -108,25 +113,31 @@ export function registerCommitRoutes(app: Hono) {
108
113
  const config = await loadConfig();
109
114
 
110
115
  let provider = (config.defaults?.provider || 'anthropic') as ProviderId;
116
+ let currentModel = config.defaults?.model ?? 'claude-3-5-sonnet-20241022';
111
117
 
112
118
  if (sessionId) {
113
119
  const db = await getDb();
114
120
  const [session] = await db
115
- .select({ provider: sessions.provider })
121
+ .select({ provider: sessions.provider, model: sessions.model })
116
122
  .from(sessions)
117
123
  .where(eq(sessions.id, sessionId));
118
124
  if (session?.provider) {
119
125
  provider = session.provider as ProviderId;
120
126
  }
127
+ if (session?.model) {
128
+ currentModel = session.model;
129
+ }
121
130
  }
122
131
 
123
132
  const auth = await getAuth(provider, config.projectRoot);
124
133
  const oauth = detectOAuth(provider, auth);
134
+ const providerDefinition = getProviderDefinition(config, provider);
125
135
 
126
136
  const modelId =
127
- getFastModelForAuth(provider, auth?.type) ??
128
- config.defaults?.model ??
129
- 'claude-3-5-sonnet-20241022';
137
+ providerDefinition?.source === 'custom' ||
138
+ providerDefinition?.compatibility === 'ollama'
139
+ ? currentModel
140
+ : (getFastModelForAuth(provider, auth?.type) ?? currentModel);
130
141
  const model = await resolveModel(provider, modelId, config);
131
142
 
132
143
  const userPrompt = `Generate a commit message for these git changes.
@@ -4,7 +4,7 @@ import { getDb } from '@ottocode/database';
4
4
  import { sessions, messages, messageParts } from '@ottocode/database/schema';
5
5
  import { desc, eq, and, asc, count } from 'drizzle-orm';
6
6
  import type { ProviderId } from '@ottocode/sdk';
7
- import { isProviderId } from '@ottocode/sdk';
7
+ import { hasConfiguredProvider } from '@ottocode/sdk';
8
8
  import { serializeError } from '../runtime/errors/api-error.ts';
9
9
  import { logger } from '@ottocode/sdk';
10
10
  import { publish } from '../events/bus.ts';
@@ -89,7 +89,7 @@ export function registerResearchRoutes(app: Hono) {
89
89
  const providerCandidate =
90
90
  typeof body.provider === 'string' ? body.provider : undefined;
91
91
  const provider: ProviderId = (() => {
92
- if (providerCandidate && isProviderId(providerCandidate))
92
+ if (providerCandidate && hasConfiguredProvider(cfg, providerCandidate))
93
93
  return providerCandidate;
94
94
  return parent.provider as ProviderId;
95
95
  })();
@@ -278,7 +278,7 @@ export function registerResearchRoutes(app: Hono) {
278
278
  const providerCandidate =
279
279
  typeof body.provider === 'string' ? body.provider : undefined;
280
280
  const provider: ProviderId = (() => {
281
- if (providerCandidate && isProviderId(providerCandidate))
281
+ if (providerCandidate && hasConfiguredProvider(cfg, providerCandidate))
282
282
  return providerCandidate;
283
283
  return cfg.defaults.provider;
284
284
  })();
@@ -1,13 +1,15 @@
1
1
  import type { Hono } from 'hono';
2
- import { loadConfig, type ReasoningLevel } from '@ottocode/sdk';
3
- import { getDb } from '@ottocode/database';
4
- import { messages, messageParts, sessions } from '@ottocode/database/schema';
5
- import { eq, inArray } from 'drizzle-orm';
6
2
  import {
7
- validateProviderModel,
8
- isProviderAuthorized,
9
3
  ensureProviderEnv,
4
+ getProviderDefinition,
5
+ isProviderAuthorized,
6
+ loadConfig,
7
+ type ReasoningLevel,
8
+ validateProviderModel,
10
9
  } from '@ottocode/sdk';
10
+ import { getDb } from '@ottocode/database';
11
+ import { messages, messageParts, sessions } from '@ottocode/database/schema';
12
+ import { eq, inArray } from 'drizzle-orm';
11
13
  import { dispatchAssistantMessage } from '../runtime/message/service.ts';
12
14
  import { logger } from '@ottocode/sdk';
13
15
  import { serializeError } from '../runtime/errors/api-error.ts';
@@ -132,7 +134,7 @@ export function registerSessionMessagesRoutes(app: Hono) {
132
134
  // Validate model capabilities if tools are allowed for this agent
133
135
  const wantsToolCalls = true; // agent toolset may be non-empty
134
136
  try {
135
- validateProviderModel(provider, modelName, { wantsToolCalls });
137
+ validateProviderModel(provider, modelName, cfg, { wantsToolCalls });
136
138
  } catch (err) {
137
139
  logger.error('Model validation failed', err, { provider, modelName });
138
140
  const message = err instanceof Error ? err.message : String(err);
@@ -150,6 +152,7 @@ export function registerSessionMessagesRoutes(app: Hono) {
150
152
  );
151
153
  }
152
154
  await ensureProviderEnv(cfg, provider);
155
+ const providerDefinition = getProviderDefinition(cfg, provider);
153
156
 
154
157
  const { assistantMessageId } = await dispatchAssistantMessage({
155
158
  cfg,
@@ -161,7 +164,10 @@ export function registerSessionMessagesRoutes(app: Hono) {
161
164
  content,
162
165
  oneShot: Boolean(body?.oneShot),
163
166
  userContext,
164
- reasoningText: reasoning,
167
+ reasoningText:
168
+ providerDefinition?.compatibility === 'ollama'
169
+ ? (body?.reasoningText ?? false)
170
+ : reasoning,
165
171
  reasoningLevel,
166
172
  images,
167
173
  files,
@@ -10,7 +10,7 @@ import {
10
10
  } from '@ottocode/database/schema';
11
11
  import { desc, eq, and, ne, inArray, or } from 'drizzle-orm';
12
12
  import type { ProviderId } from '@ottocode/sdk';
13
- import { isProviderId, catalog } from '@ottocode/sdk';
13
+ import { hasConfiguredProvider, validateProviderModel } from '@ottocode/sdk';
14
14
  import { resolveAgentConfig } from '../runtime/agent/registry.ts';
15
15
  import { createSession as createSessionRow } from '../runtime/session/manager.ts';
16
16
  import { serializeError } from '../runtime/errors/api-error.ts';
@@ -79,9 +79,9 @@ export function registerSessionsRoutes(app: Hono) {
79
79
  const providerCandidate =
80
80
  typeof body.provider === 'string' ? body.provider : undefined;
81
81
  const provider: ProviderId = (() => {
82
- if (providerCandidate && isProviderId(providerCandidate))
82
+ if (providerCandidate && hasConfiguredProvider(cfg, providerCandidate))
83
83
  return providerCandidate;
84
- if (agentCfg.provider && isProviderId(agentCfg.provider))
84
+ if (hasConfiguredProvider(cfg, agentCfg.provider))
85
85
  return agentCfg.provider;
86
86
  return cfg.defaults.provider;
87
87
  })();
@@ -208,7 +208,7 @@ export function registerSessionsRoutes(app: Hono) {
208
208
  // Validate provider if provided
209
209
  if (typeof body.provider === 'string') {
210
210
  const providerName = body.provider.trim();
211
- if (providerName && isProviderId(providerName)) {
211
+ if (providerName && hasConfiguredProvider(cfg, providerName)) {
212
212
  updates.provider = providerName;
213
213
  } else if (providerName) {
214
214
  return c.json({ error: `Invalid provider: ${providerName}` }, 400);
@@ -221,21 +221,15 @@ export function registerSessionsRoutes(app: Hono) {
221
221
  if (modelName) {
222
222
  const targetProvider = (updates.provider ||
223
223
  existingSession.provider) as ProviderId;
224
-
225
- // Check if model exists for the provider
226
- const providerCatalog = catalog[targetProvider];
227
- if (providerCatalog) {
228
- const modelExists = providerCatalog.models.some(
229
- (m) => m.id === modelName,
224
+ try {
225
+ validateProviderModel(targetProvider, modelName, cfg);
226
+ } catch {
227
+ return c.json(
228
+ {
229
+ error: `Model "${modelName}" not found for provider "${targetProvider}"`,
230
+ },
231
+ 400,
230
232
  );
231
- if (!modelExists) {
232
- return c.json(
233
- {
234
- error: `Model "${modelName}" not found for provider "${targetProvider}"`,
235
- },
236
- 400,
237
- );
238
- }
239
233
  }
240
234
 
241
235
  updates.model = modelName;