@ottocode/server 0.1.245 → 0.1.247

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.
Files changed (36) hide show
  1. package/package.json +4 -3
  2. package/src/index.ts +5 -0
  3. package/src/openapi/paths/config.ts +118 -2
  4. package/src/openapi/paths/skills.ts +122 -0
  5. package/src/openapi/schemas.ts +35 -3
  6. package/src/presets.ts +1 -1
  7. package/src/routes/auth.ts +24 -30
  8. package/src/routes/branch.ts +3 -2
  9. package/src/routes/config/defaults.ts +10 -3
  10. package/src/routes/config/main.ts +3 -0
  11. package/src/routes/config/models.ts +84 -14
  12. package/src/routes/config/providers.ts +137 -4
  13. package/src/routes/config/utils.ts +72 -2
  14. package/src/routes/doctor.ts +15 -27
  15. package/src/routes/git/commit.ts +16 -5
  16. package/src/routes/research.ts +3 -3
  17. package/src/routes/session-messages.ts +14 -8
  18. package/src/routes/sessions.ts +12 -18
  19. package/src/routes/skills.ts +140 -59
  20. package/src/runtime/agent/registry.ts +8 -5
  21. package/src/runtime/agent/runner-setup.ts +136 -39
  22. package/src/runtime/agent/runner.ts +140 -4
  23. package/src/runtime/ask/service.ts +13 -10
  24. package/src/runtime/message/history-builder.ts +22 -6
  25. package/src/runtime/message/service.ts +7 -1
  26. package/src/runtime/prompt/builder.ts +12 -0
  27. package/src/runtime/prompt/capabilities.ts +200 -0
  28. package/src/runtime/provider/index.ts +98 -0
  29. package/src/runtime/provider/reasoning.ts +73 -17
  30. package/src/runtime/provider/selection.ts +16 -14
  31. package/src/runtime/session/manager.ts +1 -1
  32. package/src/runtime/session/queue.ts +7 -2
  33. package/src/runtime/tools/approval.ts +1 -0
  34. package/src/runtime/tools/guards.ts +4 -3
  35. package/src/runtime/tools/mapping.ts +4 -2
  36. package/src/tools/adapter.ts +3 -3
@@ -1,4 +1,16 @@
1
1
  import type { OttoConfig, ProviderId } from '@ottocode/sdk';
2
+ import {
3
+ getConfiguredProviderApiKey,
4
+ getProviderDefinition,
5
+ isBuiltInProviderId,
6
+ normalizeOllamaBaseURL,
7
+ } from '@ottocode/sdk';
8
+ import { createOpenAI } from '@ai-sdk/openai';
9
+ import { createAnthropic } from '@ai-sdk/anthropic';
10
+ import { createGoogleGenerativeAI } from '@ai-sdk/google';
11
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
12
+ import { createOllama } from 'ai-sdk-ollama';
13
+ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
2
14
  import { getAnthropicInstance } from './anthropic.ts';
3
15
  import { resolveOpenAIModel } from './openai.ts';
4
16
  import { resolveGoogleModel } from './google.ts';
@@ -23,6 +35,7 @@ export async function resolveModel(
23
35
  systemPrompt?: string;
24
36
  sessionId?: string;
25
37
  messageId?: string;
38
+ reasoningText?: boolean;
26
39
  topupApprovalMode?: ResolveOttoRouterModelOptions['topupApprovalMode'];
27
40
  autoPayThresholdUsd?: ResolveOttoRouterModelOptions['autoPayThresholdUsd'];
28
41
  },
@@ -37,6 +50,13 @@ export async function resolveModel(
37
50
  if (provider === 'google') {
38
51
  return resolveGoogleModel(model, cfg);
39
52
  }
53
+ if (provider === 'ollama-cloud') {
54
+ const definition = getProviderDefinition(cfg, provider);
55
+ if (!definition) {
56
+ throw new Error(`Unsupported provider: ${provider}`);
57
+ }
58
+ return resolveCustomConfiguredModel(definition, cfg, model, options);
59
+ }
40
60
  if (provider === 'openrouter') {
41
61
  return resolveOpenRouterModel(model);
42
62
  }
@@ -65,5 +85,83 @@ export async function resolveModel(
65
85
  if (provider === 'minimax') {
66
86
  return getMinimaxInstance(cfg, model);
67
87
  }
88
+
89
+ const definition = getProviderDefinition(cfg, provider);
90
+ if (definition && !isBuiltInProviderId(provider)) {
91
+ return resolveCustomConfiguredModel(definition, cfg, model, options);
92
+ }
68
93
  throw new Error(`Unsupported provider: ${provider}`);
69
94
  }
95
+
96
+ function needsResponsesApi(model: string): boolean {
97
+ const lower = model.toLowerCase();
98
+ return (
99
+ lower.includes('gpt-5') ||
100
+ lower.startsWith('o1') ||
101
+ lower.startsWith('o3') ||
102
+ lower.startsWith('o4') ||
103
+ lower.includes('codex-mini')
104
+ );
105
+ }
106
+
107
+ function resolveCustomConfiguredModel(
108
+ definition: NonNullable<ReturnType<typeof getProviderDefinition>>,
109
+ cfg: OttoConfig,
110
+ model: string,
111
+ options?: {
112
+ reasoningText?: boolean;
113
+ },
114
+ ) {
115
+ const apiKey = getConfiguredProviderApiKey(cfg, definition.id) || '';
116
+ const baseURL =
117
+ definition.baseURL ||
118
+ (definition.id === 'ollama-cloud' ? 'https://ollama.com' : undefined);
119
+
120
+ if (!baseURL) {
121
+ throw new Error(
122
+ `Custom provider ${definition.id} requires a baseURL in config.`,
123
+ );
124
+ }
125
+
126
+ if (definition.compatibility === 'openai') {
127
+ const instance = createOpenAI({ apiKey, baseURL });
128
+ return needsResponsesApi(model)
129
+ ? instance.responses(model)
130
+ : instance(model);
131
+ }
132
+
133
+ if (definition.compatibility === 'anthropic') {
134
+ const instance = createAnthropic({ apiKey, baseURL });
135
+ return instance(model);
136
+ }
137
+
138
+ if (definition.compatibility === 'google') {
139
+ const instance = createGoogleGenerativeAI({ apiKey, baseURL });
140
+ return instance(model);
141
+ }
142
+
143
+ if (definition.compatibility === 'openrouter') {
144
+ const instance = createOpenRouter({ apiKey, baseURL });
145
+ return instance.chat(model);
146
+ }
147
+
148
+ if (definition.compatibility === 'ollama') {
149
+ const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined;
150
+ const ollamaBaseURL = normalizeOllamaBaseURL(baseURL);
151
+ const instance = createOllama({
152
+ baseURL: ollamaBaseURL,
153
+ headers,
154
+ });
155
+ return instance(model, {
156
+ ...(options?.reasoningText ? { think: true } : {}),
157
+ });
158
+ }
159
+
160
+ const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined;
161
+ const instance = createOpenAICompatible({
162
+ name: definition.label,
163
+ baseURL,
164
+ headers,
165
+ });
166
+ return instance(model);
167
+ }
@@ -1,8 +1,11 @@
1
1
  import {
2
2
  catalog,
3
+ getConfiguredProviderFamily,
4
+ getProviderDefinition,
3
5
  getModelNpmBinding,
4
6
  getUnderlyingProviderKey,
5
7
  modelSupportsReasoning,
8
+ type OttoConfig,
6
9
  type ProviderId,
7
10
  type ReasoningLevel,
8
11
  } from '@ottocode/sdk';
@@ -35,7 +38,6 @@ function toAnthropicEffort(
35
38
  case 'max':
36
39
  case 'xhigh':
37
40
  return 'max';
38
- case 'high':
39
41
  default:
40
42
  return 'high';
41
43
  }
@@ -54,7 +56,6 @@ function toOpenAIEffort(
54
56
  case 'max':
55
57
  case 'xhigh':
56
58
  return 'xhigh';
57
- case 'high':
58
59
  default:
59
60
  return 'high';
60
61
  }
@@ -70,9 +71,6 @@ function toGoogleThinkingLevel(
70
71
  return 'low';
71
72
  case 'medium':
72
73
  return 'medium';
73
- case 'max':
74
- case 'xhigh':
75
- case 'high':
76
74
  default:
77
75
  return 'high';
78
76
  }
@@ -95,7 +93,6 @@ function toThinkingBudget(
95
93
  case 'max':
96
94
  case 'xhigh':
97
95
  return Math.min(24000, cap);
98
- case 'high':
99
96
  default:
100
97
  return Math.min(16000, cap);
101
98
  }
@@ -114,11 +111,16 @@ function toCamelCaseKey(value: string): string {
114
111
  .join('');
115
112
  }
116
113
 
117
- function getOpenAICompatibleProviderOptionKeys(provider: ProviderId): string[] {
114
+ function getOpenAICompatibleProviderOptionKeys(
115
+ provider: ProviderId,
116
+ cfg?: OttoConfig,
117
+ ): string[] {
118
+ const definition = cfg ? getProviderDefinition(cfg, provider) : undefined;
118
119
  const entry = catalog[provider];
119
120
  const keys = new Set<string>(['openaiCompatible', toCamelCaseKey(provider)]);
120
- if (entry?.label) {
121
- keys.add(toCamelCaseKey(entry.label));
121
+ const label = definition?.label ?? entry?.label;
122
+ if (label) {
123
+ keys.add(toCamelCaseKey(label));
122
124
  }
123
125
  return Array.from(keys).filter(Boolean);
124
126
  }
@@ -126,8 +128,9 @@ function getOpenAICompatibleProviderOptionKeys(provider: ProviderId): string[] {
126
128
  function buildSharedProviderOptions(
127
129
  provider: ProviderId,
128
130
  options: Record<string, unknown>,
131
+ cfg?: OttoConfig,
129
132
  ): Record<string, unknown> {
130
- const keys = getOpenAICompatibleProviderOptionKeys(provider);
133
+ const keys = getOpenAICompatibleProviderOptionKeys(provider, cfg);
131
134
  return Object.fromEntries(keys.map((key) => [key, options]));
132
135
  }
133
136
 
@@ -146,14 +149,28 @@ function usesAdaptiveAnthropicThinking(model: string): boolean {
146
149
  function getReasoningProviderTarget(
147
150
  provider: ProviderId,
148
151
  model: string,
152
+ cfg?: OttoConfig,
149
153
  ):
150
154
  | 'anthropic'
151
155
  | 'openai'
152
156
  | 'google'
157
+ | 'ollama'
153
158
  | 'openai-compatible'
154
159
  | 'openrouter'
155
160
  | null {
161
+ const definition = cfg ? getProviderDefinition(cfg, provider) : undefined;
162
+ if (definition?.source === 'custom') {
163
+ if (definition.compatibility === 'anthropic') return 'anthropic';
164
+ if (definition.compatibility === 'openai') return 'openai';
165
+ if (definition.compatibility === 'google') return 'google';
166
+ if (definition.compatibility === 'ollama') return 'ollama';
167
+ if (definition.compatibility === 'openrouter') return 'openrouter';
168
+ return 'openai-compatible';
169
+ }
170
+
171
+ if (provider === 'ottorouter') return 'openrouter';
156
172
  if (provider === 'openrouter') return 'openrouter';
173
+ if (definition?.compatibility === 'ollama') return 'ollama';
157
174
  if (
158
175
  provider === 'moonshot' ||
159
176
  provider === 'zai' ||
@@ -167,6 +184,7 @@ function getReasoningProviderTarget(
167
184
  if (npmBinding === '@ai-sdk/anthropic') return 'anthropic';
168
185
  if (npmBinding === '@ai-sdk/openai') return 'openai';
169
186
  if (npmBinding === '@ai-sdk/google') return 'google';
187
+ if (npmBinding === 'ai-sdk-ollama') return 'ollama';
170
188
  if (npmBinding === '@ai-sdk/openai-compatible') return 'openai-compatible';
171
189
  if (npmBinding === '@openrouter/ai-sdk-provider') return 'openrouter';
172
190
 
@@ -175,19 +193,41 @@ function getReasoningProviderTarget(
175
193
  if (underlyingProvider === 'openai') return 'openai';
176
194
  if (underlyingProvider === 'google') return 'google';
177
195
  if (underlyingProvider === 'openai-compatible') return 'openai-compatible';
196
+
197
+ const family = cfg ? getConfiguredProviderFamily(cfg, provider, model) : null;
198
+ if (family === 'anthropic') return 'anthropic';
199
+ if (family === 'openai') return 'openai';
200
+ if (family === 'google') return 'google';
201
+ if (family === 'openai-compatible') return 'openai-compatible';
178
202
  return null;
179
203
  }
180
204
 
181
205
  export function buildReasoningConfig(args: {
206
+ cfg?: OttoConfig;
182
207
  provider: ProviderId;
183
208
  model: string;
184
209
  reasoningText?: boolean;
185
210
  reasoningLevel?: ReasoningLevel;
186
211
  maxOutputTokens: number | undefined;
187
212
  }): ReasoningConfigResult {
188
- const { provider, model, reasoningText, reasoningLevel, maxOutputTokens } =
189
- args;
190
- if (!reasoningText || !modelSupportsReasoning(provider, model)) {
213
+ const {
214
+ cfg,
215
+ provider,
216
+ model,
217
+ reasoningText,
218
+ reasoningLevel,
219
+ maxOutputTokens,
220
+ } = args;
221
+ const definition = cfg ? getProviderDefinition(cfg, provider) : undefined;
222
+ const supportsReasoning =
223
+ definition?.compatibility === 'ollama'
224
+ ? true
225
+ : definition?.source === 'custom'
226
+ ? true
227
+ : provider === 'ottorouter'
228
+ ? true
229
+ : modelSupportsReasoning(provider, model);
230
+ if (!reasoningText || !supportsReasoning) {
191
231
  return {
192
232
  providerOptions: {},
193
233
  effectiveMaxOutputTokens: maxOutputTokens,
@@ -195,7 +235,7 @@ export function buildReasoningConfig(args: {
195
235
  };
196
236
  }
197
237
 
198
- const reasoningTarget = getReasoningProviderTarget(provider, model);
238
+ const reasoningTarget = getReasoningProviderTarget(provider, model, cfg);
199
239
  if (reasoningTarget === 'anthropic') {
200
240
  if (usesAdaptiveAnthropicThinking(model)) {
201
241
  return {
@@ -263,6 +303,18 @@ export function buildReasoningConfig(args: {
263
303
  };
264
304
  }
265
305
 
306
+ if (reasoningTarget === 'ollama') {
307
+ return {
308
+ providerOptions: {
309
+ ollama: {
310
+ think: true,
311
+ },
312
+ },
313
+ effectiveMaxOutputTokens: maxOutputTokens,
314
+ enabled: true,
315
+ };
316
+ }
317
+
266
318
  if (reasoningTarget === 'openrouter') {
267
319
  return {
268
320
  providerOptions: {
@@ -277,9 +329,13 @@ export function buildReasoningConfig(args: {
277
329
 
278
330
  if (reasoningTarget === 'openai-compatible') {
279
331
  return {
280
- providerOptions: buildSharedProviderOptions(provider, {
281
- reasoningEffort: normalizeReasoningLevel(reasoningLevel),
282
- }),
332
+ providerOptions: buildSharedProviderOptions(
333
+ provider,
334
+ {
335
+ reasoningEffort: normalizeReasoningLevel(reasoningLevel),
336
+ },
337
+ cfg,
338
+ ),
283
339
  effectiveMaxOutputTokens: maxOutputTokens,
284
340
  enabled: true,
285
341
  };
@@ -1,11 +1,11 @@
1
1
  import type { OttoConfig } from '@ottocode/sdk';
2
2
  import {
3
- catalog,
4
3
  type ProviderId,
5
4
  isProviderAuthorized,
6
- providerIds,
7
- defaultModelFor,
8
- hasModel,
5
+ getConfiguredProviderIds,
6
+ getConfiguredProviderDefaultModel,
7
+ hasConfiguredModel,
8
+ hasConfiguredProvider,
9
9
  } from '@ottocode/sdk';
10
10
 
11
11
  const FALLBACK_ORDER: ProviderId[] = [
@@ -59,6 +59,7 @@ export async function selectProviderAndModel(
59
59
  }
60
60
 
61
61
  const model = resolveModelForProvider({
62
+ cfg,
62
63
  provider,
63
64
  explicitModel,
64
65
  agentModelDefault,
@@ -81,9 +82,10 @@ async function pickAuthorizedProvider(args: {
81
82
  const candidates = uniqueProviders([
82
83
  candidate,
83
84
  ...FALLBACK_ORDER,
84
- ...providerIds,
85
+ ...getConfiguredProviderIds(cfg),
85
86
  ]);
86
87
  for (const provider of candidates) {
88
+ if (!hasConfiguredProvider(cfg, provider)) continue;
87
89
  const ok = await isProviderAuthorized(cfg, provider);
88
90
  if (ok) return provider;
89
91
  }
@@ -94,7 +96,6 @@ function uniqueProviders(list: ProviderId[]): ProviderId[] {
94
96
  const seen = new Set<ProviderId>();
95
97
  const ordered: ProviderId[] = [];
96
98
  for (const provider of list) {
97
- if (!providerIds.includes(provider)) continue;
98
99
  if (seen.has(provider)) continue;
99
100
  seen.add(provider);
100
101
  ordered.push(provider);
@@ -103,16 +104,17 @@ function uniqueProviders(list: ProviderId[]): ProviderId[] {
103
104
  }
104
105
 
105
106
  function resolveModelForProvider(args: {
107
+ cfg: OttoConfig;
106
108
  provider: ProviderId;
107
109
  explicitModel?: string;
108
110
  agentModelDefault: string;
109
111
  }): string {
110
- const { provider, explicitModel, agentModelDefault } = args;
111
- if (explicitModel && hasModel(provider, explicitModel)) return explicitModel;
112
- if (hasModel(provider, agentModelDefault)) return agentModelDefault;
113
- return (
114
- defaultModelFor(provider) ??
115
- catalog[provider]?.models?.[0]?.id ??
116
- agentModelDefault
117
- );
112
+ const { cfg, provider, explicitModel, agentModelDefault } = args;
113
+ if (explicitModel && hasConfiguredModel(cfg, provider, explicitModel)) {
114
+ return explicitModel;
115
+ }
116
+ if (hasConfiguredModel(cfg, provider, agentModelDefault)) {
117
+ return agentModelDefault;
118
+ }
119
+ return getConfiguredProviderDefaultModel(cfg, provider) ?? agentModelDefault;
118
120
  }
@@ -29,7 +29,7 @@ export async function createSession({
29
29
  model,
30
30
  title,
31
31
  }: CreateSessionInput): Promise<SessionRow> {
32
- validateProviderModel(provider, model);
32
+ validateProviderModel(provider, model, cfg);
33
33
  const authorized = await isProviderAuthorized(cfg, provider);
34
34
  if (!authorized) {
35
35
  throw new Error(
@@ -10,6 +10,7 @@ export type RunOpts = {
10
10
  provider: ProviderName;
11
11
  model: string;
12
12
  projectRoot: string;
13
+ queuedAt?: number;
13
14
  oneShot?: boolean;
14
15
  userContext?: string;
15
16
  estimatedInputTokens?: number;
@@ -74,7 +75,7 @@ function publishQueueState(sessionId: string) {
74
75
  * Creates an abort controller per message.
75
76
  */
76
77
  export function enqueueAssistantRun(
77
- opts: Omit<RunOpts, 'abortSignal'>,
78
+ opts: Omit<RunOpts, 'abortSignal' | 'queuedAt'>,
78
79
  processQueueFn: (sessionId: string) => Promise<void>,
79
80
  ) {
80
81
  const abortController = new AbortController();
@@ -85,7 +86,11 @@ export function enqueueAssistantRun(
85
86
  running: false,
86
87
  currentMessageId: null,
87
88
  };
88
- state.queue.push({ ...opts, abortSignal: abortController.signal });
89
+ state.queue.push({
90
+ ...opts,
91
+ queuedAt: globalThis.performance?.now?.() ?? Date.now(),
92
+ abortSignal: abortController.signal,
93
+ });
89
94
  runners.set(opts.sessionId, state);
90
95
 
91
96
  publishQueueState(opts.sessionId);
@@ -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';