Skip to content

Commit 079a691

Browse files
committed
Merge branch 'v3' of https://github.com/uzh-bf/klicker-uzh into v3-ai
2 parents 2e3caa2 + 059d348 commit 079a691

35 files changed

Lines changed: 880 additions & 435 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,11 @@ Without Traefik, use `http://localhost:<port>` directly. The `*.klicker.com` dom
210210
- **Infisical + Turbo env sync**: Any Infisical-managed env var used by tasks must be listed in `turbo.json` `globalEnv`; otherwise task runs/cache behavior can become stale or inconsistent across environments.
211211
- **Participant email uniqueness across auth modes**: Prisma enforces `Participant @@unique([email, isSSOAccount])`, so the same normalized email can exist once as manual and once as SSO. To block new cross-mode duplicates, account creation must explicitly check normalized email collisions in service logic. (`packages/graphql/src/services/accounts.ts`)
212212
- **Helm v3 secrets are external**: `deploy/charts/klicker-uzh-v3/` deployments reference `envFrom.secretRef` names, but the chart currently defines no `Secret` manifests; secrets must be provisioned out-of-band with matching names. (`deploy/charts/klicker-uzh-v3/templates/`)
213+
- **Production rollout strategy**: Do not use `Recreate` for production web/API Deployments; PDBs do not protect against Deployment-driven scale-downs, so slow image pulls can leave services with zero endpoints. Use `RollingUpdate` in `deploy/env-uzh-prd/values.yaml`, with `maxUnavailable: 0` only for singleton services and `1` for multi-replica services.
213214
- **Edited chat image hydration needs a stable source id**: Edited branch messages in `apps/chat` get fresh local message ids, so image hydration must distinguish the local target message id from the persisted source message id (`attachmentSourceMessageId`) when fetching and merging attachments. (`apps/chat/src/hooks/useThreadManagement.ts`, `apps/chat/src/stores/chatStore.ts`)
214215
- **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`)
215216
- **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.
217+
- **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`)
216218
- **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`)
217219
- **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/`)
218220
- **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.

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
44

5+
## [3.4.0-alpha.62](https://github.com/uzh-bf/klicker-uzh/compare/v3.4.0-alpha.61...v3.4.0-alpha.62) (2026-05-05)
6+
7+
8+
### Features
9+
10+
* **chat:** make reasoning effort registry config-driven ([bb52d44](https://github.com/uzh-bf/klicker-uzh/commit/bb52d4449fe21fa828b38aa7410982e77d0eced0))
11+
512
## [3.4.0-alpha.61](https://github.com/uzh-bf/klicker-uzh/compare/v3.4.0-alpha.60...v3.4.0-alpha.61) (2026-05-03)
613

714

apps/auth/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@klicker-uzh/auth",
3-
"version": "3.4.0-alpha.61",
3+
"version": "3.4.0-alpha.62",
44
"license": "AGPL-3.0",
55
"dependencies": {
66
"@auth/prisma-adapter": "2.10.0",

apps/backend-docker/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@klicker-uzh/backend-docker",
3-
"version": "3.4.0-alpha.61",
3+
"version": "3.4.0-alpha.62",
44
"license": "AGPL-3.0",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

apps/chat/.env.local.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
AZURE_RESOURCE_NAME=klicker-ai
33
AZURE_API_KEY=sk-XXX
44
AZURE_RESPONSES_API_VERSION=preview
5+
CHAT_OPENAI_STORE_RESPONSES=false
56

67
# MCP Key
78
MCP_KEY=sk-XXX

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

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getChatModelRegistry,
66
} from '@/src/lib/server/chatModelRegistry'
77
import { ensureImagePreviewBase64 } from '@/src/lib/server/imagePreview'
8+
import { getOpenAIResponsesStore } from '@/src/lib/server/openaiResponsesOptions'
89
import {
910
getAggregatedMCPTools,
1011
type MCPServerWithConfig,
@@ -24,10 +25,7 @@ import {
2425
import { createHash, randomUUID } from 'crypto'
2526
import { NextRequest, NextResponse } from 'next/server'
2627
import { DEFAULT_PROMPT } from 'src/lib/config/prompts'
27-
import {
28-
REASONING_EFFORT_OPTIONS,
29-
type ReasoningEffort,
30-
} from 'src/lib/config/reasoning'
28+
import { type ReasoningEffort } from 'src/lib/config/reasoning'
3129
import { CreditsService } from 'src/services/credits'
3230
import { DisclaimersService } from 'src/services/disclaimers'
3331
import {
@@ -687,10 +685,7 @@ export async function POST(
687685
.optional()
688686
.transform((val) => val?.toLowerCase())
689687
.default('tutor'),
690-
reasoningEffort: z
691-
.enum(REASONING_EFFORT_OPTIONS)
692-
.optional()
693-
.default('none'),
688+
reasoningEffort: z.string().min(1).optional().default('none'),
694689
parentId: z.string().min(1).nullable().optional(),
695690
assistantMessageId: z.string().min(1),
696691
images: z
@@ -1359,7 +1354,7 @@ export async function POST(
13591354
experimental_telemetry: { isEnabled: true },
13601355
providerOptions: {
13611356
openai: {
1362-
store: false,
1357+
store: getOpenAIResponsesStore(),
13631358
...(providerReasoningEffort && {
13641359
reasoningEffort: providerReasoningEffort,
13651360
reasoningSummary: 'auto',

apps/chat/src/hooks/useChatResponse.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@ import { useParams } from 'next/navigation'
22
import { useCallback, useRef } from 'react'
33
import { hasAllImageAttachmentsHydrated } from '../lib/attachments/attachmentState'
44
import { authedFetch } from '../lib/client/authedFetch'
5-
import {
6-
REASONING_EFFORT_OPTIONS,
7-
type ReasoningEffort,
8-
} from '../lib/config/reasoning'
5+
import { type ReasoningEffort } from '../lib/config/reasoning'
96
import { generateId } from '../lib/utils/chatUtils'
107
import {
118
useChatStore,
@@ -474,10 +471,8 @@ export function useChatResponse(
474471
>
475472
const reasoningEffort =
476473
typeof metadata.reasoningEffort === 'string' &&
477-
REASONING_EFFORT_OPTIONS.includes(
478-
metadata.reasoningEffort as ReasoningEffort
479-
)
480-
? (metadata.reasoningEffort as ReasoningEffort)
474+
metadata.reasoningEffort.length > 0
475+
? metadata.reasoningEffort
481476
: null
482477

483478
messageMetadata = {
Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1 @@
1-
export const REASONING_EFFORT_OPTIONS = [
2-
'none',
3-
'minimal',
4-
'low',
5-
'medium',
6-
'high',
7-
'xhigh',
8-
] as const
9-
10-
export type ReasoningEffort = (typeof REASONING_EFFORT_OPTIONS)[number]
1+
export type ReasoningEffort = string

apps/chat/src/lib/server/chatModelRegistry.ts

Lines changed: 43 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,36 @@
11
import { z } from 'zod'
2-
import {
3-
REASONING_EFFORT_OPTIONS,
4-
type ReasoningEffort,
5-
} from '../config/reasoning'
6-
7-
const reasoningEffortSchema = z.enum(REASONING_EFFORT_OPTIONS)
8-
9-
const chatModelSchema = z.object({
10-
id: z.string().min(1),
11-
deploymentId: z.string().min(1),
12-
name: z.string().min(1),
13-
description: z.string().default(''),
14-
fallback: z.boolean().default(false),
15-
supportsReasoning: z.boolean().default(false),
16-
supportsImageAttachments: z.boolean().default(false),
17-
supportedReasoningEfforts: z.array(reasoningEffortSchema).optional(),
18-
maxOutputTokens: z.number().positive().optional(),
19-
apiVersion: z.string().min(1).optional(),
20-
cost: z.object({
21-
input: z.number().nonnegative(),
22-
output: z.number().nonnegative(),
23-
}),
24-
})
2+
import { type ReasoningEffort } from '../config/reasoning'
3+
4+
const chatModelSchema = z
5+
.object({
6+
id: z.string().min(1),
7+
deploymentId: z.string().min(1),
8+
name: z.string().min(1),
9+
description: z.string().default(''),
10+
fallback: z.boolean().default(false),
11+
supportsReasoning: z.boolean().default(false),
12+
supportsImageAttachments: z.boolean().default(false),
13+
supportedReasoningEfforts: z.array(z.string().min(1)).optional(),
14+
maxOutputTokens: z.number().positive().optional(),
15+
apiVersion: z.string().min(1).optional(),
16+
cost: z.object({
17+
input: z.number().nonnegative(),
18+
output: z.number().nonnegative(),
19+
}),
20+
})
21+
.superRefine((model, ctx) => {
22+
if (
23+
model.supportsReasoning &&
24+
(!model.supportedReasoningEfforts ||
25+
model.supportedReasoningEfforts.length === 0)
26+
) {
27+
ctx.addIssue({
28+
code: z.ZodIssueCode.custom,
29+
path: ['supportedReasoningEfforts'],
30+
message: `Model "${model.id}" has supportsReasoning=true but no supportedReasoningEfforts configured.`,
31+
})
32+
}
33+
})
2534

2635
const chatModelRegistrySchema = z
2736
.array(chatModelSchema)
@@ -63,55 +72,8 @@ export type ChatModelConfig = Omit<
6372
supportedReasoningEfforts: ReasoningEffort[]
6473
}
6574

66-
const BASE_REASONING_EFFORTS: ReasoningEffort[] = [
67-
'none',
68-
'minimal',
69-
'low',
70-
'medium',
71-
'high',
72-
]
73-
const FRONTIER_REASONING_EFFORTS: ReasoningEffort[] = [
74-
'none',
75-
'low',
76-
'medium',
77-
'high',
78-
'xhigh',
79-
]
80-
const GPT5_REASONING_EFFORTS: ReasoningEffort[] = [
81-
'minimal',
82-
'low',
83-
'medium',
84-
'high',
85-
]
86-
87-
function dedupeReasoningEfforts(efforts: readonly ReasoningEffort[]) {
88-
const seen = new Set<ReasoningEffort>()
89-
const deduped: ReasoningEffort[] = []
90-
for (const effort of efforts) {
91-
if (seen.has(effort)) continue
92-
seen.add(effort)
93-
deduped.push(effort)
94-
}
95-
return deduped
96-
}
97-
98-
function getDefaultReasoningEffortsForModel(
99-
modelId: string
100-
): ReasoningEffort[] {
101-
const normalizedId = modelId.toLowerCase()
102-
if (
103-
normalizedId.startsWith('gpt-5.5') ||
104-
normalizedId.startsWith('gpt-5.4')
105-
) {
106-
return [...FRONTIER_REASONING_EFFORTS]
107-
}
108-
if (normalizedId.startsWith('gpt-5.1')) {
109-
return [...BASE_REASONING_EFFORTS]
110-
}
111-
if (normalizedId.startsWith('gpt-5')) {
112-
return [...GPT5_REASONING_EFFORTS]
113-
}
114-
return [...BASE_REASONING_EFFORTS]
75+
function dedupeStrings(values: readonly string[]) {
76+
return Array.from(new Set(values))
11577
}
11678

11779
function normalizeChatModelConfig(model: RawChatModelConfig): ChatModelConfig {
@@ -122,15 +84,11 @@ function normalizeChatModelConfig(model: RawChatModelConfig): ChatModelConfig {
12284
}
12385
}
12486

125-
const providedEfforts = model.supportedReasoningEfforts ?? []
126-
const normalizedEfforts =
127-
providedEfforts.length > 0
128-
? dedupeReasoningEfforts(providedEfforts)
129-
: getDefaultReasoningEffortsForModel(model.id)
130-
13187
return {
13288
...model,
133-
supportedReasoningEfforts: normalizedEfforts,
89+
supportedReasoningEfforts: dedupeStrings(
90+
model.supportedReasoningEfforts ?? []
91+
),
13492
}
13593
}
13694

@@ -149,7 +107,7 @@ const DEFAULT_MODEL_REGISTRY: ChatModelConfig[] = [
149107
fallback: false,
150108
supportsReasoning: true,
151109
supportsImageAttachments: true,
152-
supportedReasoningEfforts: [...FRONTIER_REASONING_EFFORTS],
110+
supportedReasoningEfforts: ['none', 'low', 'medium', 'high', 'xhigh'],
153111
maxOutputTokens: 2048,
154112
cost: { input: 5.0, output: 30.0 },
155113
},
@@ -161,7 +119,7 @@ const DEFAULT_MODEL_REGISTRY: ChatModelConfig[] = [
161119
fallback: false,
162120
supportsReasoning: true,
163121
supportsImageAttachments: true,
164-
supportedReasoningEfforts: [...FRONTIER_REASONING_EFFORTS],
122+
supportedReasoningEfforts: ['none', 'low', 'medium', 'high', 'xhigh'],
165123
maxOutputTokens: 2048,
166124
cost: { input: 2.5, output: 15.0 },
167125
},
@@ -173,7 +131,7 @@ const DEFAULT_MODEL_REGISTRY: ChatModelConfig[] = [
173131
fallback: false,
174132
supportsReasoning: true,
175133
supportsImageAttachments: true,
176-
supportedReasoningEfforts: [...BASE_REASONING_EFFORTS],
134+
supportedReasoningEfforts: ['none', 'minimal', 'low', 'medium', 'high'],
177135
maxOutputTokens: 2048,
178136
cost: { input: 1.25, output: 10.0 },
179137
},
@@ -239,11 +197,10 @@ export function parseReasoningEffortByModel(
239197
if (!Array.isArray(rawEfforts)) continue
240198
const validEfforts: ReasoningEffort[] = rawEfforts.filter(
241199
(effort): effort is ReasoningEffort =>
242-
typeof effort === 'string' &&
243-
REASONING_EFFORT_OPTIONS.includes(effort as ReasoningEffort)
200+
typeof effort === 'string' && effort.length > 0
244201
)
245202

246-
const dedupedEfforts = dedupeReasoningEfforts(validEfforts)
203+
const dedupedEfforts = dedupeStrings(validEfforts)
247204
if (dedupedEfforts.length === 0) continue
248205

249206
result[modelId] = dedupedEfforts
@@ -261,9 +218,7 @@ export function getAllowedReasoningEffortsForModel(
261218
): ReasoningEffort[] {
262219
if (!model.supportsReasoning) return []
263220

264-
const supportedEfforts = dedupeReasoningEfforts(
265-
model.supportedReasoningEfforts
266-
)
221+
const supportedEfforts = dedupeStrings(model.supportedReasoningEfforts)
267222
if (supportedEfforts.length === 0) return []
268223

269224
const configuredByModel = parseReasoningEffortByModel(rawConfig)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
const TRUTHY_VALUES = new Set(['true', '1', 'yes', 'on'])
2+
3+
export function parseOpenAIResponsesStore(value: string | undefined): boolean {
4+
if (!value) return false
5+
return TRUTHY_VALUES.has(value.trim().toLowerCase())
6+
}
7+
8+
export function getOpenAIResponsesStore(): boolean {
9+
return parseOpenAIResponsesStore(process.env.CHAT_OPENAI_STORE_RESPONSES)
10+
}

0 commit comments

Comments
 (0)