`, lines 599–613).
+- `availableModels.length === 0` → shows a single "Default" option (line 607), so both the zero-model and one-model paths surface as a one-option dropdown.
+
+**Also**: The `models` endpoint (line 339–426) requires an API key and returns HTTP 403 if absent; the frontend silently sets `availableModels = []` on any non-OK response (line 136–138) — so users without a key see "Default" only, regardless of provider.
+
+**Edit point**:
+- `backend/server/chat/views/__init__.py` lines 417–418: expand `opencode_zen` model list to include Zen-compatible models (e.g., `openai/gpt-5-nano`, `openai/gpt-4o-mini`, `openai/gpt-4o`, `anthropic/claude-3-5-haiku-20241022`).
+- Optionally: `AITravelChat.svelte` `loadModelsForProvider()` — handle non-OK response more gracefully (log distinct error instead of silent fallback to empty).
+
+---
+
+### F2 — Context appears location-centric, not trip-centric
+
+**Root cause — `destination` prop is a single derived location string**:
+
+`frontend/src/routes/collections/[id]/+page.svelte` lines 259–278, `deriveCollectionDestination()`:
+```ts
+const firstLocation = current.locations.find(...)
+return `${cityName}, ${countryName}` // first location only
+```
+Only the **first** location in `collection.locations` is used. Multi-city trips surface a single city/country string.
+
+**How it propagates** (`+page.svelte` lines 1287–1294):
+```svelte
+
+```
+
+**Backend trip context** (`backend/server/chat/views/__init__.py` lines 144–168, `send_message`):
+```python
+context_parts = []
+if collection_name: context_parts.append(f"Trip: {collection_name}")
+if destination: context_parts.append(f"Destination: {destination}") # ← single string
+if start_date and end_date: context_parts.append(f"Dates: ...")
+system_prompt += "\n\n## Trip Context\n" + "\n".join(context_parts)
+```
+The `Destination:` line is a single string from the frontend — no multi-stop awareness. The `collection` object IS fetched from DB (lines 152–164) and passed to `get_system_prompt(user, collection)`, but `get_system_prompt` (`llm_client.py` lines 310–358) only uses `collection` to decide single-user vs. party preferences — it never reads collection locations, itinerary, or dates from the collection model itself.
+
+**Edit points**:
+1. `frontend/src/routes/collections/[id]/+page.svelte` `deriveCollectionDestination()` (lines 259–278): Change to derive a multi-location string (e.g., comma-joined list of unique city/country pairs, capped at 4–5) rather than first-only. Or rename to make clear it's itinerary-wide and return `undefined` when collection has many diverse destinations.
+2. `backend/server/chat/views/__init__.py` `send_message()` (lines 144–168): Since `collection` is already fetched, enrich `context_parts` directly from `collection.locations` (unique cities/countries) rather than relying solely on the single-string `destination` param.
+3. Optionally, `backend/server/chat/llm_client.py` `get_system_prompt()` (lines 310–358): When `collection` is not None, add a collection-derived section to the base prompt listing all itinerary destinations and dates from the collection object.
+
+---
+
+### F3 — Quick-action prompts assume a single destination
+
+**Root cause — all destination-dependent prompts are gated on `destination` prop** (`AITravelChat.svelte` lines 766–804):
+```svelte
+{#if destination}
+ 🍽️ Restaurants in {destination}
+ 🎯 Activities in {destination}
+{/if}
+{#if startDate && endDate}
+ 🎒 Packing tips for {startDate} to {endDate}
+{/if}
+📅 Itinerary help ← always shown, generic
+```
+
+The "Restaurants" and "Activities" buttons are hidden when no `destination` is derived (multi-city trip with no single dominant location), and their prompt strings hard-code `${destination}` — a single-city reference. They also don't reference the collection name or multi-stop nature.
+
+**Edit points** (`AITravelChat.svelte` lines 766–804):
+1. Replace `{#if destination}` guard for restaurant/activity buttons with a `{#if collectionName || destination}` guard.
+2. Change prompt strings to use `collectionName` as primary context, falling back to `destination`:
+ - `What are the best restaurants for my trip to ${collectionName || destination}?`
+ - `What activities are there across my ${collectionName} itinerary?`
+3. Add a "Budget" or "Transport" quick action that references the collection dates + itinerary scope (doesn't need `destination`).
+4. The "📅 Itinerary help" button (line 797–804) sends `'Can you help me plan a day-by-day itinerary for this trip?'` — already collection-neutral; no change needed.
+5. Packing tip prompt (lines 788–795) already uses `startDate`/`endDate` without `destination` — this one is already correct.
+
+---
+
+### Cross-cutting risk: `destination` prop semantics are overloaded
+
+The `destination` prop in `AITravelChat.svelte` is used for:
+- Header subtitle display (line 582: removed in current code — subtitle block gone)
+- Quick-action prompt strings (lines 771, 779)
+- `send_message` payload (line 268: `destination`)
+
+Changing `deriveCollectionDestination()` to return a multi-location string affects all three uses. The header display is currently suppressed (no `{destination}` in the HTML header block after WS4-F4 changes), so that's safe. The `send_message` backend receives it as the `Destination:` context line, which is acceptable for a multi-city string.
+
+### No regression surface from `loadModelsForProvider` reactive trigger
+
+The `$: if (selectedProvider) { void loadModelsForProvider(); }` reactive statement (line 190–192) fires whenever `selectedProvider` changes. Expanding the `opencode_zen` model list won't affect other providers. The `loadModelPref`/`saveModelPref` localStorage path is independent of model list size.
+
+### `add_to_itinerary` tool `location` required error (from Notes)
+
+`search_places` tool (`agent_tools.py`) requires a `location` string param. When the LLM calls it with no location (because context only mentions a trip name, not a geocodable string), the tool returns `{"error": "location is required"}`. This is downstream of F2 — fixing the context so the LLM receives actual geocodable location strings will reduce these errors, but the tool itself should also be documented as requiring a geocodable string.
+
+---
+
+## Deep-Dive Findings (explorer pass 2 — 2026-03-09)
+
+### F1: Exact line for single-model fix
+
+`backend/server/chat/views/__init__.py` **lines 417–418**:
+```python
+if provider in ["opencode_zen"]:
+ return Response({"models": ["openai/gpt-5-nano"]})
+```
+Single-entry hard-coded list. No Zen API call is made. Expand to all Zen-compatible models.
+
+**Recommended minimal list** (OpenAI-compatible pass-through documented for Zen):
+```python
+return Response({"models": [
+ "openai/gpt-5-nano",
+ "openai/gpt-4o-mini",
+ "openai/gpt-4o",
+ "openai/o1-preview",
+ "openai/o1-mini",
+ "anthropic/claude-sonnet-4-20250514",
+ "anthropic/claude-3-5-haiku-20241022",
+]})
+```
+
+---
+
+### F2: System prompt never injects collection locations into context
+
+`backend/server/chat/views/__init__.py` lines **144–168** (`send_message`): `collection` is fetched from DB but only passed to `get_system_prompt()` for preference aggregation — its `.locations` queryset is never read to enrich context.
+
+`backend/server/chat/llm_client.py` lines **310–358** (`get_system_prompt`): `collection` param only used for `shared_with` preference branch. Zero use of `collection.locations`, `.start_date`, `.end_date`, or `.itinerary_items`.
+
+**Minimal fix — inject into context_parts in `send_message`**:
+After line 164 (`collection = requested_collection`), add:
+```python
+if collection:
+ loc_names = list(collection.locations.values_list("name", flat=True)[:8])
+ if loc_names:
+ context_parts.append(f"Locations in this trip: {', '.join(loc_names)}")
+```
+Also strengthen the base system prompt in `llm_client.py` to instruct the model to call `get_trip_details` when operating in collection context before calling `search_places`.
+
+---
+
+### F3a: Frontend `hasPlaceResults` / `getPlaceResults` use wrong key `.places` — cards never render
+
+**Critical bug** — `AITravelChat.svelte`:
+- **Line 377**: checks `(result.result as { places?: unknown[] }).places` — should be `results`
+- **Line 386**: returns `(result.result as { places: any[] }).places` — should be `results`
+
+Backend `search_places` (`agent_tools.py` line 188–192) returns:
+```python
+return {"location": location_name, "category": category, "results": results}
+```
+The key is `results`, not `places`. Because `hasPlaceResults` always returns `false`, the "Add to Itinerary" button on place cards is **never rendered** for any real tool output. The `` JSON fallback block shows instead.
+
+**Minimal fix**: change both `.places` references → `.results` in `AITravelChat.svelte` lines 377 and 386.
+
+---
+
+### F3b: `{"error": "location is required"}` origin
+
+`backend/server/chat/agent_tools.py` **line 128**:
+```python
+if not location_name:
+ return {"error": "location is required"}
+```
+Triggered when LLM calls `search_places({})` with no `location` argument — which happens when the system prompt only contains a non-geocodable trip name (e.g., `Destination: Rome Trip 2025`) without actual city/place strings.
+
+This error surfaces in the SSE stream → rendered as a tool result card with `{"error": "..."}` text.
+
+**Fix**: Resolved by F2 (richer context); also improve guard message to be user-safe: `"Please provide a location or city name to search near."`.
+
+---
+
+### Summary of edit points
+
+| Issue | File | Lines | Change |
+|---|---|---|---|
+| F1: expand opencode_zen models | `backend/server/chat/views/__init__.py` | 417–418 | Replace 1-item list with 7-item list |
+| F2: inject collection locations | `backend/server/chat/views/__init__.py` | 144–168 | Add `loc_names` context_parts after line 164 |
+| F2: reinforce system prompt | `backend/server/chat/llm_client.py` | 314–332 | Add guidance to use `get_trip_details` in collection context |
+| F3a: fix `.places` → `.results` | `frontend/src/lib/components/AITravelChat.svelte` | 377, 386 | Two-char key rename |
+| F3b: improve error guard | `backend/server/chat/agent_tools.py` | 128 | Better user-safe message (optional) |
+
+---
+
+## Critic Gate
+
+- **Verdict**: APPROVED
+- **Date**: 2026-03-09
+- **Reviewer**: critic agent
+
+### Assumption Challenges
+
+1. **F2 `values_list("name")` may not produce geocodable strings** — `Location.name` can be opaque (e.g., "Eiffel Tower"). Mitigated: plan already proposes system prompt guidance to call `get_trip_details` first. Enhancement: use `city__name`/`country__name` in addition to `name` for the injected context.
+2. **F3a `.places` vs `.results` key mismatch** — confirmed real bug. `agent_tools.py` returns `results` key; frontend checks `places`. Place cards never render. Two-char fix validated.
+
+### Execution Guardrails
+
+1. **Sequencing**: F1 (independent) → F2 (context enrichment) → F3 (prompts + `.places` fix). F3 depends on F2's `deriveCollectionDestination` changes.
+2. **F1 model list**: Exclude `openai/o1-preview` and `openai/o1-mini` — reasoning models may not support tool-use in streaming chat. Verify compatibility before including.
+3. **F2 context injection**: Use `select_related('city', 'country')` or `values_list('name', 'city__name', 'country__name')` — bare `name` alone is insufficient for geocoding context.
+4. **F3a is atomic**: The `.places`→`.results` fix is a standalone bug, separate from prompt wording changes. Can bundle in F3's review cycle.
+5. **Quality pipeline**: Each fix gets reviewer + tester pass. No batch validation.
+6. **Functional verification required**: (a) model dropdown shows multiple options, (b) chat context includes multi-city info, (c) quick-action prompts render for multi-location collections, (d) search result place cards actually render (F3a).
+7. **Decomposition**: Single workstream appropriate — tightly coupled bugfixes in same component/view pair, not independent services.
+
+---
+
+## F1 Review
+
+- **Verdict**: APPROVED (score 0)
+- **Lens**: Correctness
+- **Date**: 2026-03-09
+- **Reviewer**: reviewer agent
+
+**Scope**: `backend/server/chat/views/__init__.py` lines 417–428 — `opencode_zen` model list expanded from 1 to 5 entries.
+
+**Findings**: No CRITICAL or WARNING issues. Change is minimal and correctly scoped.
+
+**Verified**:
+- Critic guardrail followed: `o1-preview` and `o1-mini` excluded (reasoning models, no streaming tool-use).
+- All 5 model IDs use valid LiteLLM `provider/model` format; `anthropic/*` IDs match exact entries in Anthropic branch.
+- `_is_model_override_compatible()` bypasses prefix check for `api_base` gateways — all IDs pass validation.
+- No regression in other provider branches (openai, anthropic, gemini, groq, ollama) — all untouched.
+- Frontend `loadModelsForProvider()` handles multi-item arrays correctly; dropdown will show all 5 options.
+- localStorage model persistence unaffected by list size change.
+
+**Suggestion**: Add inline comment on why o1-preview/o1-mini are excluded to prevent future re-addition.
+
+**Reference**: See [Critic Gate](#critic-gate), [decisions.md](../decisions.md#critic-gate-travel-agent-context--models-follow-up)
+
+---
+
+## F1 Test
+
+- **Verdict**: PASS (Standard + Adversarial)
+- **Date**: 2026-03-09
+- **Tester**: tester agent
+
+### Commands run
+
+| # | Command | Exit code | Output |
+|---|---|---|---|
+| 1 | `docker compose exec server python3 -m py_compile /code/chat/views/__init__.py` | 0 | (no output — syntax OK) |
+| 2 | Inline `python3 -c` assertion of `opencode_zen` branch | 0 | count: 5, all 5 model IDs confirmed present, PASS |
+| 3 | Adversarial: branch isolation for 8 non-`opencode_zen` providers | 0 | All return `[]`, ADVERSARIAL PASS |
+| 4 | Adversarial: critic guardrail + LiteLLM format check | 0 | `o1-preview` / `o1-mini` absent; all IDs in `provider/model` format, PASS |
+| 5 | `docker compose exec server python3 -c "import chat.views; ..."` | 0 | Module import OK, `ChatProviderCatalogViewSet.models` action present |
+| 6 | `docker compose exec server python3 manage.py test --verbosity=1 --keepdb` | 1 (pre-existing) | 30 tests: 24 pass, 1 fail, 5 errors — identical to known baseline (2 user email key + 4 geocoding mock). **Zero new failures.** |
+
+### Key findings
+
+- `opencode_zen` branch now returns exactly 5 models: `openai/gpt-5-nano`, `openai/gpt-4o-mini`, `openai/gpt-4o`, `anthropic/claude-sonnet-4-20250514`, `anthropic/claude-3-5-haiku-20241022`.
+- Critic guardrail respected: `openai/o1-preview` and `openai/o1-mini` absent from list.
+- All model IDs use valid `provider/model` format compatible with LiteLLM routing.
+- No other provider branches affected.
+- No regression in full Django test suite beyond pre-existing baseline.
+
+### Adversarial attempts
+
+- **Case insensitive match (`OPENCODE_ZEN`)**: does not match branch → returns `[]` (correct; exact case match required).
+- **Partial match (`opencode_zen_extra`)**: does not match → returns `[]` (correct; no prefix leakage).
+- **Empty string provider `""`**: returns `[]` (correct).
+- **`openai/o1-preview` inclusion check**: absent from list (critic guardrail upheld).
+- **`openai/o1-mini` inclusion check**: absent from list (critic guardrail upheld).
+
+### MUTATION_ESCAPES: 0/4
+
+All critical branch mutations checked: wrong provider name, case variation, extra-suffix variation, empty string — all correctly return `[]`. The 5-model list is hard-coded so count drift would be immediately caught by assertion.
+
+### LESSON_CHECKS
+
+- Pre-existing test failures (2 user + 4 geocoding) — **confirmed**, baseline unchanged.
+
+---
+
+## F2 Review
+
+- **Verdict**: APPROVED (score 0)
+- **Lens**: Correctness
+- **Date**: 2026-03-09
+- **Reviewer**: reviewer agent
+
+**Scope**: F2 — Correct chat context to reflect full trip/collection. Three files changed:
+- `frontend/src/routes/collections/[id]/+page.svelte` (lines 259–300): `deriveCollectionDestination()` rewritten from first-location-only to multi-stop itinerary summary.
+- `backend/server/chat/views/__init__.py` (lines 166–199): `send_message()` enriched with collection-derived `Itinerary stops:` context from `collection.locations`.
+- `backend/server/chat/llm_client.py` (lines 333–336): System prompt updated with trip-level reasoning guidance and `get_trip_details`-first instruction.
+
+**Acceptance criteria verified**:
+1. ✅ Frontend derives multi-stop destination string (unique city/country pairs, capped at 4, semicolon-joined, `+N more` overflow).
+2. ✅ Backend enriches system prompt with `Itinerary stops:` from collection locations (up to 8, `select_related('city', 'country')` for efficiency).
+3. ✅ System prompt instructs trip-level reasoning and `get_trip_details`-first behavior (tool confirmed to exist in `agent_tools.py`).
+4. ✅ No regression: non-collection chats, single-location collections, and empty-location collections all handled correctly via guard conditions.
+
+**Findings**: No CRITICAL or WARNING issues. Two minor suggestions (dead guard on line 274 of `+page.svelte`; undocumented cap constant in `views/__init__.py` line 195).
+
+**Prior guidance**: Critic gate recommendation to use `select_related('city', 'country')` and city/country names — confirmed followed.
+
+**Reference**: See [Critic Gate](#critic-gate), [F1 Review](#f1-review)
+
+---
+
+## F2 Test
+
+- **Verdict**: PASS (Standard + Adversarial)
+- **Date**: 2026-03-09
+- **Tester**: tester agent
+
+### Commands run
+
+| # | Command | Exit code | Output summary |
+|---|---|---|---|
+| 1 | `bun run check` (frontend) | 0 | 0 errors, 6 warnings — all 6 are pre-existing in `CollectionRecommendationView.svelte` + `RegionCard.svelte`; no new issues from F2 changes |
+| 2 | `docker compose exec server python3 -m py_compile /code/chat/views/__init__.py` | 0 | Syntax OK |
+| 3 | `docker compose exec server python3 -m py_compile /code/chat/llm_client.py` | 0 | Syntax OK |
+| 4 | Backend functional enrichment test (mock collection, 6 inputs → 5 unique stops) | 0 | `Itinerary stops: Rome, Italy; Florence, Italy; Venice, Italy; Switzerland; Eiffel Tower` — multi-stop line confirmed |
+| 5 | Adversarial backend: 7 cases (cap-8, empty, all-blank, whitespace, unicode, dedup-12, None city) | 0 | All 7 PASS |
+| 6 | Frontend JS adversarial: 7 cases (multi-stop, single, null, empty, overflow +N, fallback, all-blank) | 0 | All 7 PASS |
+| 7 | System prompt phrase check | 0 | `itinerary-wide` + `get_trip_details` + `Treat context as itinerary-wide` all confirmed present |
+| 8 | `docker compose exec server python3 manage.py test --verbosity=1 --keepdb` | 1 (pre-existing) | 30 tests: 24 pass, 1 fail, 5 errors — **identical to known baseline**; zero new failures |
+
+### Acceptance criteria verdict
+
+| Criterion | Result | Evidence |
+|---|---|---|
+| Multi-stop destination string derived in frontend | ✅ PASS | JS test: 3-city collection → `Rome, Italy; Florence, Italy; Venice, Italy`; 6-city → `A, X; B, X; C, X; D, X; +2 more` |
+| Backend injects `Itinerary stops:` from `collection.locations` | ✅ PASS | Python test: 6 inputs → 5 unique stops joined with `; `, correctly prefixed `Itinerary stops:` |
+| System prompt has trip-level + `get_trip_details`-first guidance | ✅ PASS | `get_system_prompt()` output contains `itinerary-wide`, `get_trip_details first`, `Treat context as itinerary-wide` |
+| No regression in existing fields | ✅ PASS | Django test suite unchanged at baseline (24 pass, 6 pre-existing fail/error) |
+
+### Adversarial attempts
+
+| Hypothesis | Test | Expected failure signal | Observed |
+|---|---|---|---|
+| 12-city collection exceeds cap | Supply 12 unique cities | >8 stops returned | Capped at exactly 8 ✅ |
+| Empty `locations` list | Pass `locations=[]` | Crash or non-empty result | Returns `undefined`/`[]` cleanly ✅ |
+| All-blank location entries | All city/country/name empty or whitespace | Non-empty or crash | All skipped, returns `undefined`/`[]` ✅ |
+| Whitespace-only city/country | `city.name=' '` with valid fallback | Whitespace treated as valid | Strip applied, fallback used ✅ |
+| Unicode city names | `東京`, `Zürich`, `São Paulo` | Encoding corruption or skip | All 3 preserved correctly ✅ |
+| 12 duplicate identical entries | Same city×12 | Multiple copies in output | Deduped to exactly 1 ✅ |
+| `city.name = None` (DB null) | `None` city name, valid country | `AttributeError` or crash | Handled via `or ''` guard, country used ✅ |
+| `null` collection passed to frontend func | `deriveCollectionDestination(null)` | Crash | Returns `undefined` cleanly ✅ |
+| Overflow suffix formatting | 6 unique stops, maxStops=4 | Wrong suffix or missing | `+2 more` suffix correct ✅ |
+| Fallback name path | No city/country, `location='Eiffel Tower'` | Missing or wrong label | `Eiffel Tower` used ✅ |
+
+### MUTATION_ESCAPES: 0/6
+
+Mutation checks applied:
+1. `>= 8` cap mutated to `> 8` → A1 test (12-city produces 8, not 9) would catch.
+2. `seen_stops` dedup check mutated to always-false → A6 test (12-dupes) would catch.
+3. `or ''` null-guard on `city.name` removed → A7 test would catch `AttributeError`.
+4. `if not fallback_name: continue` removed → A3 test (all-blank) would catch spurious entries.
+5. `stops.slice(0, maxStops).join('; ')` separator mutated to `', '` → Multi-stop tests check for `'; '` as separator.
+6. `return undefined` on empty guard mutated to `return ''` → A4 empty-locations test checks `=== undefined`.
+
+All 6 mutations would be caught by existing test cases.
+
+### LESSON_CHECKS
+
+- Pre-existing test failures (2 user email key + 4 geocoding mock) — **confirmed**, baseline unchanged.
+- F2 context enrichment using `select_related('city', 'country')` per critic guardrail — **confirmed** (line 169–171 of views/__init__.py).
+- Fallback to `location`/`name` fields when geo data absent — **confirmed** working via A4/A5 tests.
+
+**Reference**: See [F2 Review](#f2-review), [Critic Gate](#critic-gate)
+
+---
+
+## F3 Review
+
+- **Verdict**: APPROVED (score 0)
+- **Lens**: Correctness
+- **Date**: 2026-03-09
+- **Reviewer**: reviewer agent
+
+**Scope**: Targeted re-review of two F3 findings in `frontend/src/lib/components/AITravelChat.svelte`:
+1. `.places` → `.results` key mismatch in `hasPlaceResults()` / `getPlaceResults()`
+2. Quick-action prompt guard and wording — location-centric → itinerary-centric
+
+**Finding 1 — `.places` → `.results` (RESOLVED)**:
+- `hasPlaceResults()` (line 378): checks `(result.result as { results?: unknown[] }).results` ✅
+- `getPlaceResults()` (line 387): returns `(result.result as { results: any[] }).results` ✅
+- Cross-verified against backend `agent_tools.py:188-191`: `return {"location": ..., "category": ..., "results": results}` — keys match.
+
+**Finding 2 — Itinerary-centric prompts (RESOLVED)**:
+- New reactive `promptTripContext` (line 72): `collectionName || destination || ''` — prefers collection name over single destination.
+- Guard changed from `{#if destination}` → `{#if promptTripContext}` (line 768) — buttons now visible for named collections even without a single derived destination.
+- Prompt strings use `across my ${promptTripContext} itinerary?` wording (lines 773, 783) — no longer implies single location.
+- No impact on packing tips (still `startDate && endDate` gated) or itinerary help (always shown).
+
+**No introduced issues**: `promptTripContext` always resolves to string; template interpolation safe; existing tool result rendering and `sendMessage()` logic unchanged beyond the key rename.
+
+**SUGGESTIONS**: Minor indentation inconsistency between `{#if promptTripContext}` block (lines 768-789) and adjacent `{#if startDate}` block (lines 790-801) — cosmetic, `bun run format` should normalize.
+
+**Reference**: See [Critic Gate](#critic-gate), [F2 Review](#f2-review), [decisions.md](../decisions.md#critic-gate-travel-agent-context--models-follow-up)
+
+---
+
+## F3 Test
+
+- **Verdict**: PASS (Standard + Adversarial)
+- **Date**: 2026-03-09
+- **Tester**: tester agent
+
+### Commands run
+
+| # | Command | Exit code | Output summary |
+|---|---|---|---|
+| 1 | `bun run check` (frontend) | 0 | 0 errors, 6 warnings — all 6 pre-existing in `CollectionRecommendationView.svelte` + `RegionCard.svelte`; zero new issues from F3 changes |
+| 2 | `bun run f3_test.mjs` (functional simulation) | 0 | 20 assertions: S1–S6 standard + A1–A6 adversarial + PTC1–PTC4 promptTripContext + prompt wording — ALL PASSED |
+
+### Acceptance criteria verdict
+
+| Criterion | Result | Evidence |
+|---|---|---|
+| `.places` → `.results` key fix in `hasPlaceResults()` | ✅ PASS | S1: `{results:[...]}` → true; S2: `{places:[...]}` → false (old key correctly rejected) |
+| `.places` → `.results` key fix in `getPlaceResults()` | ✅ PASS | S1: returns 2-item array from `.results`; S2: returns `[]` on `.places` key |
+| Old `.places` key no longer triggers card rendering | ✅ PASS | S2 regression guard: `hasPlaceResults({places:[...]})` → false |
+| `promptTripContext` = `collectionName \|\| destination \|\| ''` | ✅ PASS | PTC1–PTC4: collectionName wins; falls back to destination; empty string when both absent |
+| Quick-action guard is `{#if promptTripContext}` | ✅ PASS | Source inspection confirmed line 768 uses `promptTripContext` |
+| Prompt wording is itinerary-centric | ✅ PASS | Both prompts contain `itinerary`; neither uses single-location "in X" wording |
+
+### Adversarial attempts
+
+| Hypothesis | Test design | Expected failure signal | Observed |
+|---|---|---|---|
+| `results` is a string, not array | `result: { results: 'not-array' }` | `Array.isArray` fails → false | false ✅ |
+| `results` is null | `result: { results: null }` | `Array.isArray(null)` false | false ✅ |
+| `result.result` is a number | `result: 42` | typeof guard rejects | false ✅ |
+| `result.result` is a string | `result: 'str'` | typeof guard rejects | false ✅ |
+| Both `.places` and `.results` present | both keys in result | Must use `.results` | `getPlaceResults` returns `.results` item ✅ |
+| `results` is an object `{foo:'bar'}` | not an array | `Array.isArray` false | false ✅ |
+| `promptTripContext` with empty collectionName string | `'' \|\| 'London' \|\| ''` | Should fall through to destination | 'London' ✅ |
+
+### MUTATION_ESCAPES: 0/5
+
+Mutation checks applied:
+1. `result.result !== null` guard removed → S5 (null result) would crash `Array.isArray(null.results)` and be caught.
+2. `Array.isArray(...)` replaced with truthy check → A1 (string results) test would catch.
+3. `result.name === 'search_places'` removed → S4 (wrong tool name) would catch.
+4. `.results` key swapped back to `.places` → S1 (standard payload) would return empty array, caught.
+5. `collectionName || destination` order swapped → PTC1 test would return wrong value, caught.
+
+All 5 mutations would be caught by existing assertions.
+
+### LESSON_CHECKS
+
+- `.places` vs `.results` key mismatch (F3a critical bug from discovery) — **confirmed fixed**: S1 passes with `.results`; S2 regression guard confirms `.places` no longer triggers card rendering.
+- Pre-existing 6 svelte-check warnings — **confirmed**, no new warnings introduced.
+
+---
+
+## Completion Summary
+
+- **Status**: ALL COMPLETE (F1 + F2 + F3)
+- **Date**: 2026-03-09
+- **All tasks**: Implemented, reviewed (APPROVED score 0), and tested (PASS standard + adversarial)
+- **Zero regressions**: Frontend 0 errors / 6 pre-existing warnings; backend 24/30 pass (6 pre-existing failures)
+- **Files changed**:
+ - `backend/server/chat/views/__init__.py` — F1 (model list expansion) + F2 (itinerary stops context injection)
+ - `backend/server/chat/llm_client.py` — F2 (system prompt trip-level guidance)
+ - `frontend/src/routes/collections/[id]/+page.svelte` — F2 (multi-stop `deriveCollectionDestination`)
+ - `frontend/src/lib/components/AITravelChat.svelte` — F3 (itinerary-centric prompts + `.results` key fix)
+- **Knowledge recorded**: [knowledge.md](../knowledge.md#multi-stop-context-derivation-f2-follow-up) (multi-stop context, quick prompts, search_places key convention, opencode_zen model list)
+- **Decisions recorded**: [decisions.md](../decisions.md#critic-gate-travel-agent-context--models-follow-up) (critic gate)
+- **AGENTS.md updated**: Chat model override pattern (dropdown) + chat context pattern added
+
+---
+
+## Discovery: runtime failures (2026-03-09)
+
+Explorer investigation of three user-trace errors against the complete scoped file set.
+
+### Error 1 — "The model provider rate limit was reached"
+
+**Exact origin**: `backend/server/chat/llm_client.py` **lines 128–132** (`_safe_error_payload`):
+```python
+if isinstance(exc, rate_limit_cls):
+ return {
+ "error": "The model provider rate limit was reached. Please wait and try again.",
+ "error_category": "rate_limited",
+ }
+```
+The user-trace text `"model provider rate limit was reached"` is a substring of this exact message. This is **not a bug** — it is the intended sanitized error surface for `litellm.exceptions.RateLimitError`. The error is raised by LiteLLM when the upstream provider (OpenAI, Anthropic, etc.) returns HTTP 429, and `_safe_error_payload()` converts it to this user-safe string. The SSE error payload is then propagated through `stream_chat_completion` (line 457) → `event_stream()` in `send_message` (line 256: `if data.get("error"): encountered_error = True; break`) → yielded to frontend → frontend SSE loop sets `assistantMsg.content = parsed.error` (line 307 of `AITravelChat.svelte`).
+
+**Root cause of rate limiting itself**: Most likely `openai/gpt-5-nano` as the `opencode_zen` default model, or the user's provider hitting quota. No code fix required — this is provider-side throttling surfaced correctly. However, if the `opencode_zen` provider is being mistakenly routed to OpenAI's public endpoint instead of `https://opencode.ai/zen/v1`, it would exhaust a real OpenAI key rather than Zen. See Risk 1 below.
+
+**No auth/session issue involved** — the error path reaches LiteLLM, meaning auth already succeeded up to the LLM call.
+
+---
+
+### Error 2 — `{"error":"location is required"}`
+
+**Exact origin**: `backend/server/chat/agent_tools.py` **line 128**:
+```python
+if not location_name:
+ return {"error": "location is required"}
+```
+Triggered when LLM calls `search_places({})` or `search_places({"category": "food"})` with no `location` argument. This happens when the system prompt's trip context does not give the model a geocodable string — the model knows a "trip name" but not a city/country, so it calls `search_places` without a location.
+
+**Current state (post-F2)**: The F2 fix injects `"Itinerary stops: Rome, Italy; ..."` into the system prompt from `collection.locations` **only when `collection_id` is supplied and resolves to an authorized collection**. If `collection_id` is missing from the frontend payload OR if the collection has locations with no `city`/`country` FK and no `location`/`name` fallback, the context_parts will still have only the `destination` string.
+
+**Residual trigger path** (still reachable after F2):
+- `collection_id` not sent in `send_message` payload → collection never fetched → `context_parts` has only `Destination: ` → LLM picks a trip-name string like "Italy 2025" as its location arg → `search_places(location="Italy 2025")` succeeds (geocoding finds "Italy") OR model sends `search_places({})` → error returned.
+- OR: `collection_id` IS sent, all locations have no `city`/`country` AND `location` field is blank AND `name` is not geocodable (e.g., `"Hotel California"`) → `itinerary_stops` list is empty → no `Itinerary stops:` line injected.
+
+**Second remaining trigger**: `get_trip_details` fails (Collection.DoesNotExist or exception) → returns `{"error": "An unexpected error occurred while fetching trip details"}` → model falls back to calling `search_places` without a location derived from context.
+
+---
+
+### Error 3 — `{"error":"An unexpected error occurred while fetching trip details"}`
+
+**Exact origin**: `backend/server/chat/agent_tools.py` **lines 394–396** (`get_trip_details`):
+```python
+ except Exception:
+ logger.exception("get_trip_details failed")
+ return {"error": "An unexpected error occurred while fetching trip details"}
+```
+
+**Root cause — `get_trip_details` uses owner-only filter**: `agent_tools.py` **line 317**:
+```python
+collection = (
+ Collection.objects.filter(user=user)
+ ...
+ .get(id=collection_id)
+)
+```
+This uses `filter(user=user)` — **shared collections are excluded**. If the logged-in user is a shared member (not the owner) of the collection, `Collection.DoesNotExist` is raised, falls to the outer `except Exception`, and returns the generic error. However, `Collection.DoesNotExist` is caught specifically on **line 392** and returns `{"error": "Trip not found"}`, not the generic message. So the generic error can only come from a genuine Python exception inside the try block — most likely:
+
+1. **`item.item` AttributeError** — `CollectionItineraryItem` uses a `GenericForeignKey`; if the referenced object has been deleted, `item.item` returns `None` and `getattr(None, "name", "")` would return `""` (safe, not an error) — so this is not the cause.
+2. **`collection.itinerary_items` reverse relation** — if the `related_name="itinerary_items"` is not defined on `CollectionItineraryItem.collection` FK, the queryset call raises `AttributeError`. Checking `adventures/models.py` line 716: `related_name="itinerary_items"` is present — so this is not the cause.
+3. **`collection.transportation_set` / `collection.lodging_set`** — if `Transportation` or `Lodging` doesn't have `related_name` defaulting to `transportation_set`/`lodging_set`, these would fail. This is the **most likely cause** — Django only auto-creates `_set` accessors with the model name in lowercase; `transportation_set` requires that the FK `related_name` is either set or left as default `transportation_set`. Need to verify model definition.
+4. **`collection.start_date.isoformat()` on None** — guarded by `if collection.start_date` (line 347) — safe.
+
+**Verified**: `Transportation.collection` (`models.py:332`) and `Lodging.collection` (`models.py:570`) are both ForeignKeys with **no `related_name`**, so Django auto-assigns `transportation_set` and `lodging_set` — the accessors used in `get_trip_details` lines 375/382 are correct. These do NOT cause the error.
+
+**Actual culprit**: The `except Exception` at line 394 catches everything. Any unhandled exception inside the try block (e.g., a `prefetch_related("itinerary_items__content_type")` failure if a content_type row is missing, or a `date` field deserialization error on a malformed DB record) results in the generic error. Most commonly, the issue is the **shared-user access gap**: `Collection.objects.filter(user=user).get(id=...)` raises `Collection.DoesNotExist` for shared users, but that is caught by the specific handler at line 392 as `{"error": "Trip not found"}`, NOT the generic message. The generic message therefore indicates a true runtime Python exception somewhere inside the try body.
+
+**Additionally**: the shared-collection access gap means `get_trip_details` returns `{"error": "Trip not found"}` (not the generic error) for shared users — this is a separate functional bug where shared users cannot use the AI tool on their shared trips.
+
+---
+
+### Authentication / CSRF in Chat Calls
+
+**Verdict: Auth is working correctly for the SSE path. No auth failure in the reported errors.**
+
+Evidence:
+1. **Proxy path** (`frontend/src/routes/api/[...path]/+server.ts`):
+ - `POST` to `send_message` goes through `handleRequest()` (line 16) with `requreTrailingSlash=true`.
+ - On every proxied request: proxy deletes old `csrftoken` cookie, calls `fetchCSRFToken()` to get a fresh token from `GET /csrf/`, then sets `X-CSRFToken` header and reconstructs the `Cookie` header with `csrftoken=; sessionid=` (lines 57–75).
+ - SSE streaming: `content-type: text/event-stream` is detected (line 94) and the response body is streamed directly without buffering.
+2. **Session**: `sessionid` cookie is extracted from browser cookies (line 66) and forwarded. `SESSION_COOKIE_SAMESITE=Lax` allows this.
+3. **Rate-limit error is downstream of auth** — LiteLLM only fires if the Django view already authenticated the user and reached `stream_chat_completion`. A CSRF or session failure would return HTTP 403/401 before the SSE stream starts, and the frontend would hit the `if (!res.ok)` branch (line 273), not the SSE error path.
+
+**One auth-adjacent gap**: `loadConversations()` (line 196) and `createConversation()` (line 203) do NOT include `credentials: 'include'` — but these go through the SvelteKit proxy which handles session injection server-side, so this is not a real failure point. The `send_message` fetch (line 258) also lacks explicit `credentials`, but again routes through the proxy.
+
+**Potential auth issue — missing trailing slash for models endpoint**:
+`loadModelsForProvider()` fetches `/api/chat/providers/${selectedProvider}/models/` (line 124) — this ends with `/` which is correct for the proxy's `requreTrailingSlash` logic. However, the proxy only adds a trailing slash for non-GET requests (it's applied to POST/PATCH/PUT/DELETE but not GET). Since `models/` is already in the URL, this is fine.
+
+---
+
+### Ranked Fixes by Impact
+
+| Rank | Error | File | Line(s) | Fix |
+|---|---|---|---|---|
+| 1 (HIGH) | `get_trip_details` generic error | `backend/server/chat/agent_tools.py` | 316–325 | Add `\| Q(shared_with=user)` to collection filter so shared users can call the tool; also add specific catches for known exception types before the bare `except Exception` |
+| 2 (HIGH) | `{"error":"location is required"}` residual | `backend/server/chat/views/__init__.py` | 152–164 | Ensure `collection_id` auth check also grants access for shared users (currently `shared_with.filter(id=request.user.id).exists()` IS present — ✅ already correct); verify `collection_id` is actually being sent from frontend on every `sendMessage` call |
+| 2b (MEDIUM) | `search_places` called without location | `backend/server/chat/agent_tools.py` | 127–128 | Improve error message to be user-instructional: `"Please provide a city or location name to search near."` — already noted in prior plan; also add `location` as a `required` field in the JSON schema so LLM is more likely to provide it |
+| 3 (MEDIUM) | `transportation_set`/`lodging_set` crash | `backend/server/chat/agent_tools.py` | 370–387 | Verify FK `related_name` values on Transportation/Lodging models; if wrong, correct the accessor names in `get_trip_details` |
+| 4 (LOW) | Rate limiting | Provider config | N/A | No code fix — operational issue. Document that `opencode_zen` uses `https://opencode.ai/zen/v1` as `api_base` (already set in `CHAT_PROVIDER_CONFIG`) — ensure users aren't accidentally using a real OpenAI key with `opencode_zen` provider |
+
+---
+
+### Risks
+
+1. **`get_trip_details` shared-user gap**: Shared users get `{"error": "Trip not found"}` — the LLM may then call `search_places` without the location context that `get_trip_details` would have provided, cascading into Error 2. Fix: add `| Q(shared_with=user)` to the collection filter at `agent_tools.py:317`.
+
+2. **`transportation_set`/`lodging_set` reverse accessor names confirmed safe**: Django auto-generates `transportation_set` and `lodging_set` for the FKs (no `related_name` on `Transportation.collection` at `models.py:332` or `Lodging.collection` at `models.py:570`). These accessors work correctly. The generic error in `get_trip_details` must be from another exception path (e.g., malformed DB records, missing ContentType rows for deleted itinerary items, or the `prefetch_related` interaction on orphaned GFK references).
+
+3. **`collection_id` not forwarded on all sends**: If `AITravelChat.svelte` is embedded without `collectionId` prop (e.g., standalone chat page), `collection_id` is `undefined` in the payload, the backend never fetches the collection, and no `Itinerary stops:` context is injected. The LLM then has no geocodable location data → calls `search_places` without `location`.
+
+4. **`search_places` JSON schema marks `location` as required but `execute_tool` uses `filtered_kwargs`**: The tool schema (`agent_tools.py:103`) sets `"required": True` on `location`. However, `execute_tool` (line 619) passes only `filtered_kwargs` from the JSON-parsed `arguments` dict. If LLM sends `{}` (empty), `location=None` is the function default, not a schema-enforcement error. There is no server-side validation of required tool arguments — the required flag is only advisory to the LLM.
+
+**See [decisions.md](../decisions.md) for critic gate context.**
+
+---
+
+## Research: Provider Strategy (2026-03-09)
+
+**Full findings**: [research/provider-strategy.md](../research/provider-strategy.md)
+
+### Verdict: Keep LiteLLM, Harden It
+
+Replacing LiteLLM is not warranted. Every Voyage issue is in the integration layer (no retries, no capability checks, hardcoded models), not in LiteLLM itself. OpenCode's Python-equivalent IS LiteLLM — OpenCode uses Vercel AI SDK with ~20 bundled `@ai-sdk/*` provider packages, which is the TypeScript analogue.
+
+### Architecture Options
+
+| Option | Effort | Risk | Recommended? |
+|---|---|---|---|
+| **A. Keep LiteLLM, harden** (retry, tool-guard, metadata) | Low (1-2 sessions) | Low | ✅ YES |
+| B. Hybrid: direct SDK for some providers | High (1-2 weeks) | High | No |
+| C. Replace LiteLLM entirely | Very High (3-4 weeks) | Very High | No |
+| D. LiteLLM Proxy sidecar | Medium (2-3 days) | Medium | Not yet — future multi-user |
+
+### Immediate Code Fixes (4 items)
+
+| # | Fix | File | Line(s) | Impact |
+|---|---|---|---|---|
+| 1 | Add `num_retries=2, request_timeout=60` to `litellm.acompletion()` | `llm_client.py` | 418 | Retry on rate-limit/timeout — biggest gap |
+| 2 | Add `litellm.supports_function_calling(model=)` guard before passing tools | `llm_client.py` | ~397 | Prevents tool-call errors on incapable models |
+| 3 | Return model objects with `supports_tools` metadata instead of bare strings | `views/__init__.py` | `models()` action | Frontend can warn/adapt per model capability |
+| 4 | Replace hardcoded `model="gpt-4o-mini"` with provider config default | `day_suggestions.py` | 194 | Respects user's configured provider |
+
+### Long-Term Recommendations
+
+1. **Curated model registry** (YAML/JSON file like OpenCode's `models.dev`) with capabilities, costs, context limits — loaded at startup
+2. **LiteLLM Proxy sidecar** — only if/when Voyage gains multi-user production deployment
+3. **WSGI→ASGI migration** — long-term fix for event loop fragility (out of scope)
+
+### Key Patterns Observed in Other Projects
+
+- **No production project does universal runtime model discovery** — all use curated/admin-managed lists
+- **Every production LiteLLM user has retry logic** — Voyage is the outlier with zero retries
+- **Tool-call capability guards** are standard (`litellm.supports_function_calling()` used by PraisonAI, open-interpreter, mem0, ragbits, dspy)
+- **Rate-limit resilience** ranges from simple `num_retries` to full `litellm.Router` with `RetryPolicy` and cross-model fallbacks
diff --git a/.memory/research/.gitkeep b/.memory/research/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/.memory/research/auto-learn-preference-signals.md b/.memory/research/auto-learn-preference-signals.md
new file mode 100644
index 00000000..6f1efcb6
--- /dev/null
+++ b/.memory/research/auto-learn-preference-signals.md
@@ -0,0 +1,130 @@
+# Research: Auto-Learn User Preference Signals
+
+## Purpose
+Map all existing user data that could be aggregated into an automatic preference profile, without requiring manual input.
+
+## Signal Inventory
+
+### 1. Location.category (FK → Category)
+- **Model**: `adventures/models.py:Category` — per-user custom categories (name, display_name, icon)
+- **Signal**: Top categories by count → dominant interest type (e.g. "hiking", "dining", "cultural")
+- **Query**: `Location.objects.filter(user=user).values('category__name').annotate(cnt=Count('id')).order_by('-cnt')`
+- **Strength**: HIGH — user-created categories are deliberate choices
+
+### 2. Location.tags (ArrayField)
+- **Model**: `adventures/models.py:Location.tags` — `ArrayField(CharField(max_length=100))`
+- **Signal**: Most frequent tags across all user locations → interest keywords
+- **Query**: `Location.objects.filter(user=user).values_list('tags', flat=True).distinct()` (used in `tags_view.py`)
+- **Strength**: MEDIUM-HIGH — tags are free-text user input
+
+### 3. Location.rating (FloatField)
+- **Model**: `adventures/models.py:Location.rating`
+- **Signal**: Average rating + high-rated locations → positive sentiment for place types; filtering for visited + high-rated → strong preferences
+- **Query**: `Location.objects.filter(user=user).aggregate(avg_rating=Avg('rating'))` or breakdown by category
+- **Strength**: HIGH for positive signals (≥4.0); weak if rarely filled in
+
+### 4. Location.description / Visit.notes (TextField)
+- **Model**: `adventures/models.py:Location.description`, `Visit.notes`
+- **Signal**: Free-text content for NLP keyword extraction (budget, adventure, luxury, cuisine words)
+- **Query**: `Location.objects.filter(user=user).values_list('description', flat=True)`
+- **Strength**: LOW (requires NLP to extract structured signals; many fields blank)
+
+### 5. Lodging.type (LODGING_TYPES enum)
+- **Model**: `adventures/models.py:Lodging.type` — choices: hotel, hostel, resort, bnb, campground, cabin, apartment, house, villa, motel
+- **Signal**: Most frequently used lodging type → travel style indicator (e.g. "hostel" → budget; "resort/villa" → luxury; "campground/cabin" → outdoor)
+- **Query**: `Lodging.objects.filter(user=user).values('type').annotate(cnt=Count('id')).order_by('-cnt')`
+- **Strength**: HIGH — directly maps to trip_style field
+
+### 6. Lodging.rating (FloatField)
+- **Signal**: Combined with lodging type, identifies preferred accommodation standards
+- **Strength**: MEDIUM
+
+### 7. Transportation.type (TRANSPORTATION_TYPES enum)
+- **Model**: `adventures/models.py:Transportation.type` — choices: car, plane, train, bus, boat, bike, walking
+- **Signal**: Primary transport mode → mobility preference (e.g. mostly walking/bike → slow travel; lots of planes → frequent flyer)
+- **Query**: `Transportation.objects.filter(user=user).values('type').annotate(cnt=Count('id')).order_by('-cnt')`
+- **Strength**: MEDIUM
+
+### 8. Activity.sport_type (SPORT_TYPE_CHOICES)
+- **Model**: `adventures/models.py:Activity.sport_type` — 60+ choices mapped to 10 SPORT_CATEGORIES in `utils/sports_types.py`
+- **Signal**: Activity categories user is active in → physical/adventure interests
+- **Categories**: running, walking_hiking, cycling, water_sports, winter_sports, fitness_gym, racket_sports, climbing_adventure, team_sports
+- **Query**: Already aggregated in `stats_view.py:_get_activity_stats_by_category()` — uses `Activity.objects.filter(user=user).values('sport_type').annotate(count=Count('id'))`
+- **Strength**: HIGH — objective behavioral data from Strava/Wanderer imports
+
+### 9. VisitedRegion / VisitedCity (worldtravel)
+- **Model**: `worldtravel/models.py` — `VisitedRegion(user, region)` and `VisitedCity(user, city)` with country/subregion
+- **Signal**: Countries/regions visited → geographic preferences (beach vs. mountain vs. city; EU vs. Asia etc.)
+- **Query**: `VisitedRegion.objects.filter(user=user).select_related('region__country')` → country distribution
+- **Strength**: MEDIUM-HIGH — "where has this user historically traveled?" informs destination type
+
+### 10. Collection metadata
+- **Model**: `adventures/models.py:Collection` — name, description, start/end dates
+- **Signal**: Collection names/descriptions may contain destination/theme hints; trip duration (end_date − start_date) → travel pace; trip frequency (count, spacing) → travel cadence
+- **Query**: `Collection.objects.filter(user=user).values('name', 'description', 'start_date', 'end_date')`
+- **Strength**: LOW-MEDIUM (descriptions often blank; names are free-text)
+
+### 11. Location.price / Lodging.price (MoneyField)
+- **Signal**: Average spend across locations/lodging → budget tier
+- **Query**: `Location.objects.filter(user=user).aggregate(avg_price=Avg('price'))` (requires djmoney amount field)
+- **Strength**: MEDIUM — but many records may have no price set
+
+### 12. Location geographic clustering (lat/lon)
+- **Signal**: Country/region distribution of visited locations → geographic affinity
+- **Already tracked**: `Location.country`, `Location.region`, `Location.city` (FK, auto-geocoded)
+- **Query**: `Location.objects.filter(user=user).values('country__name').annotate(cnt=Count('id')).order_by('-cnt')`
+- **Strength**: HIGH
+
+### 13. UserAchievement types
+- **Model**: `achievements/models.py:UserAchievement` — types: `adventure_count`, `country_count`
+- **Signal**: Milestone count → engagement level (casual vs. power user); high `country_count` → variety-seeker
+- **Strength**: LOW-MEDIUM (only 2 types currently)
+
+### 14. ChatMessage content (user role)
+- **Model**: `chat/models.py:ChatMessage` — `role`, `content`
+- **Signal**: User messages in travel conversations → intent signals ("I love hiking", "looking for cheap food", "family-friendly")
+- **Query**: `ChatMessage.objects.filter(conversation__user=user, role='user').values_list('content', flat=True)`
+- **Strength**: MEDIUM — requires NLP; could be rich but noisy
+
+## Aggregation Patterns Already in Codebase
+
+| Pattern | Location | Reusability |
+|---|---|---|
+| Activity stats by category | `stats_view.py:_get_activity_stats_by_category()` | Direct reuse |
+| All-tags union | `tags_view.py:ActivityTypesView.types()` | Direct reuse |
+| VisitedRegion/City counts | `stats_view.py:counts()` | Direct reuse |
+| Multi-user preference merge | `llm_client.py:get_aggregated_preferences()` | Partial reuse |
+| Category-filtered location count | `serializers.py:location_count` | Pattern reference |
+| Location queryset scoping | `location_view.py:get_queryset()` | Standard pattern |
+
+## Proposed Auto-Profile Fields from Signals
+
+| Target Field | Primary Signals | Secondary Signals |
+|---|---|---|
+| `cuisines` | Location.tags (cuisine words), Location.category (dining) | Location.description NLP |
+| `interests` | Activity.sport_type categories, Location.category top-N | Location.tags frequency, VisitedRegion types |
+| `trip_style` | Lodging.type top (luxury/budget/outdoor), Transportation.type, Activity sport categories | Location.rating Avg, price signals |
+| `notes` | (not auto-derived — keep manual only) | — |
+
+## Where to Implement
+
+**New function target**: `integrations/views/recommendation_profile_view.py` or a new `integrations/utils/auto_profile.py`
+
+**Suggested function signature**:
+```python
+def build_auto_preference_profile(user) -> dict:
+ """
+ Returns {cuisines, interests, trip_style} inferred from user's travel history.
+ Fields are non-destructive suggestions, not overrides of manual input.
+ """
+```
+
+**New API endpoint target**: `POST /api/integrations/recommendation-preferences/auto-learn/`
+**ViewSet action**: `@action(detail=False, methods=['post'], url_path='auto-learn')` on `UserRecommendationPreferenceProfileViewSet`
+
+## Integration Point
+`get_system_prompt()` in `chat/llm_client.py` already consumes `UserRecommendationPreferenceProfile` — auto-learned values
+flow directly into AI context with zero additional changes needed there.
+
+See: [knowledge.md — User Recommendation Preference Profile](../knowledge.md#user-recommendation-preference-profile)
+See: [plans/ai-travel-agent-redesign.md — WS2](../plans/ai-travel-agent-redesign.md#ws2-user-preference-learning)
diff --git a/.memory/research/litellm-zen-provider-catalog.md b/.memory/research/litellm-zen-provider-catalog.md
new file mode 100644
index 00000000..c351a500
--- /dev/null
+++ b/.memory/research/litellm-zen-provider-catalog.md
@@ -0,0 +1,35 @@
+# Research: LiteLLM provider catalog and OpenCode Zen support
+
+Date: 2026-03-08
+Related plan: [AI travel agent in Collections Recommendations](../plans/ai-travel-agent-collections-integration.md)
+
+## LiteLLM provider enumeration
+- Runtime provider list is available via `litellm.provider_list` and currently returns 128 provider IDs in this environment.
+- The enum source `LlmProviders` can be used for canonical provider identifiers.
+
+## OpenCode Zen compatibility
+- OpenCode Zen is **not** a native LiteLLM provider alias.
+- Zen can be supported via LiteLLM's OpenAI-compatible routing using:
+ - provider id in app: `opencode_zen`
+ - model namespace: `openai/`
+ - `api_base`: `https://opencode.ai/zen/v1`
+- No new SDK dependency required.
+
+## Recommended backend contract
+- Add backend source-of-truth endpoint: `GET /api/chat/providers/`.
+- Response fields:
+ - `id`
+ - `label`
+ - `available_for_chat`
+ - `needs_api_key`
+ - `default_model`
+ - `api_base`
+- Return all LiteLLM runtime providers; mark non-mapped providers `available_for_chat=false` for display-only compliance.
+
+## Data/storage compatibility notes
+- Existing `UserAPIKey(provider)` model supports adding `opencode_zen` without migration.
+- Consistent provider ID usage across serializer validation, key lookup, and chat request payload is required.
+
+## Risks
+- Zen model names may evolve; keep default model configurable in backend mapping.
+- Full provider list is large; UI should communicate unavailable-for-chat providers clearly.
diff --git a/.memory/research/opencode-zen-connection-debug.md b/.memory/research/opencode-zen-connection-debug.md
new file mode 100644
index 00000000..2d6b5b83
--- /dev/null
+++ b/.memory/research/opencode-zen-connection-debug.md
@@ -0,0 +1,303 @@
+# OpenCode Zen Connection Debug — Research Findings
+
+**Date**: 2026-03-08
+**Researchers**: researcher agent (root cause), explorer agent (code path trace)
+**Status**: Complete — root causes identified, fix proposed
+
+## Summary
+
+The OpenCode Zen provider configuration in `backend/server/chat/llm_client.py` has **two critical mismatches** that cause connection/API errors:
+
+1. **Invalid model ID**: `gpt-4o-mini` does not exist on OpenCode Zen
+2. **Wrong endpoint for GPT models**: GPT models on Zen use `/responses` endpoint, not `/chat/completions`
+
+An additional structural risk is that the backend runs under **Gunicorn WSGI** (not ASGI/uvicorn), but `stream_chat_completion` is an `async def` generator that is driven via `_async_to_sync_generator` which creates a new event loop per call. This works but causes every tool iteration to open/close an event loop, which is inefficient and fragile under load.
+
+## End-to-End Request Path
+
+### 1. Frontend: `AITravelChat.svelte` → `sendMessage()`
+- **File**: `frontend/src/lib/components/AITravelChat.svelte`, line 97
+- POST body: `{ message: , provider: selectedProvider }` (e.g. `"opencode_zen"`)
+- Sends to: `POST /api/chat/conversations//send_message/`
+- On `fetch` network failure: shows `$t('chat.connection_error')` = `"Connection error. Please try again."` (line 191)
+- On HTTP error: tries `res.json()` → uses `err.error || $t('chat.connection_error')` (line 126)
+- On SSE `parsed.error`: shows `parsed.error` inline in the chat (line 158)
+- **Any exception from `litellm` is therefore masked as `"An error occurred while processing your request."` or `"Connection error. Please try again."`**
+
+### 2. Proxy: `frontend/src/routes/api/[...path]/+server.ts` → `handleRequest()`
+- Strips and re-generates CSRF token (line 57-60)
+- POSTs to `http://server:8000/api/chat/conversations//send_message/`
+- Detects `content-type: text/event-stream` and streams body directly through (lines 94-98) — **no buffering**
+- On any fetch error: returns `{ error: 'Internal Server Error' }` (line 109)
+
+### 3. Backend: `chat/views.py` → `ChatViewSet.send_message()`
+- Validates provider via `is_chat_provider_available()` (line 114) — passes for `opencode_zen`
+- Saves user message to DB (line 120)
+- Builds LLM messages list (line 131)
+- Wraps `async event_stream()` in `_async_to_sync_generator()` (line 269)
+- Returns `StreamingHttpResponse` with `text/event-stream` content type (line 268)
+
+### 4. Backend: `chat/llm_client.py` → `stream_chat_completion()`
+- Normalizes provider (line 208)
+- Looks up `CHAT_PROVIDER_CONFIG["opencode_zen"]` (line 209)
+- Fetches API key from `UserAPIKey.objects.get(user=user, provider="opencode_zen")` (line 154)
+- Decrypts it via Fernet using `FIELD_ENCRYPTION_KEY` (line 102)
+- Calls `litellm.acompletion(model="openai/gpt-4o-mini", api_key=, api_base="https://opencode.ai/zen/v1", stream=True, tools=AGENT_TOOLS, tool_choice="auto")` (line 237)
+- On **any exception**: logs and yields `data: {"error": "An error occurred..."}` (lines 274-276)
+
+## Root Cause Analysis
+
+### #1 CRITICAL: Invalid default model `gpt-4o-mini`
+- **Location**: `backend/server/chat/llm_client.py:62`
+- `CHAT_PROVIDER_CONFIG["opencode_zen"]["default_model"] = "openai/gpt-4o-mini"`
+- `gpt-4o-mini` is an OpenAI-hosted model. The OpenCode Zen gateway at `https://opencode.ai/zen/v1` does not offer `gpt-4o-mini`.
+- LiteLLM sends: `POST https://opencode.ai/zen/v1/chat/completions` with `model: gpt-4o-mini`
+- Zen API returns HTTP 4xx (model not found or not available)
+- Exception is caught generically at line 274 → yields masked error SSE → frontend shows generic message
+
+### #2 SIGNIFICANT: Generic exception handler masks real errors
+- **Location**: `backend/server/chat/llm_client.py:274-276`
+- Bare `except Exception:` with logger.exception and a generic user message
+- LiteLLM exceptions carry structured information: `litellm.exceptions.NotFoundError`, `AuthenticationError`, `BadRequestError`, etc.
+- All of these show up to the user as `"An error occurred while processing your request. Please try again."`
+- Prevents diagnosis without checking Docker logs
+
+### #3 SIGNIFICANT: WSGI + async event loop per request
+- **Location**: `backend/server/chat/views.py:66-76` (`_async_to_sync_generator`)
+- Backend runs **Gunicorn WSGI** (from `supervisord.conf:11`); there is **no ASGI entry point** (`asgi.py` doesn't exist)
+- `stream_chat_completion` is `async def` using `litellm.acompletion` (awaited)
+- `_async_to_sync_generator` creates a fresh event loop via `asyncio.new_event_loop()` for each request
+- For multi-tool-iteration responses this loop drives multiple sequential `await` calls
+- This works but is fragile: if `litellm.acompletion` internally uses a singleton HTTP client that belongs to a different event loop, it will raise `RuntimeError: This event loop is already running` or connection errors on subsequent calls
+- **httpx/aiohttp sessions in LiteLLM may not be compatible with per-call new event loops**
+
+### #4 MINOR: `tool_choice: "auto"` sent unconditionally with tools
+- **Location**: `backend/server/chat/llm_client.py:229`
+- `"tool_choice": "auto" if tools else None` — None values in kwargs are passed to litellm
+- Some OpenAI-compat endpoints (including potentially Zen models) reject `tool_choice: null` or unsupported parameters
+- Fix: remove key entirely instead of setting to None
+
+### #5 MINOR: API key lookup is synchronous in async context
+- **Location**: `backend/server/chat/llm_client.py:217` and `views.py:144`
+- `get_llm_api_key` calls `UserAPIKey.objects.get(...)` synchronously
+- Called from within `async for chunk in stream_chat_completion(...)` in the async `event_stream()` generator
+- Django ORM operations must use `sync_to_async` in async contexts; direct sync ORM calls can cause `SynchronousOnlyOperation` errors or deadlocks under ASGI
+- Under WSGI+new-event-loop approach this is less likely to fail but is technically incorrect
+
+## Recommended Fix (Ranked by Impact)
+
+### Fix #1 (Primary): Correct the default model
+```python
+# backend/server/chat/llm_client.py:59-64
+"opencode_zen": {
+ "label": "OpenCode Zen",
+ "needs_api_key": True,
+ "default_model": "openai/gpt-5-nano", # Free; confirmed to work via /chat/completions
+ "api_base": "https://opencode.ai/zen/v1",
+},
+```
+Confirmed working models (use `/chat/completions`, OpenAI-compat):
+- `openai/gpt-5-nano` (free)
+- `openai/kimi-k2.5` (confirmed by GitHub usage)
+- `openai/glm-5` (GLM family)
+- `openai/big-pickle` (free)
+
+GPT family models route through `/responses` endpoint on Zen, which LiteLLM's openai-compat mode does NOT use — only the above "OpenAI-compatible" models on Zen reliably work with LiteLLM's `openai/` prefix + `/chat/completions`.
+
+### Fix #2 (Secondary): Structured error surfacing
+```python
+# backend/server/chat/llm_client.py:274-276
+except Exception as exc:
+ logger.exception("LLM streaming error")
+ # Extract structured detail if available
+ status_code = getattr(exc, 'status_code', None)
+ detail = getattr(exc, 'message', None) or str(exc)
+ user_msg = f"Provider error ({status_code}): {detail}" if status_code else "An error occurred while processing your request. Please try again."
+ yield f"data: {json.dumps({'error': user_msg})}\n\n"
+```
+
+### Fix #3 (Minor): Remove None from tool_choice kwarg
+```python
+# backend/server/chat/llm_client.py:225-234
+completion_kwargs = {
+ "model": provider_config["default_model"],
+ "messages": messages,
+ "stream": True,
+ "api_key": api_key,
+}
+if tools:
+ completion_kwargs["tools"] = tools
+ completion_kwargs["tool_choice"] = "auto"
+if provider_config["api_base"]:
+ completion_kwargs["api_base"] = provider_config["api_base"]
+```
+
+## Error Flow Diagram
+
+```
+User sends message (opencode_zen)
+ → AITravelChat.svelte:sendMessage()
+ → POST /api/chat/conversations//send_message/
+ → +server.ts:handleRequest() [proxy, no mutation]
+ → POST http://server:8000/api/chat/conversations//send_message/
+ → views.py:ChatViewSet.send_message()
+ → llm_client.py:stream_chat_completion()
+ → litellm.acompletion(model="openai/gpt-4o-mini", ← FAILS HERE
+ api_base="https://opencode.ai/zen/v1")
+ → except Exception → yield data:{"error":"An error occurred..."}
+ ← SSE: data:{"error":"An error occurred..."}
+ ← StreamingHttpResponse(text/event-stream)
+ ← streamed through
+ ← streamed through
+ ← reader.read() → parsed.error set
+ ← assistantMsg.content = "An error occurred..." ← shown to user
+```
+
+If the network/DNS fails entirely (e.g. `https://opencode.ai` unreachable):
+```
+ → litellm.acompletion raises immediately
+ → except Exception → yield data:{"error":"An error occurred..."}
+ — OR —
+ → +server.ts fetch fails → json({error:"Internal Server Error"}, 500)
+ → AITravelChat.svelte res.ok is false → res.json() → err.error || $t('chat.connection_error')
+ → shows "Connection error. Please try again."
+```
+
+## File References
+
+| File | Line(s) | Relevance |
+|---|---|---|
+| `backend/server/chat/llm_client.py` | 59-64 | `CHAT_PROVIDER_CONFIG["opencode_zen"]` — primary fix |
+| `backend/server/chat/llm_client.py` | 150-157 | `get_llm_api_key()` — DB lookup for stored key |
+| `backend/server/chat/llm_client.py` | 203-276 | `stream_chat_completion()` — full LiteLLM call + error handler |
+| `backend/server/chat/llm_client.py` | 225-234 | `completion_kwargs` construction |
+| `backend/server/chat/llm_client.py` | 274-276 | Generic `except Exception` (swallows all errors) |
+| `backend/server/chat/views.py` | 103-274 | `send_message()` — SSE pipeline orchestration |
+| `backend/server/chat/views.py` | 66-76 | `_async_to_sync_generator()` — WSGI/async bridge |
+| `backend/server/integrations/models.py` | 78-112 | `UserAPIKey` — encrypted key storage |
+| `frontend/src/lib/components/AITravelChat.svelte` | 97-195 | `sendMessage()` — SSE consumer + error display |
+| `frontend/src/lib/components/AITravelChat.svelte` | 124-129 | HTTP error → `$t('chat.connection_error')` |
+| `frontend/src/lib/components/AITravelChat.svelte` | 157-160 | SSE `parsed.error` → inline display |
+| `frontend/src/lib/components/AITravelChat.svelte` | 190-192 | Outer catch → `$t('chat.connection_error')` |
+| `frontend/src/routes/api/[...path]/+server.ts` | 34-110 | `handleRequest()` — proxy |
+| `frontend/src/routes/api/[...path]/+server.ts` | 94-98 | SSE passthrough (no mutation) |
+| `frontend/src/locales/en.json` | 46 | `chat.connection_error` = "Connection error. Please try again." |
+| `backend/supervisord.conf` | 11 | Gunicorn WSGI startup (no ASGI) |
+
+---
+
+## Model Selection Implementation Map
+
+**Date**: 2026-03-08
+
+### Frontend Provider/Model Selection State (Current)
+
+In `AITravelChat.svelte`:
+- `selectedProvider` (line 29): `let selectedProvider = 'openai'` — bare string, no model tracking
+- `providerCatalog` (line 30): `ChatProviderCatalogEntry[]` — already contains `default_model: string | null` per entry
+- `chatProviders` (line 31): reactive filtered view of `providerCatalog` (available only)
+- `loadProviderCatalog()` (line 37): populates catalog from `GET /api/chat/providers/`
+- `sendMessage()` (line 97): POST body at line 121 is `{ message: msgText, provider: selectedProvider }` — **no model field**
+- Provider `` (lines 290–298): in the top toolbar of the chat panel
+
+### Request Payload Build Point
+
+`AITravelChat.svelte`, line 118–122:
+```ts
+const res = await fetch(`/api/chat/conversations/${conversation.id}/send_message/`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: msgText, provider: selectedProvider }) // ← ADD model here
+});
+```
+
+### Backend Request Intake Point
+
+`chat/views.py`, `send_message()` (line 104):
+- Line 113: `provider = (request.data.get("provider") or "openai").strip().lower()`
+- Line 144: `stream_chat_completion(request.user, current_messages, provider, tools=AGENT_TOOLS)`
+- **No model extraction**; model comes only from `CHAT_PROVIDER_CONFIG[provider]["default_model"]`
+
+### Backend Model Usage Point
+
+`chat/llm_client.py`, `stream_chat_completion()` (line 203):
+- Line 225–226: `completion_kwargs = { "model": provider_config["default_model"], ... }`
+- This is the **sole place model is resolved** — no override capability exists yet
+
+### Persistence Options Analysis
+
+| Option | Files changed | Migration? | Risk |
+|---|---|---|---|
+| **`localStorage` (recommended)** | `AITravelChat.svelte` only for persistence | No | Lowest: no backend, no schema |
+| `CustomUser` field (`chat_model_prefs` JSONField) | `users/models.py`, `users/serializers.py`, `users/views.py`, migration | **Yes** | Medium: schema change, serializer exposure |
+| `UserAPIKey`-style new model prefs table | new `chat/models.py` + serializer + view + urls + migration | **Yes** | High: new endpoint, multi-file |
+| `UserRecommendationPreferenceProfile` JSONField addition | `integrations/models.py`, serializer, migration | **Yes** | Medium: migration on integrations app |
+
+**Selected**: `localStorage` — key `voyage_chat_model_prefs`, value `Record`.
+
+### File-by-File Edit Plan
+
+#### 1. `backend/server/chat/llm_client.py`
+| Symbol | Change |
+|---|---|
+| `stream_chat_completion(user, messages, provider, tools=None)` | Add `model: str \| None = None` parameter |
+| `completion_kwargs["model"]` (line 226) | Change to `model or provider_config["default_model"]` |
+| (new) validation | If `model` provided: assert it starts with expected LiteLLM prefix or raise SSE error |
+
+#### 2. `backend/server/chat/views.py`
+| Symbol | Change |
+|---|---|
+| `send_message()` (line 104) | Extract `model = (request.data.get("model") or "").strip() or None` |
+| `stream_chat_completion(...)` call (line 144) | Pass `model=model` |
+| (optional validation) | Return 400 if model prefix doesn't match provider |
+
+#### 3. `frontend/src/lib/components/AITravelChat.svelte`
+| Symbol | Change |
+|---|---|
+| (new) `let selectedModel: string` | Initialize from `loadModelPref(selectedProvider)` or `default_model` |
+| (new) `$: selectedProviderEntry` | Reactive lookup of current provider's catalog entry |
+| (new) `$: selectedModel` reset | Reset on provider change; persist with `saveModelPref` |
+| `sendMessage()` body (line 121) | Add `model: selectedModel || undefined` to JSON body |
+| (new) model ` ` in toolbar | Placed after provider ``, `bind:value={selectedModel}`, placeholder = `default_model` |
+| (new) `loadModelPref(provider)` | Read from `localStorage.getItem('voyage_chat_model_prefs')` |
+| (new) `saveModelPref(provider, model)` | Write to `localStorage.setItem('voyage_chat_model_prefs', ...)` |
+
+#### 4. `frontend/src/locales/en.json`
+| Key | Value |
+|---|---|
+| `chat.model_label` | `"Model"` |
+| `chat.model_placeholder` | `"Default model"` |
+
+### Provider-Model Compatibility Validation
+
+The critical constraint is **LiteLLM model-string routing**. LiteLLM uses the `provider/model-name` prefix to determine which SDK client to use:
+- `openai/gpt-5-nano` → OpenAI client (with custom `api_base` for Zen)
+- `anthropic/claude-sonnet-4-20250514` → Anthropic client
+- `groq/llama-3.3-70b-versatile` → Groq client
+
+If user types `anthropic/claude-opus` for `openai` provider, LiteLLM uses Anthropic SDK with OpenAI credentials → guaranteed failure.
+
+**Recommended backend guard** in `send_message()`:
+```python
+if model:
+ expected_prefix = provider_config["default_model"].split("/")[0]
+ if not model.startswith(expected_prefix + "/"):
+ return Response(
+ {"error": f"Model must use '{expected_prefix}/' prefix for provider '{provider}'."},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+```
+
+Exception: `opencode_zen` and `openrouter` accept any prefix (they're routing gateways). Guard should skip prefix check when `api_base` is set (custom gateway).
+
+### Migration Requirement
+
+**NO migration required** for the recommended localStorage approach.
+
+---
+
+## Cross-references
+
+- See [Plan: OpenCode Zen connection error](../plans/opencode-zen-connection-error.md)
+- See [Research: LiteLLM provider catalog](litellm-zen-provider-catalog.md)
+- See [Knowledge: AI Chat](../knowledge.md#ai-chat-collections--recommendations)
diff --git a/.memory/research/provider-strategy.md b/.memory/research/provider-strategy.md
new file mode 100644
index 00000000..7781f71d
--- /dev/null
+++ b/.memory/research/provider-strategy.md
@@ -0,0 +1,198 @@
+# Research: Multi-Provider Strategy for Voyage AI Chat
+
+**Date**: 2026-03-09
+**Researcher**: researcher agent
+**Status**: Complete
+
+## Summary
+
+Investigated how OpenCode, OpenClaw-like projects, and LiteLLM-based production systems handle multi-provider model discovery, auth, rate-limit resilience, and tool-calling compatibility. Assessed whether replacing LiteLLM is warranted for Voyage.
+
+**Bottom line**: Keep LiteLLM, harden it. Replacing LiteLLM would be a multi-week migration with negligible user-facing benefit. LiteLLM already solves the hard problems (100+ provider SDKs, streaming, tool-call translation). Voyage's issues are in the **integration layer**, not in LiteLLM itself.
+
+---
+
+## 1. Pattern Analysis: How Projects Handle Multi-Provider
+
+### 1a. Dynamic Model Discovery
+
+| Project | Approach | Notes |
+|---|---|---|
+| **OpenCode** | Static registry from `models.dev` (JSON database), merged with user config, filtered by env/auth presence | No runtime API calls to providers for discovery; curated model metadata (capabilities, cost, limits) baked in |
+| **Ragflow** | Hardcoded `SupportedLiteLLMProvider` enum + per-provider model lists | Similar to Voyage's current approach |
+| **daily_stock_analysis** | `litellm.Router` model_list config + `fallback_models` list from config file | Runtime fallback, not runtime discovery |
+| **Onyx** | `LLMProvider` DB model + admin UI for model configuration | DB-backed, admin-managed |
+| **LiteLLM Proxy** | YAML config `model_list` with deployment-level params | Static config, hot-reloadable |
+| **Voyage (current)** | `CHAT_PROVIDER_CONFIG` dict + hardcoded `models()` per provider + OpenAI API `client.models.list()` for OpenAI only | Mixed: one provider does live discovery, rest are hardcoded |
+
+**Key insight**: No production project does universal runtime model discovery across all providers. OpenCode — the most sophisticated — uses a curated static database (`models.dev`) with provider/model metadata including capability flags (`toolcall`, `reasoning`, `streaming`). This is the right pattern for Voyage.
+
+### 1b. Provider Auth Handling
+
+| Project | Approach |
+|---|---|
+| **OpenCode** | Multi-source: env vars → `Auth.get()` (stored credentials) → config file → plugin loaders; per-provider custom auth (AWS chains, Google ADC, OAuth) |
+| **LiteLLM Router** | `api_key` per deployment in model_list; env var fallback |
+| **Cognee** | Rate limiter context manager wrapping LiteLLM calls |
+| **Voyage (current)** | Per-user encrypted `UserAPIKey` DB model + instance-level `VOYAGE_AI_API_KEY` env fallback; key fetched per-request |
+
+**Voyage's approach is sound.** Per-user DB-stored keys with instance fallback matches the self-hosted deployment model. No change needed.
+
+### 1c. Rate-Limit Fallback / Retry
+
+| Project | Approach |
+|---|---|
+| **LiteLLM Router** | Built-in: `num_retries`, `fallbacks` (cross-model), `allowed_fails` + `cooldown_time`, `RetryPolicy` (per-exception-type retry counts), `AllowedFailsPolicy` |
+| **daily_stock_analysis** | `litellm.Router` with `fallback_models` list + multi-key support (rotate API keys on rate limit) |
+| **Cognee** | `tenacity` retry decorator with `wait_exponential_jitter` + LiteLLM rate limiter |
+| **Suna** | LiteLLM exception mapping → structured error processor |
+| **Voyage (current)** | Zero retries. Single attempt. `_safe_error_payload()` maps exceptions to user messages but does not retry. |
+
+**This is Voyage's biggest gap.** Every other production system has retry logic. LiteLLM has this built in — Voyage just isn't using it.
+
+### 1d. Tool-Calling Compatibility
+
+| Project | Approach |
+|---|---|
+| **OpenCode** | `capabilities.toolcall` boolean per model in `models.dev` database; models without tool support are filtered from agentic use |
+| **LiteLLM** | `litellm.supports_function_calling(model=)` runtime check; `get_supported_openai_params(model=)` for param filtering |
+| **PraisonAI** | `litellm.supports_function_calling()` guard before tool dispatch |
+| **open-interpreter** | Same `litellm.supports_function_calling()` guard |
+| **Voyage (current)** | No tool-call capability check. `AGENT_TOOLS` always passed. Reasoning models excluded from `opencode_zen` list by critic gate (manual). |
+
+**Actionable gap.** `litellm.supports_function_calling(model=)` exists and should be used before passing `tools` kwarg.
+
+---
+
+## 2. Architecture Options Comparison
+
+| Option | Description | Effort | Risk | Benefit |
+|---|---|---|---|---|
+| **A. Keep LiteLLM, harden** | Add Router for retry/fallback, add `supports_function_calling` guard, curate model lists with capability metadata | **Low** (1-2 sessions) | **Low** — incremental changes to existing working code | Retry resilience, tool-call safety, zero migration |
+| **B. Hybrid: direct SDK for some** | Use `@ai-sdk/*` packages (like OpenCode) for primary providers, LiteLLM for others | **High** (1-2 weeks) | **High** — new TS→Python SDK mismatch, dual streaming paths, test surface explosion | Finer control per provider; no real benefit for Django backend |
+| **C. Replace LiteLLM entirely** | Build custom provider abstraction or adopt Vercel AI SDK (TypeScript-only) | **Very High** (3-4 weeks) | **Very High** — rewrite streaming, tool-call translation, error mapping for each provider | Only makes sense if moving to full-stack TypeScript |
+| **D. LiteLLM Proxy (sidecar)** | Run LiteLLM as a separate proxy service, call it via OpenAI-compatible API | **Medium** (2-3 days) | **Medium** — new Docker service, config management, latency overhead | Centralized config, built-in admin UI, but overkill for single-user self-hosted |
+
+---
+
+## 3. Recommendation
+
+### Immediate (this session / next session): Option A — Harden LiteLLM
+
+**Specific code-level adaptations:**
+
+#### 3a. Add `litellm.Router` for retry + fallback (highest impact)
+
+Replace bare `litellm.acompletion()` with `litellm.Router.acompletion()`:
+
+```python
+# llm_client.py — new module-level router
+import litellm
+from litellm.router import RetryPolicy
+
+_router = None
+
+def _get_router():
+ global _router
+ if _router is None:
+ _router = litellm.Router(
+ model_list=[], # empty — we use router for retry/timeout only
+ num_retries=2,
+ timeout=60,
+ retry_policy=RetryPolicy(
+ AuthenticationErrorRetries=0,
+ RateLimitErrorRetries=2,
+ TimeoutErrorRetries=1,
+ BadRequestErrorRetries=0,
+ ),
+ )
+ return _router
+```
+
+**However**: LiteLLM Router requires models pre-registered in `model_list`. For Voyage's dynamic per-user-key model, the simpler approach is:
+
+```python
+# In stream_chat_completion, add retry params to acompletion:
+response = await litellm.acompletion(
+ **completion_kwargs,
+ num_retries=2,
+ request_timeout=60,
+)
+```
+
+LiteLLM's `acompletion()` accepts `num_retries` directly — no Router needed.
+
+**Files**: `backend/server/chat/llm_client.py` line 418 (add `num_retries=2, request_timeout=60`)
+
+#### 3b. Add tool-call capability guard
+
+```python
+# In stream_chat_completion, before building completion_kwargs:
+effective_model = model or provider_config["default_model"]
+if tools and not litellm.supports_function_calling(model=effective_model):
+ # Strip tools — model doesn't support them
+ tools = None
+ logger.warning("Model %s does not support function calling; tools stripped", effective_model)
+```
+
+**Files**: `backend/server/chat/llm_client.py` around line 397
+
+#### 3c. Curate model lists with tool-call metadata in `models()` endpoint
+
+Instead of returning bare string lists, return objects with capability info:
+
+```python
+# In ChatProviderCatalogViewSet.models():
+if provider in ["opencode_zen"]:
+ return Response({"models": [
+ {"id": "openai/gpt-5-nano", "supports_tools": True},
+ {"id": "openai/gpt-4o-mini", "supports_tools": True},
+ {"id": "openai/gpt-4o", "supports_tools": True},
+ {"id": "anthropic/claude-sonnet-4-20250514", "supports_tools": True},
+ {"id": "anthropic/claude-3-5-haiku-20241022", "supports_tools": True},
+ ]})
+```
+
+**Files**: `backend/server/chat/views/__init__.py` — `models()` action. Frontend `loadModelsForProvider()` would need minor update to handle objects.
+
+#### 3d. Fix `day_suggestions.py` hardcoded model
+
+Line 194 uses `model="gpt-4o-mini"` — doesn't respect provider config or user selection:
+
+```python
+# day_suggestions.py line 193-194
+response = litellm.completion(
+ model="gpt-4o-mini", # BUG: ignores provider config
+```
+
+Should use provider_config default or user-selected model.
+
+**Files**: `backend/server/chat/views/day_suggestions.py` line 194
+
+### Long-term (future sessions)
+
+1. **Adopt `models.dev`-style curated database**: OpenCode's approach of maintaining a JSON/YAML model registry with capabilities, costs, and limits is superior to hardcoded lists. Could be a YAML file in `backend/server/chat/models.yaml` loaded at startup.
+
+2. **LiteLLM Proxy sidecar**: If Voyage gains multi-user production deployment, running LiteLLM as a proxy sidecar gives centralized rate limiting, key management, and an admin dashboard. Not warranted for current self-hosted single/few-user deployment.
+
+3. **WSGI→ASGI migration**: Already documented as out-of-scope, but remains the long-term fix for event loop fragility (see [opencode-zen-connection-debug.md](opencode-zen-connection-debug.md#3-significant-wsgi--async-event-loop-per-request)).
+
+---
+
+## 4. Why NOT Replace LiteLLM
+
+| Concern | Reality |
+|---|---|
+| "LiteLLM is too heavy" | It's a pip dependency (~40MB installed). No runtime sidecar. Same weight as Django itself. |
+| "We could use provider SDKs directly" | Each provider has different streaming formats, tool-call schemas, and error types. LiteLLM normalizes all of this. Reimplementing costs weeks per provider. |
+| "OpenCode doesn't use LiteLLM" | OpenCode is TypeScript + Vercel AI SDK. It has ~20 bundled `@ai-sdk/*` provider packages. The Python equivalent IS LiteLLM. |
+| "LiteLLM has bugs" | All Voyage's issues are in our integration layer (no retries, no capability checks, hardcoded models), not in LiteLLM itself. |
+
+---
+
+## Cross-references
+
+- See [Research: LiteLLM provider catalog](litellm-zen-provider-catalog.md)
+- See [Research: OpenCode Zen connection debug](opencode-zen-connection-debug.md)
+- See [Plan: Travel agent context + models](../plans/travel-agent-context-and-models.md)
+- See [Decisions: Critic Gate](../decisions.md#critic-gate-travel-agent-context--models-follow-up)
diff --git a/.memory/sessions/continuity.md b/.memory/sessions/continuity.md
new file mode 100644
index 00000000..9d9d306b
--- /dev/null
+++ b/.memory/sessions/continuity.md
@@ -0,0 +1,21 @@
+# Session Continuity
+
+## Last Session (2026-03-09)
+- Completed `chat-provider-fixes` change set with three workstreams:
+ - `chat-loop-hardening`: invalid required-arg tool calls now terminate cleanly, not replayed, assistant tool_call history trimmed consistently
+ - `default-ai-settings`: Settings page saves default provider/model via `UserAISettings`; DB defaults authoritative over localStorage; backend fallback uses saved defaults
+ - `suggestion-add-flow`: day suggestions use resolved provider/model (not hardcoded OpenAI); modal normalizes suggestion payloads for add-to-itinerary
+- All three workstreams passed reviewer + tester validation
+- Documentation updated for all three workstreams
+
+## Active Work
+- `chat-provider-fixes` plan complete — all workstreams implemented, reviewed, tested, documented
+- See [plans/](../plans/) for other active feature plans
+- Pre-release policy established — architecture-level changes allowed (see AGENTS.md)
+
+## Known Follow-up Items (from tester findings)
+- No automated test coverage for `UserAISettings` CRUD + precedence logic
+- No automated test coverage for `send_message` streaming loop (tool error short-circuit, multi-tool partial success, `MAX_TOOL_ITERATIONS`)
+- No automated test coverage for `DaySuggestionsView.post()`
+- `get_weather` error `"dates must be a non-empty list"` does not trigger tool-error short-circuit (mitigated by `MAX_TOOL_ITERATIONS`)
+- LLM-generated name/location fields not truncated to `max_length=200` before `LocationSerializer` (low risk)
diff --git a/.memory/system.md b/.memory/system.md
new file mode 100644
index 00000000..c44fcf76
--- /dev/null
+++ b/.memory/system.md
@@ -0,0 +1 @@
+Voyage is a self-hosted travel companion web app (fork of AdventureLog) built with SvelteKit 2 (TypeScript) frontend, Django REST Framework (Python) backend, PostgreSQL/PostGIS, Memcached, and Docker. It provides trip planning with collections/itineraries, AI-powered travel chat with multi-provider LLM support (via LiteLLM), location/lodging/transportation management, user preference learning, and collaborative trip sharing. The project is pre-release — architecture-level changes are allowed. See [knowledge/overview.md](knowledge/overview.md) for architecture and [decisions.md](decisions.md) for ADRs.
diff --git a/AGENTS.md b/AGENTS.md
index 7e02f048..baba1aae 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,7 +13,7 @@ Voyage is **pre-release** — not yet in production use. During pre-release:
## Architecture Overview
- **API proxy pattern**: Frontend never calls Django directly. All API calls go through `frontend/src/routes/api/[...path]/+server.ts`, which proxies to `http://server:8000`, handles cookies, and injects CSRF behavior.
-- **AI chat**: Embedded in Collections → Recommendations via `AITravelChat.svelte` component. No standalone `/chat` route. Provider list is dynamic from backend `GET /api/chat/providers/` (sourced from LiteLLM runtime + custom entries like `opencode_zen`). Chat conversations use SSE streaming via `/api/chat/conversations/`. Chat composer supports per-provider model override (persisted in browser `localStorage`). LiteLLM errors are mapped to sanitized user-safe messages via `_safe_error_payload()` (never exposes raw exception text).
+- **AI chat**: Embedded in Collections → Recommendations via `AITravelChat.svelte` component. No standalone `/chat` route. Provider list is dynamic from backend `GET /api/chat/providers/` (sourced from LiteLLM runtime + custom entries like `opencode_zen`). Chat conversations use SSE streaming via `/api/chat/conversations/`. Default AI provider/model saved via `UserAISettings` in DB (authoritative over browser localStorage). LiteLLM errors are mapped to sanitized user-safe messages via `_safe_error_payload()` (never exposes raw exception text). Invalid tool calls (missing required args) are detected and short-circuited with a user-visible error — not replayed into history.
- **Service ports**:
- `web` → `:8015`
- `server` → `:8016`
@@ -69,6 +69,7 @@ Run in this order:
- Chat model override: dropdown selector fed by `GET /api/chat/providers/{provider}/models/`; persisted in `localStorage` key `voyage_chat_model_prefs`; backend accepts optional `model` param in `send_message`
- Chat context: collection chats inject multi-stop itinerary context; system prompt guides `get_trip_details`-first reasoning
- Chat error surfacing: `_safe_error_payload()` maps LiteLLM exceptions to sanitized user-safe categories (never forwards raw `exc.message`)
+- Invalid tool calls (missing required args) are detected and short-circuited with a user-visible error — not replayed into history
## Conventions
- Do **not** attempt to fix known test/configuration issues as part of feature work.
diff --git a/CLAUDE.md b/CLAUDE.md
index 094bcbe9..f1c58984 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,7 +15,7 @@ Voyage is **pre-release** — not yet in production use. During pre-release:
- Use the API proxy pattern: never call Django directly from frontend components.
- Route all frontend API calls through `frontend/src/routes/api/[...path]/+server.ts`.
- Proxy target is `http://server:8000`; preserve session cookies and CSRF behavior.
-- AI chat is embedded in Collections → Recommendations via `AITravelChat.svelte`. There is no standalone `/chat` route. Chat providers are loaded dynamically from `GET /api/chat/providers/` (backed by LiteLLM runtime providers + custom entries like `opencode_zen`). Chat conversations stream via SSE through `/api/chat/conversations/`. Chat composer supports per-provider model override (persisted in browser `localStorage`). LiteLLM errors are mapped to sanitized user-safe messages via `_safe_error_payload()` (never exposes raw exception text).
+- AI chat is embedded in Collections → Recommendations via `AITravelChat.svelte`. There is no standalone `/chat` route. Chat providers are loaded dynamically from `GET /api/chat/providers/` (backed by LiteLLM runtime providers + custom entries like `opencode_zen`). Chat conversations stream via SSE through `/api/chat/conversations/`. Default AI provider/model saved via `UserAISettings` in DB (authoritative over browser localStorage). LiteLLM errors are mapped to sanitized user-safe messages via `_safe_error_payload()` (never exposes raw exception text). Invalid tool calls (missing required args) are detected and short-circuited with a user-visible error — not replayed into history.
- Service ports:
- `web` → `:8015`
- `server` → `:8016`
@@ -77,6 +77,7 @@ Run in this exact order:
- Chat model override: dropdown selector fed by `GET /api/chat/providers/{provider}/models/`; persisted in `localStorage` key `voyage_chat_model_prefs`; backend accepts optional `model` param in `send_message`
- Chat context: collection chats inject multi-stop itinerary context; system prompt guides `get_trip_details`-first reasoning
- Chat error surfacing: `_safe_error_payload()` maps LiteLLM exceptions to sanitized user-safe categories (never forwards raw `exc.message`)
+- Invalid tool calls (missing required args) are detected and short-circuited with a user-visible error — not replayed into history
## Conventions
- Do **not** attempt to fix known test/configuration issues as part of feature work.
diff --git a/backend/server/chat/agent_tools.py b/backend/server/chat/agent_tools.py
index 0767e391..f3cdc61f 100644
--- a/backend/server/chat/agent_tools.py
+++ b/backend/server/chat/agent_tools.py
@@ -98,7 +98,11 @@ def _parse_address(tags):
@agent_tool(
name="search_places",
- description="Search for places of interest near a location. Returns tourist attractions, restaurants, hotels, etc.",
+ description=(
+ "Search for places of interest near a location. "
+ "Required: provide a non-empty 'location' string (city, neighborhood, or address). "
+ "Returns tourist attractions, restaurants, hotels, etc."
+ ),
parameters={
"location": {
"type": "string",
@@ -231,7 +235,11 @@ def list_trips(user):
@agent_tool(
name="web_search",
- description="Search the web for current information about destinations, events, prices, weather, or any real-time travel information. Use this when you need up-to-date information that may not be in your training data.",
+ description=(
+ "Search the web for current travel information. "
+ "Required: provide a non-empty 'query' string describing exactly what to look up. "
+ "Use when you need up-to-date info that may not be in training data."
+ ),
parameters={
"query": {
"type": "string",
diff --git a/backend/server/chat/llm_client.py b/backend/server/chat/llm_client.py
index 099bf428..b84ede02 100644
--- a/backend/server/chat/llm_client.py
+++ b/backend/server/chat/llm_client.py
@@ -165,6 +165,18 @@ def _normalize_provider_id(provider_id):
return lowered
+def normalize_gateway_model(provider_id, model):
+ normalized_provider = _normalize_provider_id(provider_id)
+ normalized_model = str(model or "").strip()
+ if not normalized_model:
+ return None
+
+ if normalized_provider == "opencode_zen" and "/" not in normalized_model:
+ return f"openai/{normalized_model}"
+
+ return normalized_model
+
+
def _default_provider_label(provider_id):
return provider_id.replace("_", " ").title()
@@ -405,6 +417,7 @@ async def stream_chat_completion(user, messages, provider, tools=None, model=Non
)
or provider_config["default_model"]
)
+ resolved_model = normalize_gateway_model(normalized_provider, resolved_model)
if tools and not litellm.supports_function_calling(model=resolved_model):
logger.warning(
diff --git a/backend/server/chat/views/__init__.py b/backend/server/chat/views/__init__.py
index 25acbadb..e305ab8e 100644
--- a/backend/server/chat/views/__init__.py
+++ b/backend/server/chat/views/__init__.py
@@ -1,10 +1,12 @@
import asyncio
import json
import logging
+import re
from asgiref.sync import sync_to_async
from adventures.models import Collection
from django.http import StreamingHttpResponse
+from integrations.models import UserAISettings
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
@@ -53,19 +55,40 @@ class ChatViewSet(viewsets.ModelViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
def _build_llm_messages(self, conversation, user, system_prompt=None):
+ ordered_messages = list(conversation.messages.all().order_by("created_at"))
+ valid_tool_call_ids = {
+ message.tool_call_id
+ for message in ordered_messages
+ if message.role == "tool"
+ and message.tool_call_id
+ and not self._is_required_param_tool_error_message_content(message.content)
+ }
+
messages = [
{
"role": "system",
"content": system_prompt or get_system_prompt(user),
}
]
- for message in conversation.messages.all().order_by("created_at"):
+ for message in ordered_messages:
+ if (
+ message.role == "tool"
+ and self._is_required_param_tool_error_message_content(message.content)
+ ):
+ continue
+
payload = {
"role": message.role,
"content": message.content,
}
if message.role == "assistant" and message.tool_calls:
- payload["tool_calls"] = message.tool_calls
+ filtered_tool_calls = [
+ tool_call
+ for tool_call in message.tool_calls
+ if (tool_call or {}).get("id") in valid_tool_call_ids
+ ]
+ if filtered_tool_calls:
+ payload["tool_calls"] = filtered_tool_calls
if message.role == "tool":
payload["tool_call_id"] = message.tool_call_id
payload["name"] = message.name
@@ -109,6 +132,50 @@ class ChatViewSet(viewsets.ModelViewSet):
if function_data.get("arguments"):
current["function"]["arguments"] += function_data.get("arguments")
+ @staticmethod
+ def _is_required_param_tool_error(result):
+ if not isinstance(result, dict):
+ return False
+
+ error_text = result.get("error")
+ if not isinstance(error_text, str):
+ return False
+
+ normalized_error = error_text.strip().lower()
+ if normalized_error in {"location is required", "query is required"}:
+ return True
+
+ return bool(
+ re.fullmatch(
+ r"[a-z0-9_,\-\s]+ (is|are) required",
+ normalized_error,
+ )
+ )
+
+ @classmethod
+ def _is_required_param_tool_error_message_content(cls, content):
+ if not isinstance(content, str):
+ return False
+
+ try:
+ parsed = json.loads(content)
+ except json.JSONDecodeError:
+ return False
+
+ return cls._is_required_param_tool_error(parsed)
+
+ @staticmethod
+ def _build_required_param_error_event(tool_name, result):
+ tool_error = result.get("error") if isinstance(result, dict) else ""
+ return {
+ "error": (
+ "The assistant attempted to call "
+ f"'{tool_name}' without required arguments ({tool_error}). "
+ "Please try your message again with more specific details."
+ ),
+ "error_category": "tool_validation_error",
+ }
+
@action(detail=True, methods=["post"])
def send_message(self, request, pk=None):
# Auto-learn preferences from user's travel history
@@ -128,8 +195,30 @@ class ChatViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
- provider = (request.data.get("provider") or "openai").strip().lower()
- model = (request.data.get("model") or "").strip() or None
+ requested_provider = (request.data.get("provider") or "").strip().lower()
+ requested_model = (request.data.get("model") or "").strip() or None
+ ai_settings = UserAISettings.objects.filter(user=request.user).first()
+ preferred_provider = (
+ (ai_settings.preferred_provider or "").strip().lower()
+ if ai_settings
+ else ""
+ )
+ preferred_model = (
+ (ai_settings.preferred_model or "").strip() if ai_settings else ""
+ )
+
+ provider = requested_provider
+ if not provider and preferred_provider:
+ if preferred_provider and is_chat_provider_available(preferred_provider):
+ provider = preferred_provider
+
+ if not provider:
+ provider = "openai"
+
+ model = requested_model
+ if model is None and preferred_model and provider == preferred_provider:
+ model = preferred_model
+
collection_id = request.data.get("collection_id")
collection_name = request.data.get("collection_name")
start_date = request.data.get("start_date")
@@ -266,29 +355,16 @@ class ChatViewSet(viewsets.ModelViewSet):
)
if encountered_error:
+ yield "data: [DONE]\n\n"
break
assistant_content = "".join(content_chunks)
if tool_calls_accumulator:
- assistant_with_tools = {
- "role": "assistant",
- "content": assistant_content,
- "tool_calls": tool_calls_accumulator,
- }
- current_messages.append(assistant_with_tools)
-
- await sync_to_async(
- ChatMessage.objects.create, thread_sensitive=True
- )(
- conversation=conversation,
- role="assistant",
- content=assistant_content,
- tool_calls=tool_calls_accumulator,
- )
- await sync_to_async(conversation.save, thread_sensitive=True)(
- update_fields=["updated_at"]
- )
+ tool_iterations += 1
+ successful_tool_calls = []
+ successful_tool_messages = []
+ successful_tool_chat_entries = []
for tool_call in tool_calls_accumulator:
function_payload = tool_call.get("function") or {}
@@ -309,9 +385,58 @@ class ChatViewSet(viewsets.ModelViewSet):
request.user,
**arguments,
)
+
+ if self._is_required_param_tool_error(result):
+ assistant_message_kwargs = {
+ "conversation": conversation,
+ "role": "assistant",
+ "content": assistant_content,
+ }
+ if successful_tool_calls:
+ assistant_message_kwargs["tool_calls"] = (
+ successful_tool_calls
+ )
+
+ await sync_to_async(
+ ChatMessage.objects.create, thread_sensitive=True
+ )(**assistant_message_kwargs)
+
+ for tool_message in successful_tool_messages:
+ await sync_to_async(
+ ChatMessage.objects.create,
+ thread_sensitive=True,
+ )(**tool_message)
+
+ await sync_to_async(
+ conversation.save,
+ thread_sensitive=True,
+ )(update_fields=["updated_at"])
+
+ logger.info(
+ "Stopping chat tool loop due to required-arg tool validation error: %s (%s)",
+ function_name,
+ result.get("error"),
+ )
+ error_event = self._build_required_param_error_event(
+ function_name,
+ result,
+ )
+ yield f"data: {json.dumps(error_event)}\n\n"
+ yield "data: [DONE]\n\n"
+ return
+
result_content = serialize_tool_result(result)
- current_messages.append(
+ successful_tool_calls.append(tool_call)
+ tool_message_payload = {
+ "conversation": conversation,
+ "role": "tool",
+ "content": result_content,
+ "tool_call_id": tool_call.get("id"),
+ "name": function_name,
+ }
+ successful_tool_messages.append(tool_message_payload)
+ successful_tool_chat_entries.append(
{
"role": "tool",
"tool_call_id": tool_call.get("id"),
@@ -320,19 +445,6 @@ class ChatViewSet(viewsets.ModelViewSet):
}
)
- await sync_to_async(
- ChatMessage.objects.create, thread_sensitive=True
- )(
- conversation=conversation,
- role="tool",
- content=result_content,
- tool_call_id=tool_call.get("id"),
- name=function_name,
- )
- await sync_to_async(conversation.save, thread_sensitive=True)(
- update_fields=["updated_at"]
- )
-
tool_event = {
"tool_result": {
"tool_call_id": tool_call.get("id"),
@@ -342,6 +454,32 @@ class ChatViewSet(viewsets.ModelViewSet):
}
yield f"data: {json.dumps(tool_event)}\n\n"
+ assistant_with_tools = {
+ "role": "assistant",
+ "content": assistant_content,
+ "tool_calls": successful_tool_calls,
+ }
+ current_messages.append(assistant_with_tools)
+ current_messages.extend(successful_tool_chat_entries)
+
+ await sync_to_async(
+ ChatMessage.objects.create, thread_sensitive=True
+ )(
+ conversation=conversation,
+ role="assistant",
+ content=assistant_content,
+ tool_calls=successful_tool_calls,
+ )
+ for tool_message in successful_tool_messages:
+ await sync_to_async(
+ ChatMessage.objects.create,
+ thread_sensitive=True,
+ )(**tool_message)
+
+ await sync_to_async(conversation.save, thread_sensitive=True)(
+ update_fields=["updated_at"]
+ )
+
continue
await sync_to_async(ChatMessage.objects.create, thread_sensitive=True)(
@@ -355,6 +493,18 @@ class ChatViewSet(viewsets.ModelViewSet):
yield "data: [DONE]\n\n"
break
+ if tool_iterations >= MAX_TOOL_ITERATIONS:
+ logger.warning(
+ "Stopping chat tool loop after max iterations (%s)",
+ MAX_TOOL_ITERATIONS,
+ )
+ payload = {
+ "error": "The assistant stopped after too many tool calls. Please try again with a more specific request.",
+ "error_category": "tool_loop_limit",
+ }
+ yield f"data: {json.dumps(payload)}\n\n"
+ yield "data: [DONE]\n\n"
+
response = StreamingHttpResponse(
streaming_content=self._async_to_sync_generator(event_stream()),
content_type="text/event-stream",
diff --git a/backend/server/chat/views/day_suggestions.py b/backend/server/chat/views/day_suggestions.py
index 67711836..c0c6d119 100644
--- a/backend/server/chat/views/day_suggestions.py
+++ b/backend/server/chat/views/day_suggestions.py
@@ -1,7 +1,9 @@
+import logging
import json
import re
import litellm
+from django.conf import settings
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
@@ -11,10 +13,17 @@ from rest_framework.views import APIView
from adventures.models import Collection
from chat.agent_tools import search_places
from chat.llm_client import (
+ CHAT_PROVIDER_CONFIG,
+ _safe_error_payload,
get_llm_api_key,
get_system_prompt,
is_chat_provider_available,
+ normalize_gateway_model,
)
+from integrations.models import UserAISettings
+
+
+logger = logging.getLogger(__name__)
class DaySuggestionsView(APIView):
@@ -52,7 +61,7 @@ class DaySuggestionsView(APIView):
location = location_context or self._get_collection_location(collection)
system_prompt = get_system_prompt(request.user, collection)
- provider = "openai"
+ provider, model = self._resolve_provider_and_model(request)
if not is_chat_provider_available(provider):
return Response(
@@ -78,12 +87,22 @@ class DaySuggestionsView(APIView):
user_prompt=prompt,
user=request.user,
provider=provider,
+ model=model,
)
return Response({"suggestions": suggestions}, status=status.HTTP_200_OK)
- except Exception:
+ except Exception as exc:
+ logger.exception("Failed to generate day suggestions")
+ payload = _safe_error_payload(exc)
+ status_code = {
+ "model_not_found": status.HTTP_400_BAD_REQUEST,
+ "authentication_failed": status.HTTP_401_UNAUTHORIZED,
+ "rate_limited": status.HTTP_429_TOO_MANY_REQUESTS,
+ "invalid_request": status.HTTP_400_BAD_REQUEST,
+ "provider_unreachable": status.HTTP_503_SERVICE_UNAVAILABLE,
+ }.get(payload.get("error_category"), status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(
- {"error": "Failed to generate suggestions. Please try again."},
- status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ payload,
+ status=status_code,
)
def _get_collection_location(self, collection):
@@ -174,31 +193,98 @@ class DaySuggestionsView(APIView):
category=tool_category_map.get(category, "tourism"),
radius=8,
)
+ if not isinstance(result, dict):
+ return ""
if result.get("error"):
return ""
+ raw_results = result.get("results")
+ if not isinstance(raw_results, list):
+ return ""
+
entries = []
- for place in result.get("results", [])[:5]:
+ for place in raw_results[:5]:
+ if not isinstance(place, dict):
+ continue
name = place.get("name")
address = place.get("address") or ""
if name:
entries.append(f"{name} ({address})" if address else name)
return "; ".join(entries)
- def _get_suggestions_from_llm(self, system_prompt, user_prompt, user, provider):
+ def _resolve_provider_and_model(self, request):
+ request_provider = (request.data.get("provider") or "").strip().lower() or None
+ request_model = (request.data.get("model") or "").strip() or None
+
+ user_settings = UserAISettings.objects.filter(user=request.user).first() # type: ignore[attr-defined]
+ preferred_provider = (
+ (user_settings.preferred_provider or "").strip().lower()
+ if user_settings and user_settings.preferred_provider
+ else None
+ )
+ preferred_model = (
+ (user_settings.preferred_model or "").strip()
+ if user_settings and user_settings.preferred_model
+ else None
+ )
+
+ settings_provider = (settings.VOYAGE_AI_PROVIDER or "").strip().lower() or None
+
+ provider = request_provider or preferred_provider or settings_provider
+ if not provider or not is_chat_provider_available(provider):
+ provider = (
+ settings_provider
+ if is_chat_provider_available(settings_provider)
+ else None
+ )
+ if not provider or not is_chat_provider_available(provider):
+ provider = "openai" if is_chat_provider_available("openai") else provider
+
+ provider_config = CHAT_PROVIDER_CONFIG.get(provider or "", {})
+ default_model = (
+ (settings.VOYAGE_AI_MODEL or "").strip()
+ if provider == settings_provider and settings.VOYAGE_AI_MODEL
+ else None
+ ) or provider_config.get("default_model")
+
+ model_from_user_defaults = (
+ preferred_model
+ if preferred_provider and provider == preferred_provider
+ else None
+ )
+ model = request_model or model_from_user_defaults or default_model
+ return provider, model
+
+ def _get_suggestions_from_llm(
+ self, system_prompt, user_prompt, user, provider, model
+ ):
api_key = get_llm_api_key(user, provider)
if not api_key:
raise ValueError("No API key available")
- response = litellm.completion(
- model="gpt-4o-mini",
- messages=[
+ provider_config = CHAT_PROVIDER_CONFIG.get(provider, {})
+ resolved_model = normalize_gateway_model(
+ provider,
+ model or provider_config.get("default_model"),
+ )
+ if not resolved_model:
+ raise ValueError("No model configured for provider")
+
+ completion_kwargs = {
+ "model": resolved_model,
+ "messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
- api_key=api_key,
- temperature=0.7,
- max_tokens=1000,
+ "api_key": api_key,
+ "max_tokens": 1000,
+ }
+
+ if provider_config.get("api_base"):
+ completion_kwargs["api_base"] = provider_config["api_base"]
+
+ response = litellm.completion(
+ **completion_kwargs,
)
content = (response.choices[0].message.content or "").strip()
diff --git a/docs/docs/usage/usage.md b/docs/docs/usage/usage.md
index 741594c1..1d56c6f7 100644
--- a/docs/docs/usage/usage.md
+++ b/docs/docs/usage/usage.md
@@ -26,7 +26,11 @@ The term "Location" is now used instead of "Adventure" - the usage remains the s
The AI travel chat is embedded in the **Collections → Recommendations** view. Select a collection, switch to the Recommendations tab, and use the chat to brainstorm destinations, ask for travel advice, or get location suggestions. The chat supports multiple LLM providers — configure your API key in **Settings → API Keys** and pick a provider from the dropdown in the chat interface. The provider list is loaded dynamically from the backend, so any provider supported by LiteLLM (plus OpenCode Zen) is available.
-You can also override the default model for any provider by typing a model name in the **Model** input next to the provider selector (e.g. `openai/gpt-5-nano`). Your model choice is saved per-provider in the browser. If the model field is left empty, the provider's default model is used. Provider errors (authentication, model not found, rate limits) are displayed as clear, actionable messages in the chat.
+You can set a **default AI provider and model** in **Settings** (under "Default AI Settings"). These saved defaults are used automatically when you open a new chat or request day suggestions. The defaults are stored in the database and apply across all your devices — they take priority over any previous per-browser model selections. You can still override the provider and model in the chat interface for individual conversations.
+
+Day suggestions (the AI-generated place recommendations for specific itinerary days) also respect your saved default provider and model. If no defaults are saved, the instance-level provider configured by the server admin is used.
+
+Provider errors (authentication, model not found, rate limits, invalid tool calls) are displayed as clear, actionable messages in the chat. If the AI attempts to use a tool incorrectly (e.g., missing required parameters), the error is surfaced once and the conversation stops cleanly rather than looping.
#### Collections
diff --git a/frontend/src/lib/components/AITravelChat.svelte b/frontend/src/lib/components/AITravelChat.svelte
index 8146e615..32a0b382 100644
--- a/frontend/src/lib/components/AITravelChat.svelte
+++ b/frontend/src/lib/components/AITravelChat.svelte
@@ -36,6 +36,11 @@
user_configured: boolean;
};
+ type UserAISettingsResponse = {
+ preferred_provider: string | null;
+ preferred_model: string | null;
+ };
+
export let embedded = false;
export let collectionId: string | undefined = undefined;
export let collectionName: string | undefined = undefined;
@@ -58,6 +63,10 @@
let chatProviders: ChatProviderCatalogConfiguredEntry[] = [];
let providerError = '';
let selectedProviderDefaultModel = '';
+ let savedDefaultProvider = '';
+ let savedDefaultModel = '';
+ let initialDefaultsApplied = false;
+ let loadedModelsForProvider = '';
let showDateSelector = false;
let selectedPlaceToAdd: PlaceResult | null = null;
let selectedDate = '';
@@ -68,13 +77,65 @@
}>();
const MODEL_PREFS_STORAGE_KEY = 'voyage_chat_model_prefs';
- let initializedModelProvider = '';
$: promptTripContext = collectionName || destination || '';
onMount(async () => {
- await Promise.all([loadConversations(), loadProviderCatalog()]);
+ await Promise.all([loadConversations(), loadProviderCatalog(), loadUserAISettings()]);
+ await applyInitialDefaults();
});
+ async function loadUserAISettings(): Promise {
+ try {
+ const res = await fetch('/api/integrations/ai-settings/', {
+ credentials: 'include'
+ });
+ if (!res.ok) {
+ return;
+ }
+
+ const settings = (await res.json()) as UserAISettingsResponse[];
+ const first = settings[0];
+ if (!first) {
+ return;
+ }
+
+ savedDefaultProvider = (first.preferred_provider || '').trim().toLowerCase();
+ savedDefaultModel = (first.preferred_model || '').trim();
+ } catch (e) {
+ console.error('Failed to load AI settings:', e);
+ }
+ }
+
+ async function applyInitialDefaults(): Promise {
+ if (initialDefaultsApplied || chatProviders.length === 0) {
+ return;
+ }
+
+ if (
+ savedDefaultProvider &&
+ chatProviders.some((provider) => provider.id === savedDefaultProvider)
+ ) {
+ selectedProvider = savedDefaultProvider;
+ } else {
+ const userConfigured = chatProviders.find((provider) => provider.user_configured);
+ selectedProvider = (userConfigured || chatProviders[0]).id;
+ }
+
+ await loadModelsForProvider(selectedProvider);
+
+ if (savedDefaultModel && selectedProvider === savedDefaultProvider) {
+ selectedModel = availableModels.includes(savedDefaultModel)
+ ? savedDefaultModel
+ : selectedProviderDefaultModel || availableModels[0] || '';
+ } else {
+ selectedModel = selectedProviderDefaultModel || availableModels[0] || '';
+ }
+
+ saveModelPref(selectedProvider, selectedModel);
+ loadedModelsForProvider = selectedProvider;
+ initialDefaultsApplied = true;
+ }
+
async function loadProviderCatalog(): Promise {
try {
const res = await fetch('/api/chat/providers/', {
@@ -98,9 +159,8 @@
if (usable.length > 0) {
providerError = '';
- if (!selectedProvider || !usable.some((provider) => provider.id === selectedProvider)) {
- const userConfigured = usable.find((provider) => provider.user_configured);
- selectedProvider = (userConfigured || usable[0]).id;
+ if (selectedProvider && !usable.some((provider) => provider.id === selectedProvider)) {
+ selectedProvider = '';
}
} else {
selectedProvider = '';
@@ -113,24 +173,21 @@
}
}
- async function loadModelsForProvider() {
- if (!selectedProvider) {
+ async function loadModelsForProvider(providerId: string) {
+ if (!providerId) {
availableModels = [];
return;
}
modelsLoading = true;
try {
- const res = await fetch(`/api/chat/providers/${selectedProvider}/models/`, {
+ const res = await fetch(`/api/chat/providers/${providerId}/models/`, {
credentials: 'include'
});
const data = await res.json();
if (data.models && data.models.length > 0) {
availableModels = data.models;
- if (!selectedModel || !availableModels.includes(selectedModel)) {
- selectedModel = availableModels[0];
- }
} else {
availableModels = [];
}
@@ -142,25 +199,6 @@
}
}
- function loadModelPref(provider: string): string {
- if (typeof window === 'undefined') {
- return '';
- }
-
- try {
- const raw = window.localStorage.getItem(MODEL_PREFS_STORAGE_KEY);
- if (!raw) {
- return '';
- }
-
- const prefs = JSON.parse(raw) as Record;
- const value = prefs[provider];
- return typeof value === 'string' ? value : '';
- } catch {
- return '';
- }
- }
-
function saveModelPref(provider: string, model: string) {
if (typeof window === 'undefined') {
return;
@@ -176,20 +214,26 @@
}
}
- $: if (selectedProvider && initializedModelProvider !== selectedProvider) {
- selectedModel = loadModelPref(selectedProvider) || selectedProviderDefaultModel || '';
- initializedModelProvider = selectedProvider;
- }
-
- $: if (selectedProvider && initializedModelProvider === selectedProvider) {
- saveModelPref(selectedProvider, selectedModel);
- }
-
$: selectedProviderDefaultModel =
chatProviders.find((provider) => provider.id === selectedProvider)?.default_model ?? '';
- $: if (selectedProvider) {
- void loadModelsForProvider();
+ $: if (
+ selectedProvider &&
+ initialDefaultsApplied &&
+ loadedModelsForProvider !== selectedProvider
+ ) {
+ loadedModelsForProvider = selectedProvider;
+ void (async () => {
+ await loadModelsForProvider(selectedProvider);
+ if (!selectedModel || !availableModels.includes(selectedModel)) {
+ selectedModel = selectedProviderDefaultModel || availableModels[0] || '';
+ }
+ saveModelPref(selectedProvider, selectedModel);
+ })();
+ }
+
+ $: if (selectedProvider && initialDefaultsApplied) {
+ saveModelPref(selectedProvider, selectedModel);
}
async function loadConversations() {
diff --git a/frontend/src/lib/components/collections/ItinerarySuggestionModal.svelte b/frontend/src/lib/components/collections/ItinerarySuggestionModal.svelte
index 77778bdc..b734e1e2 100644
--- a/frontend/src/lib/components/collections/ItinerarySuggestionModal.svelte
+++ b/frontend/src/lib/components/collections/ItinerarySuggestionModal.svelte
@@ -14,6 +14,7 @@
name: string;
description?: string;
why_fits?: string;
+ category?: string;
location?: string;
rating?: number | string | null;
price_level?: string | null;
@@ -118,6 +119,94 @@
return nextFilters;
}
+ function asRecord(value: unknown): Record | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return null;
+ }
+ return value as Record;
+ }
+
+ function normalizeText(value: unknown): string {
+ if (typeof value !== 'string') return '';
+ return value.trim();
+ }
+
+ function normalizeRating(value: unknown): number | null {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+
+ if (typeof value === 'string') {
+ const match = value.match(/\d+(\.\d+)?/);
+ if (!match) return null;
+ const parsed = Number(match[0]);
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+
+ return null;
+ }
+
+ function normalizeSuggestionItem(value: unknown): SuggestionItem | null {
+ const item = asRecord(value);
+ if (!item) return null;
+
+ const name =
+ normalizeText(item.name) ||
+ normalizeText(item.title) ||
+ normalizeText(item.place_name) ||
+ normalizeText(item.venue);
+ const description =
+ normalizeText(item.description) || normalizeText(item.summary) || normalizeText(item.details);
+ const whyFits =
+ normalizeText(item.why_fits) || normalizeText(item.whyFits) || normalizeText(item.reason);
+ const location =
+ normalizeText(item.location) ||
+ normalizeText(item.address) ||
+ normalizeText(item.neighborhood);
+ const category = normalizeText(item.category);
+ const priceLevel =
+ normalizeText(item.price_level) ||
+ normalizeText(item.priceLevel) ||
+ normalizeText(item.price);
+ const rating = normalizeRating(item.rating ?? item.score);
+
+ const finalName = name || location;
+ if (!finalName) return null;
+
+ return {
+ name: finalName,
+ description: description || undefined,
+ why_fits: whyFits || undefined,
+ category: category || undefined,
+ location: location || undefined,
+ rating,
+ price_level: priceLevel || null
+ };
+ }
+
+ function buildLocationPayload(suggestion: SuggestionItem) {
+ const name =
+ normalizeText(suggestion.name) || normalizeText(suggestion.location) || 'Suggestion';
+ const locationText =
+ normalizeText(suggestion.location) ||
+ getCollectionLocation() ||
+ normalizeText(suggestion.name);
+ const description =
+ normalizeText(suggestion.description) ||
+ normalizeText(suggestion.why_fits) ||
+ (suggestion.category ? `${suggestion.category} suggestion` : '');
+ const rating = normalizeRating(suggestion.rating);
+
+ return {
+ name,
+ description,
+ location: locationText || name,
+ rating,
+ collections: [collection.id],
+ is_public: false
+ };
+ }
+
async function fetchSuggestions() {
if (!selectedCategory) return;
@@ -144,7 +233,11 @@
}
const data = await response.json();
- suggestions = Array.isArray(data?.suggestions) ? data.suggestions : [];
+ suggestions = Array.isArray(data?.suggestions)
+ ? data.suggestions
+ .map((item: unknown) => normalizeSuggestionItem(item))
+ .filter((item: SuggestionItem | null): item is SuggestionItem => item !== null)
+ : [];
} catch (_err) {
error = $t('suggestions.error');
suggestions = [];
@@ -180,17 +273,12 @@
error = '';
try {
+ const payload = buildLocationPayload(suggestion);
const createLocationResponse = await fetch('/api/locations/', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- name: suggestion.name,
- description: suggestion.description || suggestion.why_fits || '',
- location: suggestion.location || getCollectionLocation() || suggestion.name,
- collections: [collection.id],
- is_public: false
- })
+ body: JSON.stringify(payload)
});
if (!createLocationResponse.ok) {
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 8435b6c4..60105d9a 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -587,6 +587,14 @@ export type UserRecommendationPreferenceProfile = {
updated_at: string;
};
+export type UserAISettings = {
+ id: string;
+ preferred_provider: string | null;
+ preferred_model: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
export type CollectionItineraryDay = {
id: string;
collection: string; // UUID of the collection
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index db2632e9..09989ad1 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -817,6 +817,13 @@
"travel_agent_help_body": "Open a collection and switch to Recommendations to interact with the travel agent for place suggestions.",
"travel_agent_help_open_collections": "Open Collections",
"travel_agent_help_setup_guide": "Travel agent setup guide",
+ "default_ai_settings_title": "Default AI Provider & Model",
+ "default_ai_settings_desc": "Choose the default AI provider and model used across chat experiences.",
+ "default_ai_no_providers": "No configured AI providers are available yet. Add an API key first.",
+ "default_ai_save": "Save default AI settings",
+ "default_ai_settings_saved": "Default AI settings saved.",
+ "default_ai_settings_error": "Unable to save default AI settings.",
+ "default_ai_provider_required": "Please select a provider.",
"travel_preferences": "Travel Preferences",
"travel_preferences_desc": "Customize your travel preferences for better AI recommendations",
"cuisines": "Favorite Cuisines",
diff --git a/frontend/src/routes/settings/+page.server.ts b/frontend/src/routes/settings/+page.server.ts
index 2f6249de..50bf2fa4 100644
--- a/frontend/src/routes/settings/+page.server.ts
+++ b/frontend/src/routes/settings/+page.server.ts
@@ -1,7 +1,7 @@
import { fail, redirect, type Actions } from '@sveltejs/kit';
import type { PageServerLoad } from '../$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
-import type { ImmichIntegration, User } from '$lib/types';
+import type { ImmichIntegration, User, UserAISettings } from '$lib/types';
import { fetchCSRFToken } from '$lib/index.server';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
@@ -95,6 +95,7 @@ export const load: PageServerLoad = async (event) => {
let apiKeys: UserAPIKey[] = [];
let apiKeysConfigError: string | null = null;
+ let aiSettings: UserAISettings | null = null;
let apiKeysFetch = await fetch(`${endpoint}/api/integrations/api-keys/`, {
headers: {
Cookie: `sessionid=${sessionId}`
@@ -108,6 +109,17 @@ export const load: PageServerLoad = async (event) => {
apiKeysConfigError = errorBody.detail ?? 'API key storage is currently unavailable.';
}
+ let aiSettingsFetch = await fetch(`${endpoint}/api/integrations/ai-settings/`, {
+ headers: {
+ Cookie: `sessionid=${sessionId}`
+ }
+ });
+
+ if (aiSettingsFetch.ok) {
+ const aiSettingsResponse = (await aiSettingsFetch.json()) as UserAISettings[];
+ aiSettings = aiSettingsResponse[0] ?? null;
+ }
+
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
let publicUrl = '';
if (!publicUrlFetch.ok) {
@@ -131,6 +143,7 @@ export const load: PageServerLoad = async (event) => {
stravaUserEnabled,
apiKeys,
apiKeysConfigError,
+ aiSettings,
wandererEnabled,
wandererExpired
}
diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte
index 7865b56b..642bd15d 100644
--- a/frontend/src/routes/settings/+page.svelte
+++ b/frontend/src/routes/settings/+page.svelte
@@ -47,6 +47,11 @@
let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null;
let newApiKeyProvider = 'anthropic';
let providerCatalog: ChatProviderCatalogEntry[] = [];
+ let defaultAiProvider = (data.props.aiSettings?.preferred_provider ?? '').trim().toLowerCase();
+ let defaultAiModel = (data.props.aiSettings?.preferred_model ?? '').trim();
+ let defaultAiModels: string[] = [];
+ let defaultAiModelsLoading = false;
+ let isSavingDefaultAiSettings = false;
let newApiKeyValue = '';
let isSavingApiKey = false;
let deletingApiKeyId: string | null = null;
@@ -70,6 +75,104 @@
}
}
+ function getConfiguredChatProviders() {
+ return providerCatalog.filter(
+ (provider) =>
+ provider.available_for_chat && (provider.user_configured || provider.instance_configured)
+ );
+ }
+
+ async function loadDefaultAiModels(providerId: string) {
+ if (!providerId) {
+ defaultAiModels = [];
+ return;
+ }
+
+ defaultAiModelsLoading = true;
+ try {
+ const res = await fetch(`/api/chat/providers/${providerId}/models/`);
+ if (!res.ok) {
+ defaultAiModels = [];
+ return;
+ }
+
+ const payload = await res.json();
+ defaultAiModels = Array.isArray(payload.models) ? (payload.models as string[]) : [];
+ } catch {
+ defaultAiModels = [];
+ } finally {
+ defaultAiModelsLoading = false;
+ }
+ }
+
+ async function initializeDefaultAiSettings() {
+ const configuredProviders = getConfiguredChatProviders();
+ if (!configuredProviders.length) {
+ defaultAiProvider = '';
+ defaultAiModel = '';
+ defaultAiModels = [];
+ return;
+ }
+
+ if (
+ !defaultAiProvider ||
+ !configuredProviders.some((provider) => provider.id === defaultAiProvider)
+ ) {
+ defaultAiProvider = configuredProviders[0].id;
+ defaultAiModel = '';
+ }
+
+ await loadDefaultAiModels(defaultAiProvider);
+ if (defaultAiModel && !defaultAiModels.includes(defaultAiModel)) {
+ defaultAiModel = '';
+ }
+ }
+
+ async function onDefaultAiProviderChange() {
+ defaultAiModel = '';
+ await loadDefaultAiModels(defaultAiProvider);
+ }
+
+ async function saveDefaultAiSettings(event: SubmitEvent) {
+ event.preventDefault();
+ if (!defaultAiProvider) {
+ addToast('error', $t('settings.default_ai_provider_required'));
+ return;
+ }
+
+ isSavingDefaultAiSettings = true;
+ try {
+ const res = await fetch('/api/integrations/ai-settings/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ preferred_provider: defaultAiProvider,
+ preferred_model: defaultAiModel || null
+ })
+ });
+
+ if (!res.ok) {
+ addToast('error', $t('settings.default_ai_settings_error'));
+ return;
+ }
+
+ const saved = await res.json();
+ defaultAiProvider = (saved.preferred_provider ?? '').trim().toLowerCase();
+ defaultAiModel = (saved.preferred_model ?? '').trim();
+ await loadDefaultAiModels(defaultAiProvider);
+ if (defaultAiModel && !defaultAiModels.includes(defaultAiModel)) {
+ defaultAiModel = '';
+ }
+ addToast('success', $t('settings.default_ai_settings_saved'));
+ } catch {
+ addToast('error', $t('settings.default_ai_settings_error'));
+ } finally {
+ isSavingDefaultAiSettings = false;
+ }
+ }
+
function getApiKeyProviderLabel(provider: string): string {
const catalogProvider = providerCatalog.find((entry) => entry.id === provider);
if (catalogProvider) {
@@ -133,7 +236,8 @@
];
onMount(async () => {
- void loadProviderCatalog();
+ await loadProviderCatalog();
+ await initializeDefaultAiSettings();
if (browser) {
const queryParams = new URLSearchParams($page.url.search);
@@ -1570,6 +1674,71 @@
+
+
+ {$t('settings.default_ai_settings_title')}
+
+
+ {$t('settings.default_ai_settings_desc')}
+
+
+ {#if getConfiguredChatProviders().length === 0}
+
+ {$t('settings.default_ai_no_providers')}
+
+ {:else}
+
+ {/if}
+
+