Skip to content

Commit b6ed437

Browse files
authored
fix(chat): route fallback models through chat completions (#5121)
1 parent c638d9b commit b6ed437

5 files changed

Lines changed: 921 additions & 13 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ dumps/**/*.dump.gpg
4646
# Temporary files
4747
*.tmp
4848
*.log
49+
NOCOMMIT*
50+
20??-??-??_chatbot*.csv
51+
output/
4952

5053
# Environment-specific files
5154
.env.*

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ Without Traefik, use `http://localhost:<port>` directly. The `*.klicker.com` dom
215215
- **Assistant UI chat drop targets**: `ComposerPrimitive.AttachmentDropzone` must wrap both normal and edit chat composer roots; it owns the drag/drop capture handlers that prevent native browser file navigation, while Klicker-specific limits stay in local composer code. (`apps/chat/src/components/thread.tsx`)
216216
- **Deployed chat model registries default attachment support off**: `apps/chat` loads `CHAT_MODEL_REGISTRY_JSON` via `chatModelRegistry.ts`, where omitted `supportsImageAttachments` values default to `false`; if deployment values override the built-in registry, each image-capable model must set the flag explicitly in `deploy/env-uzh-*/values.yaml` or the attach button disappears.
217217
- **OpenAI Responses storage and tool calls**: GPT-5.5 via the OpenAI Responses API can reference prior response items across tool-call steps; with `store: false`, LiteLLM/Azure can return "item not found" for those references. Keep `CHAT_OPENAI_STORE_RESPONSES=true` in shared/staged deployments that use Responses-compatible OpenAI backends, while local OpenRouter-style setups can leave it false. (`apps/chat/src/app/api/chatbots/[chatbotId]/chat/route.ts`, `deploy/env-uzh-*/values.yaml`)
218+
- **Chat fallback allow-lists**: Zero-credit course chatbots require a usable fallback model (`gpt-4.1-mini` / `CHAT_FALLBACK_MODEL_ID`) and explicit chatbot `allowedModelIds` must include it. Use `packages/prisma-data/src/scripts/2026-06-15_ensure_chatbot_fallback_model.ts` to audit or fix allow-lists. (`apps/chat/src/lib/server/chatModelRegistry.ts`)
218219
- **Embedded PWA messaging trust boundary**: For embedded PWA pages, use a parent-initiated `postMessage` handshake to capture `event.origin` and avoid `'*'` target origins; do not add a second per-platform messaging allowlist in page code. Embedding permission remains enforced separately by ingress `frame-ancestors`. (`apps/frontend-pwa/src/pages/course/[courseId]/practiceQuizzes/[id].tsx`, `deploy/charts/klicker-uzh-v3/templates/ingress-frontend-pwa.yaml`)
219220
- **Local embed harness target**: `util/embed-harness/` is for local verification only and should target the branch-local PWA (`http://127.0.0.1:3101/...`), not `https://pwa.klicker.com/...`, because production CSP / `frame-ancestors` blocks localhost embedding. (`util/embed-harness/`)
220221
- **Chat PWA login redirects**: `apps/chat/src/app/noLogin/page.tsx` must pass an absolute chat URL to the PWA login `redirect_to`; a relative chatbot path makes the PWA redirect to its own domain and 404. Local chat dev also needs ignored local env values for the backend `APP_SECRET` and `DATABASE_URL` so participant cookies verify and Prisma can load chatbot data.

apps/chat/src/app/api/chatbots/[chatbotId]/chat/route.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
getAllowedReasoningEffortsForModel,
44
getAutomaticModelId,
55
getChatModelRegistry,
6+
type ChatModelConfig,
67
} from '@/src/lib/server/chatModelRegistry'
78
import { ensureImagePreviewBase64 } from '@/src/lib/server/imagePreview'
89
import { getOpenAIResponsesStore } from '@/src/lib/server/openaiResponsesOptions'
@@ -101,7 +102,16 @@ type ModelRouting = {
101102
baseUrl: string | undefined
102103
}
103104

104-
function getModel(chatbot: Chatbot, modelId: string) {
105+
function getOpenAIModel(
106+
provider: ReturnType<typeof createOpenAI>,
107+
modelConfig: ChatModelConfig
108+
) {
109+
return modelConfig.supportsReasoning
110+
? provider.responses(modelConfig.deploymentId)
111+
: provider.chat(modelConfig.deploymentId)
112+
}
113+
114+
function getModel(chatbot: Chatbot, modelConfig: ChatModelConfig) {
105115
// Use per-chatbot configuration if available
106116
const hasCustomKey =
107117
typeof chatbot.openaiApiKey === 'string' && chatbot.openaiApiKey.length > 0
@@ -136,11 +146,14 @@ function getModel(chatbot: Chatbot, modelId: string) {
136146
}
137147

138148
return {
139-
model: createOpenAI({
140-
baseURL: baseUrl,
141-
apiKey: apiKey || 'no-key',
142-
fetch: responsesApiFetch,
143-
})(modelId),
149+
model: getOpenAIModel(
150+
createOpenAI({
151+
baseURL: baseUrl,
152+
apiKey: apiKey || 'no-key',
153+
fetch: responsesApiFetch,
154+
}),
155+
modelConfig
156+
),
144157
routing,
145158
}
146159
}
@@ -153,11 +166,14 @@ function getModel(chatbot: Chatbot, modelId: string) {
153166
}
154167

155168
return {
156-
model: createOpenAI({
157-
baseURL: process.env.OPENAI_BASE_URL,
158-
apiKey: process.env.OPENAI_API_KEY || 'no-key',
159-
fetch: responsesApiFetch,
160-
})(modelId),
169+
model: getOpenAIModel(
170+
createOpenAI({
171+
baseURL: process.env.OPENAI_BASE_URL,
172+
apiKey: process.env.OPENAI_API_KEY || 'no-key',
173+
fetch: responsesApiFetch,
174+
}),
175+
modelConfig
176+
),
161177
routing,
162178
}
163179
}
@@ -923,7 +939,7 @@ export async function POST(
923939
? appliedReasoningEffort
924940
: undefined
925941

926-
const { model, routing } = getModel(chatbot, selectedModelConfig.deploymentId)
942+
const { model, routing } = getModel(chatbot, selectedModelConfig)
927943

928944
// create image descriptions if images attached
929945
let imageDescriptionCost: number = 0
@@ -1233,7 +1249,9 @@ export async function POST(
12331249
experimental_telemetry: { isEnabled: true },
12341250
providerOptions: {
12351251
openai: {
1236-
store: getOpenAIResponsesStore(),
1252+
...(selectedModelConfig.supportsReasoning && {
1253+
store: getOpenAIResponsesStore(),
1254+
}),
12371255
...(providerReasoningEffort && {
12381256
reasoningEffort: providerReasoningEffort,
12391257
reasoningSummary: 'auto',
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { prisma } from '@klicker-uzh/prisma'
2+
3+
const DEFAULT_FALLBACK_MODEL_ID = 'gpt-4.1-mini'
4+
const APPLY_FLAG = '--apply'
5+
6+
const fallbackModelId =
7+
process.env.CHAT_FALLBACK_MODEL_ID?.trim() || DEFAULT_FALLBACK_MODEL_ID
8+
const apply = process.argv.includes(APPLY_FLAG)
9+
10+
async function run() {
11+
const [chatbots, zeroCreditCounts] = await Promise.all([
12+
prisma.chatbot.findMany({
13+
select: {
14+
id: true,
15+
name: true,
16+
modelSelection: true,
17+
allowedModelIds: true,
18+
},
19+
orderBy: { updatedAt: 'desc' },
20+
}),
21+
prisma.chatUsageCredits.groupBy({
22+
by: ['chatbotId'],
23+
where: { current: { lte: 0 } },
24+
_count: { _all: true },
25+
}),
26+
])
27+
28+
const zeroCreditCountByChatbotId = new Map(
29+
zeroCreditCounts.map((entry) => [entry.chatbotId, entry._count._all])
30+
)
31+
const missingFallback = chatbots.filter(
32+
(chatbot) =>
33+
chatbot.allowedModelIds.length > 0 &&
34+
!chatbot.allowedModelIds.includes(fallbackModelId)
35+
)
36+
37+
console.log(`Fallback model: ${fallbackModelId}`)
38+
console.log(`Chatbots scanned: ${chatbots.length}`)
39+
console.log(
40+
`Explicit allow-lists missing fallback: ${missingFallback.length}`
41+
)
42+
43+
for (const chatbot of missingFallback) {
44+
const zeroCreditParticipants =
45+
zeroCreditCountByChatbotId.get(chatbot.id) ?? 0
46+
console.log(
47+
[
48+
`- ${chatbot.name}`,
49+
`id=${chatbot.id}`,
50+
`modelSelection=${chatbot.modelSelection}`,
51+
`zeroCreditParticipants=${zeroCreditParticipants}`,
52+
`allowedModelIds=${chatbot.allowedModelIds.join(',')}`,
53+
].join(' ')
54+
)
55+
}
56+
57+
if (!apply) {
58+
console.log(`Dry run only. Re-run with ${APPLY_FLAG} to update rows.`)
59+
return
60+
}
61+
62+
for (const chatbot of missingFallback) {
63+
await prisma.chatbot.update({
64+
where: { id: chatbot.id },
65+
data: {
66+
allowedModelIds: [...chatbot.allowedModelIds, fallbackModelId],
67+
},
68+
})
69+
}
70+
71+
console.log(`Updated ${missingFallback.length} chatbot allow-list(s).`)
72+
}
73+
74+
try {
75+
await run()
76+
} finally {
77+
await prisma.$disconnect()
78+
}

0 commit comments

Comments
 (0)