@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,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);