-
Notifications
You must be signed in to change notification settings - Fork 642
fix(title): fall back to configured provider for title generation #263
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import { createFileRoute } from '@tanstack/react-router'; | ||
|
|
||
| import { createAnthropicText } from '@/server/anthropic'; | ||
| import { | ||
| isRecord, | ||
|
|
@@ -10,7 +11,9 @@ import { | |
| } from '@/server/api'; | ||
|
|
||
| const TITLE_SYSTEM_PROMPT = | ||
| 'Generate a concise, descriptive title under 80 characters for this CAD conversation. Return only the title. If unclear, return "New Conversation".'; | ||
| 'Generate a concise, descriptive title under 80 characters for this ' + | ||
| 'CAD conversation. Return only the title. If unclear, return ' + | ||
| '"New Conversation".'; | ||
|
|
||
| function textFromParts(parts: unknown): string { | ||
| if (!Array.isArray(parts)) return ''; | ||
|
|
@@ -25,6 +28,103 @@ function textFromParts(parts: unknown): string { | |
| .trim(); | ||
| } | ||
|
|
||
| function textFromChatCompletion(data: unknown): string | null { | ||
| if (!isRecord(data)) return null; | ||
| const choices = data.choices; | ||
| if (!Array.isArray(choices)) return null; | ||
| const first: unknown = choices[0]; | ||
| const message = isRecord(first) ? first.message : null; | ||
| const content = isRecord(message) ? message.content : null; | ||
| return typeof content === 'string' ? content : null; | ||
| } | ||
|
|
||
| function textFromGemini(data: unknown): string | null { | ||
| if (!isRecord(data)) return null; | ||
| const candidates = data.candidates; | ||
| if (!Array.isArray(candidates)) return null; | ||
| const first: unknown = candidates[0]; | ||
| const content = isRecord(first) ? first.content : null; | ||
| const parts = isRecord(content) ? content.parts : null; | ||
| if (!Array.isArray(parts)) return null; | ||
| const part: unknown = parts[0]; | ||
| const value = isRecord(part) ? part.text : null; | ||
| return typeof value === 'string' ? value : null; | ||
| } | ||
|
|
||
| async function openRouterTitle(text: string): Promise<string | null> { | ||
| const response = await fetch( | ||
| 'https://openrouter.ai/api/v1/chat/completions', | ||
| { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, | ||
| }, | ||
| body: JSON.stringify({ | ||
| model: 'openai/gpt-5.6-sol', | ||
| max_tokens: 100, | ||
| messages: [ | ||
| { role: 'system', content: TITLE_SYSTEM_PROMPT }, | ||
| { role: 'user', content: text }, | ||
| ], | ||
| }), | ||
| }, | ||
| ); | ||
| if (!response.ok) return null; | ||
| return textFromChatCompletion(await response.json()); | ||
| } | ||
|
|
||
| async function googleTitle(text: string): Promise<string | null> { | ||
| const url = | ||
| 'https://generativelanguage.googleapis.com/v1beta/models/' + | ||
| 'gemini-3.8-flash:generateContent'; | ||
| const response = await fetch(url, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'x-goog-api-key': process.env.GOOGLE_API_KEY ?? '', | ||
| }, | ||
| body: JSON.stringify({ | ||
| systemInstruction: { parts: [{ text: TITLE_SYSTEM_PROMPT }] }, | ||
| contents: [{ role: 'user', parts: [{ text }] }], | ||
| generationConfig: { maxOutputTokens: 100 }, | ||
| }), | ||
| }); | ||
| if (!response.ok) return null; | ||
| return textFromGemini(await response.json()); | ||
| } | ||
|
|
||
| /** | ||
| * Generate a title with whichever provider is configured. | ||
| * | ||
| * Anthropic stays the default (unchanged for the hosted app). A self-hosted | ||
| * instance configured only with an OpenRouter or Google key would otherwise | ||
| * get every conversation stuck on "New Conversation", since the previous | ||
| * implementation always called Anthropic. Fall back to those providers — the | ||
| * same set the chat supports, where OpenAI models are served through | ||
| * OpenRouter — when no Anthropic key is set. Returns null when none is set. | ||
| */ | ||
| async function generateTitleText(text: string): Promise<string | null> { | ||
| if (process.env.ANTHROPIC_API_KEY) { | ||
| return createAnthropicText({ | ||
| model: 'claude-haiku-4-5-20251001', | ||
| maxTokens: 100, | ||
| system: TITLE_SYSTEM_PROMPT, | ||
| content: text, | ||
| }); | ||
| } | ||
|
|
||
| if (process.env.OPENROUTER_API_KEY) { | ||
| return openRouterTitle(text); | ||
| } | ||
|
|
||
| if (process.env.GOOGLE_API_KEY) { | ||
| return googleTitle(text); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
Comment on lines
+107
to
+126
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new provider selection, request, error, and response-parsing branches have no tests covering each key configuration and returned title. Because failures are deliberately converted into “New Conversation,” a broken request shape or routing regression would look exactly like the original bug. Add mocked tests that assert the actual title for Anthropic, OpenRouter, Google, and no-provider configurations. |
||
|
|
||
| export const Route = createFileRoute('/api/title-generator')({ | ||
| server: { | ||
| handlers: { | ||
|
|
@@ -39,23 +139,17 @@ export const Route = createFileRoute('/api/title-generator')({ | |
| } | ||
| throw err; | ||
| } | ||
|
|
||
| try { | ||
| const body: unknown = await request.json(); | ||
| if (!isRecord(body)) { | ||
| return json({ title: 'New Conversation' }); | ||
| } | ||
| if (!isRecord(body)) return json({ title: 'New Conversation' }); | ||
| const trimmedText = | ||
| typeof body.text === 'string' ? body.text.trim() : ''; | ||
| const text = trimmedText || textFromParts(body.parts); | ||
| if (!text) return json({ title: 'New Conversation' }); | ||
|
|
||
| const title = await createAnthropicText({ | ||
| model: 'claude-haiku-4-5-20251001', | ||
| maxTokens: 100, | ||
| system: TITLE_SYSTEM_PROMPT, | ||
| content: text, | ||
| }); | ||
| return json({ title: title || 'New Conversation' }); | ||
| const title = await generateTitleText(text); | ||
| return json({ title: title?.trim() || 'New Conversation' }); | ||
| } catch { | ||
| return json({ title: 'New Conversation' }); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: When a self-host is configured with only
OPENAI_API_KEY, this selector skips it and returnsNew Conversation. Add a direct OpenAI branch or include that key in the documented provider selection.Prompt for AI agents