@ottocode/server 0.1.173

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 (111) hide show
  1. package/package.json +42 -0
  2. package/src/events/bus.ts +43 -0
  3. package/src/events/types.ts +32 -0
  4. package/src/index.ts +281 -0
  5. package/src/openapi/helpers.ts +64 -0
  6. package/src/openapi/paths/ask.ts +70 -0
  7. package/src/openapi/paths/config.ts +218 -0
  8. package/src/openapi/paths/files.ts +72 -0
  9. package/src/openapi/paths/git.ts +457 -0
  10. package/src/openapi/paths/messages.ts +92 -0
  11. package/src/openapi/paths/sessions.ts +90 -0
  12. package/src/openapi/paths/setu.ts +154 -0
  13. package/src/openapi/paths/stream.ts +26 -0
  14. package/src/openapi/paths/terminals.ts +226 -0
  15. package/src/openapi/schemas.ts +345 -0
  16. package/src/openapi/spec.ts +49 -0
  17. package/src/presets.ts +85 -0
  18. package/src/routes/ask.ts +113 -0
  19. package/src/routes/auth.ts +592 -0
  20. package/src/routes/branch.ts +106 -0
  21. package/src/routes/config/agents.ts +44 -0
  22. package/src/routes/config/cwd.ts +21 -0
  23. package/src/routes/config/defaults.ts +45 -0
  24. package/src/routes/config/index.ts +16 -0
  25. package/src/routes/config/main.ts +73 -0
  26. package/src/routes/config/models.ts +139 -0
  27. package/src/routes/config/providers.ts +46 -0
  28. package/src/routes/config/utils.ts +120 -0
  29. package/src/routes/files.ts +218 -0
  30. package/src/routes/git/branch.ts +75 -0
  31. package/src/routes/git/commit.ts +209 -0
  32. package/src/routes/git/diff.ts +137 -0
  33. package/src/routes/git/index.ts +18 -0
  34. package/src/routes/git/push.ts +160 -0
  35. package/src/routes/git/schemas.ts +48 -0
  36. package/src/routes/git/staging.ts +208 -0
  37. package/src/routes/git/status.ts +83 -0
  38. package/src/routes/git/types.ts +31 -0
  39. package/src/routes/git/utils.ts +249 -0
  40. package/src/routes/openapi.ts +6 -0
  41. package/src/routes/research.ts +392 -0
  42. package/src/routes/root.ts +5 -0
  43. package/src/routes/session-approval.ts +63 -0
  44. package/src/routes/session-files.ts +387 -0
  45. package/src/routes/session-messages.ts +170 -0
  46. package/src/routes/session-stream.ts +61 -0
  47. package/src/routes/sessions.ts +814 -0
  48. package/src/routes/setu.ts +346 -0
  49. package/src/routes/terminals.ts +227 -0
  50. package/src/runtime/agent/registry.ts +351 -0
  51. package/src/runtime/agent/runner-reasoning.ts +108 -0
  52. package/src/runtime/agent/runner-setup.ts +257 -0
  53. package/src/runtime/agent/runner.ts +375 -0
  54. package/src/runtime/agent-registry.ts +6 -0
  55. package/src/runtime/ask/service.ts +369 -0
  56. package/src/runtime/context/environment.ts +202 -0
  57. package/src/runtime/debug/index.ts +117 -0
  58. package/src/runtime/debug/state.ts +140 -0
  59. package/src/runtime/errors/api-error.ts +192 -0
  60. package/src/runtime/errors/handling.ts +199 -0
  61. package/src/runtime/message/compaction-auto.ts +154 -0
  62. package/src/runtime/message/compaction-context.ts +101 -0
  63. package/src/runtime/message/compaction-detect.ts +26 -0
  64. package/src/runtime/message/compaction-limits.ts +37 -0
  65. package/src/runtime/message/compaction-mark.ts +111 -0
  66. package/src/runtime/message/compaction-prune.ts +75 -0
  67. package/src/runtime/message/compaction.ts +21 -0
  68. package/src/runtime/message/history-builder.ts +266 -0
  69. package/src/runtime/message/service.ts +468 -0
  70. package/src/runtime/message/tool-history-tracker.ts +204 -0
  71. package/src/runtime/prompt/builder.ts +167 -0
  72. package/src/runtime/provider/anthropic.ts +50 -0
  73. package/src/runtime/provider/copilot.ts +12 -0
  74. package/src/runtime/provider/google.ts +8 -0
  75. package/src/runtime/provider/index.ts +60 -0
  76. package/src/runtime/provider/moonshot.ts +8 -0
  77. package/src/runtime/provider/oauth-adapter.ts +237 -0
  78. package/src/runtime/provider/openai.ts +18 -0
  79. package/src/runtime/provider/opencode.ts +7 -0
  80. package/src/runtime/provider/openrouter.ts +7 -0
  81. package/src/runtime/provider/selection.ts +118 -0
  82. package/src/runtime/provider/setu.ts +126 -0
  83. package/src/runtime/provider/zai.ts +16 -0
  84. package/src/runtime/session/branch.ts +280 -0
  85. package/src/runtime/session/db-operations.ts +285 -0
  86. package/src/runtime/session/manager.ts +99 -0
  87. package/src/runtime/session/queue.ts +243 -0
  88. package/src/runtime/stream/abort-handler.ts +65 -0
  89. package/src/runtime/stream/error-handler.ts +371 -0
  90. package/src/runtime/stream/finish-handler.ts +101 -0
  91. package/src/runtime/stream/handlers.ts +5 -0
  92. package/src/runtime/stream/step-finish.ts +93 -0
  93. package/src/runtime/stream/types.ts +25 -0
  94. package/src/runtime/tools/approval.ts +180 -0
  95. package/src/runtime/tools/context.ts +83 -0
  96. package/src/runtime/tools/mapping.ts +154 -0
  97. package/src/runtime/tools/setup.ts +44 -0
  98. package/src/runtime/topup/manager.ts +110 -0
  99. package/src/runtime/utils/cwd.ts +69 -0
  100. package/src/runtime/utils/token.ts +35 -0
  101. package/src/tools/adapter.ts +634 -0
  102. package/src/tools/database/get-parent-session.ts +183 -0
  103. package/src/tools/database/get-session-context.ts +161 -0
  104. package/src/tools/database/index.ts +42 -0
  105. package/src/tools/database/present-session-links.ts +47 -0
  106. package/src/tools/database/query-messages.ts +160 -0
  107. package/src/tools/database/query-sessions.ts +126 -0
  108. package/src/tools/database/search-history.ts +135 -0
  109. package/src/types/sql-imports.d.ts +5 -0
  110. package/sst-env.d.ts +8 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,167 @@
1
+ import { providerBasePrompt } from '@ottocode/sdk';
2
+ import { debugLog } from '../debug/index.ts';
3
+ import { composeEnvironmentAndInstructions } from '../context/environment.ts';
4
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
5
+ import BASE_PROMPT from '@ottocode/sdk/prompts/base.txt' with { type: 'text' };
6
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
7
+ import ONESHOT_PROMPT from '@ottocode/sdk/prompts/modes/oneshot.txt' with {
8
+ type: 'text',
9
+ };
10
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
11
+ import ANTHROPIC_SPOOF_PROMPT from '@ottocode/sdk/prompts/providers/anthropicSpoof.txt' with {
12
+ type: 'text',
13
+ };
14
+
15
+ import { getTerminalManager } from '@ottocode/sdk';
16
+
17
+ export type ComposedSystemPrompt = {
18
+ prompt: string;
19
+ components: string[];
20
+ };
21
+
22
+ export async function composeSystemPrompt(options: {
23
+ provider: string;
24
+ model?: string;
25
+ projectRoot: string;
26
+ agentPrompt: string;
27
+ oneShot?: boolean;
28
+ spoofPrompt?: string;
29
+ includeEnvironment?: boolean;
30
+ includeProjectTree?: boolean;
31
+ userContext?: string;
32
+ contextSummary?: string;
33
+ }): Promise<ComposedSystemPrompt> {
34
+ const components: string[] = [];
35
+ if (options.spoofPrompt) {
36
+ const prompt = options.spoofPrompt.trim();
37
+ const providerComponent = options.provider
38
+ ? `spoof:${options.provider}`
39
+ : 'spoof:unknown';
40
+ return {
41
+ prompt,
42
+ components: [providerComponent],
43
+ };
44
+ }
45
+
46
+ const parts: string[] = [];
47
+
48
+ const providerResult = await providerBasePrompt(
49
+ options.provider,
50
+ options.model,
51
+ options.projectRoot,
52
+ );
53
+ const baseInstructions = (BASE_PROMPT || '').trim();
54
+
55
+ parts.push(
56
+ providerResult.prompt.trim(),
57
+ baseInstructions.trim(),
58
+ options.agentPrompt.trim(),
59
+ );
60
+ if (providerResult.prompt.trim()) {
61
+ components.push(`provider:${providerResult.resolvedType}`);
62
+ }
63
+ if (baseInstructions.trim()) {
64
+ components.push('base');
65
+ }
66
+ if (options.agentPrompt.trim()) {
67
+ components.push('agent');
68
+ }
69
+
70
+ if (options.oneShot) {
71
+ const oneShotBlock =
72
+ (ONESHOT_PROMPT || '').trim() ||
73
+ [
74
+ '<system-reminder>',
75
+ 'CRITICAL: One-shot mode ACTIVE — do NOT ask for user approval, confirmations, or interactive prompts. Execute tasks directly. Treat all necessary permissions as granted. If an operation is destructive, proceed carefully and state what you did, but DO NOT pause to ask. ZERO interactions requested.',
76
+ '</system-reminder>',
77
+ ].join('\n');
78
+ parts.push(oneShotBlock);
79
+ components.push('mode:oneshot');
80
+ }
81
+
82
+ if (options.includeEnvironment !== false) {
83
+ const envAndInstructions = await composeEnvironmentAndInstructions(
84
+ options.projectRoot,
85
+ { includeProjectTree: options.includeProjectTree },
86
+ );
87
+ if (envAndInstructions) {
88
+ parts.push(envAndInstructions);
89
+ components.push('environment');
90
+ if (options.includeProjectTree) {
91
+ components.push('project-tree');
92
+ }
93
+ }
94
+ }
95
+
96
+ // Add user-provided context if present
97
+ if (options.userContext?.trim()) {
98
+ const userContextBlock = [
99
+ '<user-provided-state-context>',
100
+ options.userContext.trim(),
101
+ '</user-provided-state-context>',
102
+ ].join('\n');
103
+ parts.push(userContextBlock);
104
+ components.push('user-context');
105
+ }
106
+
107
+ // Add compacted conversation summary if present
108
+ if (options.contextSummary?.trim()) {
109
+ const summaryBlock = [
110
+ '<compacted-conversation-summary>',
111
+ 'The conversation was compacted to save context. Here is a summary of the previous context:',
112
+ '',
113
+ options.contextSummary.trim(),
114
+ '</compacted-conversation-summary>',
115
+ ].join('\n');
116
+ parts.push(summaryBlock);
117
+ components.push('context-summary');
118
+ }
119
+
120
+ // Add terminal context if available
121
+ const terminalManager = getTerminalManager();
122
+ if (terminalManager) {
123
+ const terminalContext = terminalManager.getContext();
124
+ if (terminalContext) {
125
+ parts.push(terminalContext);
126
+ components.push('terminal-context');
127
+ }
128
+ }
129
+
130
+ const composed = parts.filter(Boolean).join('\n\n').trim();
131
+ if (composed) {
132
+ debugLog(`[system] pieces: ${dedupeComponents(components).join(', ')}`);
133
+ return {
134
+ prompt: composed,
135
+ components: dedupeComponents(components),
136
+ };
137
+ }
138
+
139
+ const fallback = [
140
+ 'You are a concise, friendly coding agent.',
141
+ 'Be precise and actionable. Use tools when needed, prefer small diffs.',
142
+ 'Stream your answer; call finish when done.',
143
+ ].join(' ');
144
+ return {
145
+ prompt: fallback,
146
+ components: dedupeComponents([...components, 'fallback']),
147
+ };
148
+ }
149
+
150
+ export function getProviderSpoofPrompt(provider: string): string | undefined {
151
+ if (provider === 'anthropic') {
152
+ return (ANTHROPIC_SPOOF_PROMPT || '').trim();
153
+ }
154
+ return undefined;
155
+ }
156
+
157
+ function dedupeComponents(input: string[]): string[] {
158
+ const seen = new Set<string>();
159
+ const out: string[] = [];
160
+ for (const item of input) {
161
+ if (!item) continue;
162
+ if (seen.has(item)) continue;
163
+ seen.add(item);
164
+ out.push(item);
165
+ }
166
+ return out;
167
+ }
@@ -0,0 +1,50 @@
1
+ import type { OttoConfig } from '@ottocode/sdk';
2
+ import {
3
+ getAuth,
4
+ refreshToken,
5
+ setAuth,
6
+ createAnthropicOAuthModel,
7
+ createAnthropicCachingFetch,
8
+ } from '@ottocode/sdk';
9
+ import { createAnthropic } from '@ai-sdk/anthropic';
10
+ import { toClaudeCodeName } from '../tools/mapping.ts';
11
+
12
+ export async function getAnthropicInstance(cfg: OttoConfig) {
13
+ const auth = await getAuth('anthropic', cfg.projectRoot);
14
+
15
+ if (auth?.type === 'oauth') {
16
+ let currentAuth = auth;
17
+
18
+ if (currentAuth.expires < Date.now()) {
19
+ const tokens = await refreshToken(currentAuth.refresh);
20
+ await setAuth(
21
+ 'anthropic',
22
+ {
23
+ type: 'oauth',
24
+ refresh: tokens.refresh,
25
+ access: tokens.access,
26
+ expires: tokens.expires,
27
+ },
28
+ cfg.projectRoot,
29
+ 'global',
30
+ );
31
+ currentAuth = {
32
+ type: 'oauth',
33
+ refresh: tokens.refresh,
34
+ access: tokens.access,
35
+ expires: tokens.expires,
36
+ };
37
+ }
38
+
39
+ return (model: string) =>
40
+ createAnthropicOAuthModel(model, {
41
+ oauth: currentAuth,
42
+ toolNameTransformer: toClaudeCodeName,
43
+ });
44
+ }
45
+
46
+ const cachingFetch = createAnthropicCachingFetch();
47
+ return createAnthropic({
48
+ fetch: cachingFetch as typeof fetch,
49
+ });
50
+ }
@@ -0,0 +1,12 @@
1
+ import { getAuth, createCopilotModel } from '@ottocode/sdk';
2
+ import type { OttoConfig } from '@ottocode/sdk';
3
+
4
+ export async function resolveCopilotModel(model: string, cfg: OttoConfig) {
5
+ const auth = await getAuth('copilot', cfg.projectRoot);
6
+ if (auth?.type === 'oauth') {
7
+ return createCopilotModel(model, { oauth: auth });
8
+ }
9
+ throw new Error(
10
+ 'Copilot provider requires OAuth. Run `otto auth login copilot`.',
11
+ );
12
+ }
@@ -0,0 +1,8 @@
1
+ import type { OttoConfig } from '@ottocode/sdk';
2
+ import { getAuth, createGoogleModel } from '@ottocode/sdk';
3
+
4
+ export async function resolveGoogleModel(model: string, cfg: OttoConfig) {
5
+ const auth = await getAuth('google', cfg.projectRoot);
6
+ const apiKey = auth?.type === 'api' ? auth.key : undefined;
7
+ return createGoogleModel(model, { apiKey });
8
+ }
@@ -0,0 +1,60 @@
1
+ import type { OttoConfig, ProviderId } from '@ottocode/sdk';
2
+ import { getAnthropicInstance } from './anthropic.ts';
3
+ import { resolveOpenAIModel } from './openai.ts';
4
+ import { resolveGoogleModel } from './google.ts';
5
+ import { resolveOpenRouterModel } from './openrouter.ts';
6
+ import { resolveSetuModel, type ResolveSetuModelOptions } from './setu.ts';
7
+ import { getZaiInstance, getZaiCodingInstance } from './zai.ts';
8
+ import { resolveOpencodeModel } from './opencode.ts';
9
+ import { getMoonshotInstance } from './moonshot.ts';
10
+ import { resolveCopilotModel } from './copilot.ts';
11
+
12
+ export type ProviderName = ProviderId;
13
+
14
+ export async function resolveModel(
15
+ provider: ProviderName,
16
+ model: string,
17
+ cfg: OttoConfig,
18
+ options?: {
19
+ systemPrompt?: string;
20
+ sessionId?: string;
21
+ messageId?: string;
22
+ topupApprovalMode?: ResolveSetuModelOptions['topupApprovalMode'];
23
+ },
24
+ ) {
25
+ if (provider === 'openai') {
26
+ return resolveOpenAIModel(model, cfg);
27
+ }
28
+ if (provider === 'anthropic') {
29
+ const instance = await getAnthropicInstance(cfg);
30
+ return instance(model);
31
+ }
32
+ if (provider === 'google') {
33
+ return resolveGoogleModel(model, cfg);
34
+ }
35
+ if (provider === 'openrouter') {
36
+ return resolveOpenRouterModel(model);
37
+ }
38
+ if (provider === 'opencode') {
39
+ return resolveOpencodeModel(model, cfg);
40
+ }
41
+ if (provider === 'copilot') {
42
+ return resolveCopilotModel(model, cfg);
43
+ }
44
+ if (provider === 'setu') {
45
+ return await resolveSetuModel(model, options?.sessionId, {
46
+ messageId: options?.messageId,
47
+ topupApprovalMode: options?.topupApprovalMode,
48
+ });
49
+ }
50
+ if (provider === 'zai') {
51
+ return getZaiInstance(cfg, model);
52
+ }
53
+ if (provider === 'zai-coding') {
54
+ return getZaiCodingInstance(cfg, model);
55
+ }
56
+ if (provider === 'moonshot') {
57
+ return getMoonshotInstance(cfg, model);
58
+ }
59
+ throw new Error(`Unsupported provider: ${provider}`);
60
+ }
@@ -0,0 +1,8 @@
1
+ import type { OttoConfig } from '@ottocode/sdk';
2
+ import { getAuth, createMoonshotModel } from '@ottocode/sdk';
3
+
4
+ export async function getMoonshotInstance(cfg: OttoConfig, model: string) {
5
+ const auth = await getAuth('moonshot', cfg.projectRoot);
6
+ const apiKey = auth?.type === 'api' ? auth.key : undefined;
7
+ return createMoonshotModel(model, { apiKey });
8
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * OAuth Provider Adapter
3
+ *
4
+ * Consolidates all OAuth-specific LLM call adaptations into one place.
5
+ * Each OAuth provider has quirks (no system prompt, must stream, needs
6
+ * spoof prompt, special providerOptions, etc.). Instead of duplicating
7
+ * that branching logic across every callsite (title gen, compaction,
8
+ * commit, runner), this module exposes two layers:
9
+ *
10
+ * ## Layer 1 — Detection (`detectOAuth`)
11
+ * Examines provider + auth and returns an `OAuthContext` describing
12
+ * what adaptations are needed. Used by ALL callsites (simple + complex).
13
+ *
14
+ * ## Layer 2 — Simple call adaptation (`adaptSimpleCall`)
15
+ * For single-shot LLM calls (title gen, compaction, commit) that follow
16
+ * the pattern: system + user message → text result.
17
+ * Returns a ready-to-spread `AdaptedLLMCall` object.
18
+ *
19
+ * ## Adding a new OAuth provider
20
+ * 1. Add detection branch in `detectOAuth()`
21
+ * 2. Add adaptation branch in `adaptSimpleCall()`
22
+ * 3. If the provider needs a custom fetch wrapper, add it under
23
+ * `packages/sdk/src/providers/src/<provider>-oauth-client.ts`
24
+ * 4. Zero changes needed at any callsite.
25
+ *
26
+ * ## Architecture
27
+ *
28
+ * ```
29
+ * callsite (commit.ts, service.ts, compaction-auto.ts, runner-setup.ts)
30
+ * │
31
+ * ├─ detectOAuth(provider, auth) → OAuthContext
32
+ * │
33
+ * ├─ adaptSimpleCall(ctx, input) → AdaptedLLMCall (title, commit, compaction)
34
+ * │
35
+ * └─ adaptRunnerCall(ctx, composed, opts) → AdaptedRunnerSetup (main chat)
36
+ * │
37
+ * ├─ OpenAI OAuth (Codex): no system, inline instructions,
38
+ * │ providerOptions.openai.store=false, forceStream=true
39
+ * │
40
+ * ├─ Anthropic OAuth: spoofPrompt as system, instructions
41
+ * │ folded into user message, normal maxOutputTokens
42
+ * │
43
+ * └─ API key (default): system=instructions, plain user msg
44
+ * ```
45
+ */
46
+ import { getProviderSpoofPrompt } from '../prompt/builder.ts';
47
+ import type { SharedV3ProviderOptions } from '@ai-sdk/provider';
48
+
49
+ export type OAuthContext = {
50
+ isOAuth: boolean;
51
+ needsSpoof: boolean;
52
+ isOpenAIOAuth: boolean;
53
+ spoofPrompt: string | undefined;
54
+ };
55
+
56
+ /**
57
+ * Detect OAuth mode for a provider and return flags describing
58
+ * what adaptations are needed. This replaces the 4-line pattern
59
+ * that was previously copy-pasted at every callsite:
60
+ *
61
+ * const isOAuth = auth?.type === 'oauth';
62
+ * const needsSpoof = isOAuth && provider === 'anthropic';
63
+ * const isOpenAIOAuth = isOAuth && provider === 'openai';
64
+ * const spoofPrompt = needsSpoof ? getProviderSpoofPrompt(...) : undefined;
65
+ */
66
+ export function detectOAuth(
67
+ provider: string,
68
+ auth: { type: string } | null | undefined,
69
+ ): OAuthContext {
70
+ const isOAuth = auth?.type === 'oauth';
71
+ const needsSpoof = !!isOAuth && provider === 'anthropic';
72
+ const isCopilot = provider === 'copilot';
73
+ return {
74
+ isOAuth: !!isOAuth || isCopilot,
75
+ needsSpoof,
76
+ isOpenAIOAuth: (!!isOAuth && provider === 'openai') || isCopilot,
77
+ spoofPrompt: needsSpoof ? getProviderSpoofPrompt(provider) : undefined,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Build OpenAI Codex-specific providerOptions.
83
+ * Codex requires `store: false` and passes the system prompt via
84
+ * `instructions` instead of the normal `system` field.
85
+ *
86
+ * Used directly by runner-setup.ts (complex flow) and indirectly
87
+ * by adaptSimpleCall (simple flows).
88
+ */
89
+ export function buildCodexProviderOptions(instructions: string) {
90
+ return {
91
+ openai: { store: false as const, instructions },
92
+ };
93
+ }
94
+
95
+ export type AdaptedLLMCall = {
96
+ system?: string;
97
+ messages: Array<{ role: 'user'; content: string }>;
98
+ maxOutputTokens?: number;
99
+ providerOptions?: SharedV3ProviderOptions;
100
+ forceStream: boolean;
101
+ };
102
+
103
+ /**
104
+ * Adapt a simple (single-shot) LLM call for the current OAuth context.
105
+ *
106
+ * Takes raw `instructions` (what would normally be the system prompt) and
107
+ * `userContent`, then returns the correct shape for the provider:
108
+ *
109
+ * - **OpenAI OAuth (Codex)**: no system prompt, instructions baked into
110
+ * user message AND providerOptions.openai.instructions, forceStream=true,
111
+ * no maxOutputTokens (Codex doesn't support it).
112
+ *
113
+ * - **Anthropic OAuth**: spoof prompt as system, real instructions folded
114
+ * into user message, normal maxOutputTokens.
115
+ *
116
+ * - **API key (default)**: instructions as system, plain user message,
117
+ * normal maxOutputTokens.
118
+ *
119
+ * Callsites just spread the result into streamText/generateText:
120
+ * ```ts
121
+ * const adapted = adaptSimpleCall(oauth, { instructions, userContent });
122
+ * const result = streamText({ model, ...adapted }); // almost — see forceStream
123
+ * ```
124
+ */
125
+ export function adaptSimpleCall(
126
+ ctx: OAuthContext,
127
+ input: {
128
+ instructions: string;
129
+ userContent: string;
130
+ maxOutputTokens?: number;
131
+ },
132
+ ): AdaptedLLMCall {
133
+ if (ctx.isOpenAIOAuth) {
134
+ return {
135
+ messages: [
136
+ {
137
+ role: 'user',
138
+ content: `${input.instructions}\n\n${input.userContent}`,
139
+ },
140
+ ],
141
+ providerOptions: buildCodexProviderOptions(input.instructions),
142
+ forceStream: true,
143
+ };
144
+ }
145
+
146
+ if (ctx.needsSpoof && ctx.spoofPrompt) {
147
+ return {
148
+ system: ctx.spoofPrompt,
149
+ messages: [
150
+ {
151
+ role: 'user',
152
+ content: `${input.instructions}\n\n${input.userContent}`,
153
+ },
154
+ ],
155
+ maxOutputTokens: input.maxOutputTokens,
156
+ forceStream: false,
157
+ };
158
+ }
159
+
160
+ return {
161
+ system: input.instructions,
162
+ messages: [{ role: 'user', content: input.userContent }],
163
+ maxOutputTokens: input.maxOutputTokens,
164
+ forceStream: false,
165
+ };
166
+ }
167
+
168
+ export type AdaptedRunnerSetup = {
169
+ system: string;
170
+ systemComponents: string[];
171
+ additionalSystemMessages: Array<{
172
+ role: 'system' | 'user';
173
+ content: string;
174
+ }>;
175
+ maxOutputTokens: number | undefined;
176
+ providerOptions: SharedV3ProviderOptions;
177
+ };
178
+
179
+ /**
180
+ * Adapt the main chat runner's system prompt placement, maxOutputTokens,
181
+ * and providerOptions based on the OAuth context.
182
+ *
183
+ * Unlike `adaptSimpleCall` (which builds the full message), this only
184
+ * decides WHERE the already-composed system prompt goes:
185
+ *
186
+ * - **OpenAI OAuth (Codex)**: system='', composed prompt sent as a user
187
+ * message in additionalSystemMessages, providerOptions with store=false
188
+ * + instructions, maxOutputTokens stripped.
189
+ *
190
+ * - **Anthropic OAuth**: spoof prompt as system, composed prompt sent as
191
+ * an additional system message. Normal maxOutputTokens.
192
+ *
193
+ * - **API key (default)**: composed prompt IS the system prompt directly.
194
+ * No additional messages needed.
195
+ *
196
+ * ```ts
197
+ * const composed = await composeSystemPrompt({ ... });
198
+ * const adapted = adaptRunnerCall(oauth, composed, { provider, rawMaxOutputTokens });
199
+ * // adapted.system, adapted.additionalSystemMessages, adapted.providerOptions ready to use
200
+ * ```
201
+ */
202
+ export function adaptRunnerCall(
203
+ ctx: OAuthContext,
204
+ composed: { prompt: string; components: string[] },
205
+ opts: {
206
+ provider: string;
207
+ rawMaxOutputTokens: number | undefined;
208
+ },
209
+ ): AdaptedRunnerSetup {
210
+ if (ctx.spoofPrompt) {
211
+ return {
212
+ system: ctx.spoofPrompt,
213
+ systemComponents: [`spoof:${opts.provider || 'unknown'}`],
214
+ additionalSystemMessages: [{ role: 'system', content: composed.prompt }],
215
+ maxOutputTokens: opts.rawMaxOutputTokens,
216
+ providerOptions: {},
217
+ };
218
+ }
219
+
220
+ if (ctx.isOpenAIOAuth) {
221
+ return {
222
+ system: '',
223
+ systemComponents: composed.components,
224
+ additionalSystemMessages: [{ role: 'user', content: composed.prompt }],
225
+ maxOutputTokens: undefined,
226
+ providerOptions: buildCodexProviderOptions(composed.prompt),
227
+ };
228
+ }
229
+
230
+ return {
231
+ system: composed.prompt,
232
+ systemComponents: composed.components,
233
+ additionalSystemMessages: [],
234
+ maxOutputTokens: opts.rawMaxOutputTokens,
235
+ providerOptions: {},
236
+ };
237
+ }
@@ -0,0 +1,18 @@
1
+ import type { OttoConfig } from '@ottocode/sdk';
2
+ import { getAuth, createOpenAIOAuthModel } from '@ottocode/sdk';
3
+ import { openai, createOpenAI } from '@ai-sdk/openai';
4
+
5
+ export async function resolveOpenAIModel(model: string, cfg: OttoConfig) {
6
+ const auth = await getAuth('openai', cfg.projectRoot);
7
+ if (auth?.type === 'oauth') {
8
+ return createOpenAIOAuthModel(model, {
9
+ oauth: auth,
10
+ projectRoot: cfg.projectRoot,
11
+ });
12
+ }
13
+ if (auth?.type === 'api' && auth.key) {
14
+ const instance = createOpenAI({ apiKey: auth.key });
15
+ return instance(model);
16
+ }
17
+ return openai(model);
18
+ }
@@ -0,0 +1,7 @@
1
+ import type { OttoConfig } from '@ottocode/sdk';
2
+ import { createOpencodeModel } from '@ottocode/sdk';
3
+
4
+ export function resolveOpencodeModel(model: string, _cfg: OttoConfig) {
5
+ const apiKey = process.env.OPENCODE_API_KEY;
6
+ return createOpencodeModel(model, { apiKey });
7
+ }
@@ -0,0 +1,7 @@
1
+ import { getOpenRouterInstance, createOpenRouterModel } from '@ottocode/sdk';
2
+
3
+ export { getOpenRouterInstance };
4
+
5
+ export function resolveOpenRouterModel(model: string) {
6
+ return createOpenRouterModel(model);
7
+ }
@@ -0,0 +1,118 @@
1
+ import type { OttoConfig } from '@ottocode/sdk';
2
+ import {
3
+ catalog,
4
+ type ProviderId,
5
+ isProviderAuthorized,
6
+ providerIds,
7
+ defaultModelFor,
8
+ hasModel,
9
+ } from '@ottocode/sdk';
10
+
11
+ const FALLBACK_ORDER: ProviderId[] = [
12
+ 'anthropic',
13
+ 'openai',
14
+ 'google',
15
+ 'opencode',
16
+ 'openrouter',
17
+ 'setu',
18
+ ];
19
+
20
+ type SelectionInput = {
21
+ cfg: OttoConfig;
22
+ agentProviderDefault: ProviderId;
23
+ agentModelDefault: string;
24
+ explicitProvider?: ProviderId;
25
+ explicitModel?: string;
26
+ skipAuth?: boolean;
27
+ };
28
+
29
+ export type ProviderSelection = {
30
+ provider: ProviderId;
31
+ model: string;
32
+ providerOverride?: ProviderId;
33
+ modelOverride?: string;
34
+ };
35
+
36
+ export async function selectProviderAndModel(
37
+ input: SelectionInput,
38
+ ): Promise<ProviderSelection> {
39
+ const {
40
+ cfg,
41
+ agentProviderDefault,
42
+ agentModelDefault,
43
+ explicitProvider,
44
+ explicitModel,
45
+ skipAuth,
46
+ } = input;
47
+
48
+ const provider = skipAuth
49
+ ? (explicitProvider ?? agentProviderDefault)
50
+ : await pickAuthorizedProvider({
51
+ cfg,
52
+ candidate: explicitProvider ?? agentProviderDefault,
53
+ });
54
+
55
+ if (!provider) {
56
+ throw new Error(
57
+ 'No authorized providers found. Run `otto auth login` to configure at least one provider.',
58
+ );
59
+ }
60
+
61
+ const model = resolveModelForProvider({
62
+ provider,
63
+ explicitModel,
64
+ agentModelDefault,
65
+ });
66
+
67
+ const providerOverride =
68
+ explicitProvider ??
69
+ (provider !== agentProviderDefault ? provider : undefined);
70
+ const modelOverride =
71
+ explicitModel ?? (model !== agentModelDefault ? model : undefined);
72
+
73
+ return { provider, model, providerOverride, modelOverride };
74
+ }
75
+
76
+ async function pickAuthorizedProvider(args: {
77
+ cfg: OttoConfig;
78
+ candidate: ProviderId;
79
+ }): Promise<ProviderId | undefined> {
80
+ const { cfg, candidate } = args;
81
+ const candidates = uniqueProviders([
82
+ candidate,
83
+ ...FALLBACK_ORDER,
84
+ ...providerIds,
85
+ ]);
86
+ for (const provider of candidates) {
87
+ const ok = await isProviderAuthorized(cfg, provider);
88
+ if (ok) return provider;
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ function uniqueProviders(list: ProviderId[]): ProviderId[] {
94
+ const seen = new Set<ProviderId>();
95
+ const ordered: ProviderId[] = [];
96
+ for (const provider of list) {
97
+ if (!providerIds.includes(provider)) continue;
98
+ if (seen.has(provider)) continue;
99
+ seen.add(provider);
100
+ ordered.push(provider);
101
+ }
102
+ return ordered;
103
+ }
104
+
105
+ function resolveModelForProvider(args: {
106
+ provider: ProviderId;
107
+ explicitModel?: string;
108
+ agentModelDefault: string;
109
+ }): 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
+ );
118
+ }