File size: 9,135 Bytes
1dbc34b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
/**
 * Model resolution utilities for handling model string mapping
 *
 * Provides centralized model resolution logic:
 * - Maps Claude model aliases to full model strings
 * - Passes through Cursor models unchanged (handled by CursorProvider)
 * - Passes through Copilot models unchanged (handled by CopilotProvider)
 * - Passes through Gemini models unchanged (handled by GeminiProvider)
 * - Provides default models per provider
 * - Handles multiple model sources with priority
 *
 * With canonical model IDs:
 * - Cursor: cursor-auto, cursor-composer-1, cursor-gpt-5.2
 * - OpenCode: opencode-big-pickle, opencode-kimi-k2.5-free
 * - Copilot: copilot-gpt-5.1, copilot-claude-sonnet-4.5, copilot-gemini-3-pro-preview
 * - Gemini: gemini-2.5-flash, gemini-2.5-pro
 * - Claude: claude-haiku, claude-sonnet, claude-opus (also supports legacy aliases)
 */

import {
  CLAUDE_MODEL_MAP,
  CLAUDE_CANONICAL_MAP,
  CURSOR_MODEL_MAP,
  CODEX_MODEL_MAP,
  DEFAULT_MODELS,
  PROVIDER_PREFIXES,
  isCursorModel,
  isOpencodeModel,
  isCopilotModel,
  isGeminiModel,
  stripProviderPrefix,
  migrateModelId,
  type PhaseModelEntry,
  type ThinkingLevel,
  type ReasoningEffort,
} from '@automaker/types';

// Pattern definitions for Codex/OpenAI models
const CODEX_MODEL_PREFIXES = ['codex-', 'gpt-'];
const OPENAI_O_SERIES_PATTERN = /^o\d/;
const OPENAI_O_SERIES_ALLOWED_MODELS = new Set<string>();

/**
 * Resolve a model key/alias to a full model string
 *
 * Handles both canonical prefixed IDs and legacy aliases:
 * - Canonical: cursor-auto, cursor-gpt-5.2, opencode-big-pickle, claude-sonnet
 * - Legacy: auto, composer-1, sonnet, opus
 *
 * @param modelKey - Model key (e.g., "claude-opus", "cursor-composer-1", "sonnet")
 * @param defaultModel - Fallback model if modelKey is undefined
 * @returns Full model string
 */
export function resolveModelString(
  modelKey?: string,
  defaultModel: string = DEFAULT_MODELS.claude
): string {
  console.log(
    `[ModelResolver] resolveModelString called with modelKey: "${modelKey}", defaultModel: "${defaultModel}"`
  );

  // No model specified - use default
  if (!modelKey) {
    console.log(`[ModelResolver] No model specified, using default: ${defaultModel}`);
    return defaultModel;
  }

  // First, migrate legacy IDs to canonical format
  const canonicalKey = migrateModelId(modelKey);
  if (canonicalKey !== modelKey) {
    console.log(`[ModelResolver] Migrated legacy ID: "${modelKey}" -> "${canonicalKey}"`);
  }

  // Cursor model with explicit prefix (e.g., "cursor-auto", "cursor-composer-1")
  // Pass through unchanged - provider will extract bare ID for CLI
  if (canonicalKey.startsWith(PROVIDER_PREFIXES.cursor)) {
    console.log(`[ModelResolver] Using Cursor model: ${canonicalKey}`);
    return canonicalKey;
  }

  // Codex model with explicit prefix (e.g., "codex-gpt-5.1-codex-max")
  if (canonicalKey.startsWith(PROVIDER_PREFIXES.codex)) {
    console.log(`[ModelResolver] Using Codex model: ${canonicalKey}`);
    return canonicalKey;
  }

  // OpenCode model (static with opencode- prefix or dynamic with provider/model format)
  if (isOpencodeModel(canonicalKey)) {
    console.log(`[ModelResolver] Using OpenCode model: ${canonicalKey}`);
    return canonicalKey;
  }

  // Copilot model with explicit prefix (e.g., "copilot-gpt-5.1", "copilot-claude-sonnet-4.5")
  if (isCopilotModel(canonicalKey)) {
    console.log(`[ModelResolver] Using Copilot model: ${canonicalKey}`);
    return canonicalKey;
  }

  // Gemini model with explicit prefix (e.g., "gemini-2.5-flash", "gemini-2.5-pro")
  if (isGeminiModel(canonicalKey)) {
    console.log(`[ModelResolver] Using Gemini model: ${canonicalKey}`);
    return canonicalKey;
  }

  // Claude canonical ID (claude-haiku, claude-sonnet, claude-opus)
  // Map to full model string
  if (canonicalKey in CLAUDE_CANONICAL_MAP) {
    const resolved = CLAUDE_CANONICAL_MAP[canonicalKey as keyof typeof CLAUDE_CANONICAL_MAP];
    console.log(`[ModelResolver] Resolved Claude canonical ID: "${canonicalKey}" -> "${resolved}"`);
    return resolved;
  }

  // Full Claude model string (e.g., claude-sonnet-4-6) - pass through
  if (canonicalKey.includes('claude-')) {
    console.log(`[ModelResolver] Using full Claude model string: ${canonicalKey}`);
    return canonicalKey;
  }

  // Legacy Claude model alias (sonnet, opus, haiku) - support for backward compatibility
  const resolved = CLAUDE_MODEL_MAP[canonicalKey];
  if (resolved) {
    console.log(`[ModelResolver] Resolved Claude legacy alias: "${canonicalKey}" -> "${resolved}"`);
    return resolved;
  }

  // OpenAI/Codex models - check for gpt- prefix
  if (
    CODEX_MODEL_PREFIXES.some((prefix) => canonicalKey.startsWith(prefix)) ||
    (OPENAI_O_SERIES_PATTERN.test(canonicalKey) && OPENAI_O_SERIES_ALLOWED_MODELS.has(canonicalKey))
  ) {
    console.log(`[ModelResolver] Using OpenAI/Codex model: ${canonicalKey}`);
    return canonicalKey;
  }

  // Unknown model key - pass through as-is (could be a provider model like GLM-4.7, MiniMax-M2.1)
  // This allows ClaudeCompatibleProvider models to work without being registered here
  console.log(
    `[ModelResolver] Unknown model key "${canonicalKey}", passing through unchanged (may be a provider model)`
  );
  return canonicalKey;
}

/**
 * Get the effective model from multiple sources
 * Priority: explicit model > session model > default
 *
 * @param explicitModel - Explicitly provided model (highest priority)
 * @param sessionModel - Model from session (medium priority)
 * @param defaultModel - Fallback default model (lowest priority)
 * @returns Resolved model string
 */
export function getEffectiveModel(
  explicitModel?: string,
  sessionModel?: string,
  defaultModel?: string
): string {
  return resolveModelString(explicitModel || sessionModel, defaultModel);
}

/**
 * Result of resolving a phase model entry
 */
export interface ResolvedPhaseModel {
  /** Resolved model string (full model ID) */
  model: string;
  /** Optional thinking level for extended thinking (Claude models) */
  thinkingLevel?: ThinkingLevel;
  /** Optional reasoning effort for timeout calculation (Codex models) */
  reasoningEffort?: ReasoningEffort;
  /** Provider ID if using a ClaudeCompatibleProvider */
  providerId?: string;
}

/**
 * Resolve a phase model entry to a model string and thinking level
 *
 * Handles both legacy format (string) and new format (PhaseModelEntry object).
 * This centralizes the pattern used across phase model routes.
 *
 * @param phaseModel - Phase model entry (string or PhaseModelEntry object)
 * @param defaultModel - Fallback model if resolution fails
 * @returns Resolved model string and optional thinking level
 *
 * @remarks
 * - For Cursor models, `thinkingLevel` is returned as `undefined` since Cursor
 *   handles thinking internally via model variants (e.g., 'claude-sonnet-4-thinking')
 * - Defensively handles null/undefined from corrupted settings JSON
 *
 * @example
 * ```ts
 * const phaseModel = settings?.phaseModels?.enhancementModel || DEFAULT_PHASE_MODELS.enhancementModel;
 * const { model, thinkingLevel } = resolvePhaseModel(phaseModel);
 * ```
 */
export function resolvePhaseModel(
  phaseModel: string | PhaseModelEntry | null | undefined,
  defaultModel: string = DEFAULT_MODELS.claude
): ResolvedPhaseModel {
  console.log(
    `[ModelResolver] resolvePhaseModel called with:`,
    JSON.stringify(phaseModel),
    `type: ${typeof phaseModel}`
  );

  // Handle null/undefined (defensive against corrupted JSON)
  if (!phaseModel) {
    console.log(`[ModelResolver] phaseModel is null/undefined, using default`);
    return {
      model: resolveModelString(undefined, defaultModel),
      thinkingLevel: undefined,
      reasoningEffort: undefined,
    };
  }

  // Handle legacy string format
  if (typeof phaseModel === 'string') {
    console.log(`[ModelResolver] phaseModel is string format (legacy): "${phaseModel}"`);
    return {
      model: resolveModelString(phaseModel, defaultModel),
      thinkingLevel: undefined,
      reasoningEffort: undefined,
    };
  }

  // Handle new PhaseModelEntry object format
  console.log(
    `[ModelResolver] phaseModel is object format: model="${phaseModel.model}", thinkingLevel="${phaseModel.thinkingLevel}", reasoningEffort="${phaseModel.reasoningEffort}", providerId="${phaseModel.providerId}"`
  );

  // If providerId is set, pass through the model string unchanged
  // (it's a provider-specific model ID like "GLM-4.5-Air", not a Claude alias)
  if (phaseModel.providerId) {
    console.log(
      `[ModelResolver] Using provider model: providerId="${phaseModel.providerId}", model="${phaseModel.model}"`
    );
    return {
      model: phaseModel.model, // Pass through unchanged
      thinkingLevel: phaseModel.thinkingLevel,
      reasoningEffort: phaseModel.reasoningEffort,
      providerId: phaseModel.providerId,
    };
  }

  // No providerId - resolve through normal Claude model mapping
  return {
    model: resolveModelString(phaseModel.model, defaultModel),
    thinkingLevel: phaseModel.thinkingLevel,
    reasoningEffort: phaseModel.reasoningEffort,
  };
}