diff --git a/frontend/src/pages/ChatView.tsx b/frontend/src/pages/ChatView.tsx index 3d191e42..45a0a05c 100644 --- a/frontend/src/pages/ChatView.tsx +++ b/frontend/src/pages/ChatView.tsx @@ -223,9 +223,11 @@ export function ChatView() { onChange={(e) => setModel(e.target.value)} disabled={messages.running} > + + diff --git a/frontend/src/pages/DesktopChatView.tsx b/frontend/src/pages/DesktopChatView.tsx index 1e85533b..a201ef1e 100644 --- a/frontend/src/pages/DesktopChatView.tsx +++ b/frontend/src/pages/DesktopChatView.tsx @@ -198,9 +198,11 @@ export function DesktopChatView() { onChange={(e) => setModel(e.target.value)} disabled={messages.running} > + + diff --git a/packages/harness/src/providers/anthropic-vertex.ts b/packages/harness/src/providers/anthropic-vertex.ts index 17066abe..ef517961 100644 --- a/packages/harness/src/providers/anthropic-vertex.ts +++ b/packages/harness/src/providers/anthropic-vertex.ts @@ -16,6 +16,7 @@ const log = createLogger('provider:anthropic'); /** Vertex AI model name mapping (Vertex uses different naming). */ const VERTEX_MODEL_MAP: Record = { 'claude-opus-4-6': 'claude-opus-4-6@20250514', + 'claude-sonnet-5': 'claude-sonnet-5', 'claude-sonnet-4-6': 'claude-sonnet-4-6@20250514', 'claude-haiku-4-5': 'claude-3-5-haiku@20241022', }; diff --git a/server/__tests__/model-spec.test.ts b/server/__tests__/model-spec.test.ts index 095ec4e5..7473cd0b 100644 --- a/server/__tests__/model-spec.test.ts +++ b/server/__tests__/model-spec.test.ts @@ -62,4 +62,15 @@ describe('resolveThinking', () => { it('returns undefined for haiku', () => { expect(resolveThinking('claude-haiku-4-5')).toBeUndefined(); }); + + it('returns undefined for fable (always-on thinking, must not pass disabled)', () => { + expect(resolveThinking('claude-fable-5')).toBeUndefined(); + }); + + it('returns 10k budget for sonnet 5', () => { + expect(resolveThinking('claude-sonnet-5')).toEqual({ + type: 'enabled', + budgetTokens: 10_000, + }); + }); }); diff --git a/server/chat.ts b/server/chat.ts index 50579b77..3d668ae6 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -365,10 +365,14 @@ export function getRepoConfig() { return _cachedConfig; } +export const FALLBACK_MODEL = 'claude-opus-4-6'; + export const AVAILABLE_MODELS = [ + { id: 'claude-fable-5', label: 'Fable 5', desc: 'Most capable (direct API only)' }, { id: 'claude-opus-4-7', label: 'Opus 4.7', desc: 'Adaptive thinking' }, { id: 'claude-opus-4-7:max', label: 'Opus 4.7 Max', desc: 'Max thinking (128k)' }, { id: 'claude-opus-4-6', label: 'Opus 4.6', desc: 'Previous Opus' }, + { id: 'claude-sonnet-5', label: 'Sonnet 5', desc: 'Latest Sonnet' }, { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', desc: 'Balanced' }, { id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Fastest' }, ]; @@ -437,6 +441,8 @@ export function resolveThinking( spec?: string, ): { type: 'adaptive' } | { type: 'enabled'; budgetTokens: number } | undefined { const { model, effort } = parseModelSpec(spec); + // Fable: thinking is always on, omit the param to let it default (explicit 'disabled' returns 400) + if (model.includes('fable')) return undefined; if (model.includes('opus') && effort === 'max') return { type: 'enabled', budgetTokens: 128_000 }; if (!model || model.includes('opus')) return { type: 'adaptive' }; if (model.includes('sonnet')) return { type: 'enabled', budgetTokens: 10_000 }; @@ -746,7 +752,7 @@ export async function startChat( telosTaskId?: string; agentName?: string; }, -) { +): Promise { return withSpanAsync( 'chat.start', { @@ -1082,12 +1088,37 @@ async function _startChatInner( ); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Unknown error'; + const isModelError = + message.includes('not found') || + message.includes('404') || + message.includes('not have access') || + message.includes('disallowed'); + const requestedModel = parseModelSpec(options.model).model; + const canFallback = isModelError && requestedModel && requestedModel !== FALLBACK_MODEL; + if (message.includes('No conversation found') && options.resume) { log.warn('SDK rejected resume, session expired', { sessionId: options.resume, cwd }); send(transport, { type: 'error', error: 'Session expired. Send your message again to start fresh.', }); + } else if (canFallback) { + log.warn('Model unavailable, falling back', { + requested: requestedModel, + fallback: FALLBACK_MODEL, + error: message, + }); + send(transport, { + type: 'system', + text: `Model "${requestedModel}" unavailable, falling back to ${FALLBACK_MODEL}.`, + }); + const failedSession = registry.get(clientId); + if (failedSession) cleanupSessionWorktrees(failedSession); + registry.abort(clientId); + return startChat(transport, clientId, prompt, { + ...options, + model: FALLBACK_MODEL, + }); } else { log.error('startChat failed after register, cleaning up', { clientId, error: message }); send(transport, { type: 'error', error: message });