Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 105 additions & 11 deletions src/routes/api/title-generator.ts
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,
Expand All @@ -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 '';
Expand All @@ -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) {

Copy link
Copy Markdown

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 returns New Conversation. Add a direct OpenAI branch or include that key in the documented provider selection.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/routes/api/title-generator.ts, line 48:

<comment>When a self-host is configured with only `OPENAI_API_KEY`, this selector skips it and returns `New Conversation`. Add a direct OpenAI branch or include that key in the documented provider selection.</comment>

<file context>
@@ -25,6 +26,78 @@ function textFromParts(parts: unknown): string {
+    });
+  }
+
+  if (process.env.OPENROUTER_API_KEY) {
+    const response = await fetch(
+      'https://openrouter.ai/api/v1/chat/completions',
</file context>

return openRouterTitle(text);
}

if (process.env.GOOGLE_API_KEY) {
return googleTitle(text);
}

return null;
}
Comment on lines +107 to +126

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Provider Fallbacks Lack Tests

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: {
Expand All @@ -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' });
}
Expand Down