diff --git a/server/.env.example b/server/.env.example index fdd7e0d4f..9354dea1b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -52,6 +52,8 @@ DEMO_MODE=false # Demo mode - resets data hourly # OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less. # OVERPASS_TIMEOUT_MS=12000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. Raise it for a slow self-hosted instance. (default: 12000) +# KITINERARY_EXTRACTOR_PATH= # Full path to the kitinerary-extractor binary. Unset, TREK searches /usr/lib/*/libexec/kf6/ and then PATH; the official image pins it. Set it to run a newer extractor than your distribution packages — the version in use is in the startup log and in GET /api/admin/system-info. (default: auto-detected) + # LLM_TIMEOUT_MS=900000 # Ceiling (ms) for one AI-parsing call before it is abandoned. One value for every provider, and the underlying HTTP client is held to it too. Generous by default so heavier parsing work fits without a code change; lower it on a cloud provider to fail fast. (default: 900000) # UNSPLASH_ACCESS_KEY= # Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs get blocked from (#1449). Get a free key at unsplash.com/developers. Overrides any key set per-admin in Admin > Settings. Can also be configured there instead of here. diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 4639766ba..c6dd18354 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -5,6 +5,7 @@ import { AdminService } from './admin.service'; import { TokenService } from '../tokens/token.service'; import { RegistrationInvitesService } from '../auth/registration-invites.service'; import { OauthService } from '../oauth/oauth.service'; +import { KitineraryExtractorService } from '../booking-import/kitinerary-extractor.service'; import { AdminUserCreateDto, AdminUserUpdateDto, @@ -68,6 +69,7 @@ export class AdminController { private readonly tokens: TokenService, private readonly invites: RegistrationInvitesService, private readonly oauth: OauthService, + private readonly kitinerary: KitineraryExtractorService, ) {} // ── Users ── @@ -149,6 +151,15 @@ export class AdminController { @Get('version-check') async versionCheck() { return this.admin.checkVersion(); } + /** + * Versions of the bundled tools an operator cannot see from the UI. Today the + * only one is the KItinerary extractor, whose age decides which booking + * providers parse at all — and a stale one is indistinguishable from an + * unsupported provider without this (#2261). + */ + @Get('system-info') + systemInfo() { return { kitinerary: this.kitinerary.describe() }; } + // ── Invites ── @Get('invites') listInvites() { return { invites: this.invites.listInvites() }; } diff --git a/server/src/nest/admin/admin.module.ts b/server/src/nest/admin/admin.module.ts index 061a06ee6..f26738318 100644 --- a/server/src/nest/admin/admin.module.ts +++ b/server/src/nest/admin/admin.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TokensModule } from '../tokens/tokens.module'; import { OauthModule } from '../oauth/oauth.module'; +import { KitineraryExtractorModule } from '../booking-import/kitinerary-extractor.module'; import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; import { VersionCheckJob } from './version-check.job'; @@ -24,7 +25,7 @@ import { PermissionsModule } from '../permissions/permissions.module'; import { AppConfigModule } from '../app-config/app-config.module'; @Module({ - imports: [AppConfigModule, PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule], + imports: [AppConfigModule, PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule, KitineraryExtractorModule], controllers: [AdminController], providers: [AdminService, VersionCheckJob, DemoResetJob], }) diff --git a/server/src/nest/booking-import/kitinerary-extractor.service.ts b/server/src/nest/booking-import/kitinerary-extractor.service.ts index a9bbee3d9..423f8e72c 100644 --- a/server/src/nest/booking-import/kitinerary-extractor.service.ts +++ b/server/src/nest/booking-import/kitinerary-extractor.service.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { join, extname } from 'node:path'; import { randomUUID } from 'node:crypto'; import { readEnv } from '../../app-config'; +import { logDebug } from '../audit/audit-log.logger'; import { execFileSync } from 'node:child_process'; import { promisify } from 'node:util'; import type { KiReservation } from './kitinerary.types'; @@ -15,15 +16,26 @@ const execFileAsync = promisify(execFile); const BINARY_NAME = 'kitinerary-extractor'; const TIMEOUT_MS = 30_000; const MAX_BUFFER = 5 * 1024 * 1024; +/** Ceiling on the raw stderr dump under LOG_LEVEL=debug. */ +const DEBUG_STDERR_LINES = 200; @Injectable() export class KitineraryExtractorService implements OnModuleInit { private binaryPath: string | null = null; + /** Frozen for the process: the binary cannot change under a running container, + * so it is read once at boot and never probed again. */ + private binaryVersion: string | null = null; + /** The configured path, kept even when it did not resolve — a typo there is the + * one failure that otherwise looks exactly like "not installed". */ + private configuredPath: string | null = null; onModuleInit() { + this.configuredPath = readEnv().integrations.kitineraryExtractorPath || null; this.binaryPath = this.findBinary(); if (this.binaryPath) { - console.log(`[KItinerary] extractor found at: ${this.binaryPath}`); + this.binaryVersion = this.probeVersion(this.binaryPath); + const version = this.binaryVersion ? ` (${this.binaryVersion})` : ''; + console.log(`[KItinerary] extractor found at: ${this.binaryPath}${version}`); } else { console.info('[KItinerary] extractor not found — booking import feature disabled'); } @@ -33,6 +45,34 @@ export class KitineraryExtractorService implements OnModuleInit { return this.binaryPath !== null; } + /** + * What the operator needs to tell three failure modes apart, which look + * identical from the UI today: nothing imported and no reason given (#2261). + * A stale extractor is missing years of vendor scripts and returns an empty + * list; a mistyped KITINERARY_EXTRACTOR_PATH disables the feature entirely; + * and a genuinely unsupported provider also returns nothing. `version: null` + * with `available: true` means the binary ran but did not answer `--version`. + */ + describe(): { available: boolean; path: string | null; version: string | null; configuredPath: string | null } { + return { + available: this.binaryPath !== null, + path: this.binaryPath, + version: this.binaryVersion, + configuredPath: this.configuredPath, + }; + } + + /** `kitinerary-extractor 6.3.3` → `6.3.3`. Never throws: a binary that cannot + * answer --version can still extract, so this must not disable the feature. */ + private probeVersion(path: string): string | null { + try { + const out = execFileSync(path, ['--version'], { stdio: 'pipe', timeout: 3000 }).toString(); + return out.trim().split(/\s+/).pop() || null; + } catch { + return null; + } + } + async extract(buffer: Buffer, fileName: string): Promise { if (!this.binaryPath) { throw new Error('kitinerary-extractor is not available on this system'); @@ -50,12 +90,21 @@ export class KitineraryExtractorService implements OnModuleInit { }); if (stderr?.trim()) { - // Filter expected noise: currency-symbol ambiguity warnings and vendor - // extractor script errors are normal (every matching script is tried; - // most won't match the current document). - const unexpected = stderr - .split('\n') - .filter(l => l.trim()) + const lines = stderr.split('\n').filter(l => l.trim()); + + // LOG_LEVEL=debug passes the raw stderr through. The lines the filter + // below drops are the only signal that a vendor extractor script is + // missing or failing in the installed KItinerary build — without them an + // outdated extractor and a genuinely unsupported provider look identical + // from outside: nothing imported, no reason given (#2261). Capped so one + // pathological document cannot flood the log file, and opt-in because the + // raw lines can carry document fragments. + for (const l of lines.slice(0, DEBUG_STDERR_LINES)) logDebug(`[KItinerary] ${fileName}: ${l}`); + + // At the default level, filter expected noise: currency-symbol ambiguity + // warnings and vendor extractor script errors are normal (every matching + // script is tried; most won't match the current document). + const unexpected = lines .filter(l => !l.includes('Ambig') && !l.includes('JS ERROR') && !l.includes('Invalid result type from script')); if (unexpected.length) { console.warn(`[KItinerary] stderr for "${fileName}":`, unexpected.join('\n')); diff --git a/server/src/nest/llm-parse/clients/openai-compatible.client.ts b/server/src/nest/llm-parse/clients/openai-compatible.client.ts index 05ac2aac2..2b6296768 100644 --- a/server/src/nest/llm-parse/clients/openai-compatible.client.ts +++ b/server/src/nest/llm-parse/clients/openai-compatible.client.ts @@ -6,6 +6,30 @@ import { readEnv } from '../../../app-config'; const MAX_TOKENS = 4096; +/** What one attempt differs in. Each field is switched on by a 400 that asked for it. */ +interface RequestShape { + tokenParam: 'max_tokens' | 'max_completion_tokens'; + jsonObject: boolean; + omitTemperature: boolean; +} + +/** + * Does this 400 body say the model refuses an explicit `temperature`? + * OpenAI: "Unsupported value: 'temperature' does not support 0 with this model. + * Only the default (1) value is supported."; Azure: "Unsupported parameter: + * 'temperature' is not supported with this model." + * + * The bare word is not enough. Dropping temperature costs the deterministic + * sampling every small local model depends on, and an error body can mention the + * word while complaining about something else. When the phrasing is unfamiliar + * nothing is lost — the request still falls through to the json_object retry, + * exactly as it does today. + */ +function rejectsTemperature(detail: string): boolean { + return /temperature/i.test(detail) + && /unsupported|not supported|does not support|only the default/i.test(detail); +} + /** * OpenAI-compatible chat-completions client. Covers both the "openai" cloud * provider and the "local" provider (Ollama / vLLM / llama.cpp / LM Studio), @@ -20,7 +44,15 @@ const MAX_TOKENS = 4096; * * Structured output is requested as `json_schema` first; servers that only * support `json_object` (DeepSeek, Mistral, some vLLM/llama.cpp) reject that - * with a 400, so the request is retried once in `json_object` mode. + * with a 400, so the request is retried once in `json_object` mode. Two further + * 400s are answered the same way: `max_tokens` becomes `max_completion_tokens` + * (#1760), and "temperature is not supported" drops the parameter (#2262). + * + * Those retries are a loop over what the server actually said, not a fixed + * chain. A reasoning model rejects `max_tokens` AND `temperature`, the API names + * only one parameter per response, and it may name either first — a chain of + * one-shot ifs survives only one of the two orders. Each remedy applies at most + * once, so this adds at most three extra requests. */ export class OpenAiCompatibleClient implements LlmExtractionClient { async extract(input: LlmExtractionInput): Promise[]> { @@ -48,13 +80,16 @@ export class OpenAiCompatibleClient implements LlmExtractionClient { // local server (Ollama/vLLM/llama.cpp), but newer OpenAI models reject it // with a 400 and demand `max_completion_tokens`. Start with the broadly // supported spelling and swap on that specific rejection (#1760). - const buildBody = (tokenParam: 'max_tokens' | 'max_completion_tokens', jsonObject: boolean) => { + const buildBody = (shape: RequestShape) => { const baseBody = { model: input.model, - [tokenParam]: MAX_TOKENS, + [shape.tokenParam]: MAX_TOKENS, // Extraction is a deterministic task — Ollama defaults to 0.7, which makes - // small models (NuExtract) drop fields or return empty. Pin to 0. - temperature: 0, + // small models (NuExtract) drop fields or return empty. Pin to 0, and only + // leave it out once a server has explicitly rejected the parameter (#2262). + // The first attempt always carries it, and that is the only one a local + // server ever sees. + ...(shape.omitTemperature ? {} : { temperature: 0 }), // NuExtract wants the template (in the user turn) to be the only instruction // — a system prompt or a json_schema grammar derails it. messages: nuextract @@ -67,31 +102,43 @@ export class OpenAiCompatibleClient implements LlmExtractionClient { if (nuextract) return baseBody; return { ...baseBody, - response_format: jsonObject + response_format: shape.jsonObject ? { type: 'json_object' as const } : { type: 'json_schema' as const, json_schema: { name: 'reservations', schema: input.jsonSchema, strict: false } }, }; }; - let tokenParam: 'max_tokens' | 'max_completion_tokens' = 'max_tokens'; - let res = await this.send(url, buildBody(tokenParam, false), input.apiKey); + const shape: RequestShape = { tokenParam: 'max_tokens', jsonObject: false, omitTemperature: false }; + const tried = { tokenParam: false, temperature: false, jsonObject: false }; + + let res = await this.send(url, buildBody(shape), input.apiKey); let detail = res.ok ? '' : await res.text().catch(() => ''); - // Newer OpenAI models 400 on `max_tokens` — retry the whole request (schema - // and all) with `max_completion_tokens` before giving up. - if (!res.ok && res.status === 400 && detail.includes('max_completion_tokens')) { - tokenParam = 'max_completion_tokens'; - res = await this.send(url, buildBody(tokenParam, false), input.apiKey); - detail = res.ok ? '' : await res.text().catch(() => ''); - } - - // Servers that only support `json_object` (DeepSeek, Mistral, some - // vLLM/llama.cpp) reject `json_schema` with a 400 — retry once in - // `json_object` mode (keeping whichever token param stuck). The system - // prompt already dictates the exact output shape (and mentions JSON, which - // json_object mode requires). - if (!res.ok && res.status === 400 && !nuextract) { - res = await this.send(url, buildBody(tokenParam, true), input.apiKey); + // A 400 is the server naming the parameter it dislikes. Apply every remedy it + // names, and only when it names none fall back to json_object — the unchanged + // behaviour for servers that reject `json_schema` with an unspecific 400. The + // system prompt already dictates the exact output shape and mentions JSON, + // which json_object mode requires. + while (!res.ok && res.status === 400) { + let named = false; + if (!tried.tokenParam && detail.includes('max_completion_tokens')) { + shape.tokenParam = 'max_completion_tokens'; + tried.tokenParam = true; + named = true; + } + if (!tried.temperature && rejectsTemperature(detail)) { + shape.omitTemperature = true; + tried.temperature = true; + named = true; + } + if (!named) { + // NuExtract sends no response_format at all, so it has nothing to fall + // back to and the 400 is final. + if (tried.jsonObject || nuextract) break; + shape.jsonObject = true; + tried.jsonObject = true; + } + res = await this.send(url, buildBody(shape), input.apiKey); detail = res.ok ? '' : await res.text().catch(() => ''); } diff --git a/server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts b/server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts index cfd21e0ea..8dfdf4448 100644 --- a/server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts +++ b/server/tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts @@ -8,7 +8,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; * at all, and every branch that resolves it is a filesystem lookup that behaves * differently on the three platforms TREK ships to. */ -const { existsSync, readdirSync, readEnv, execFileSync, execFile } = vi.hoisted(() => ({ +const { existsSync, readdirSync, readEnv, execFileSync, execFile, logDebug } = vi.hoisted(() => ({ + logDebug: vi.fn(), existsSync: vi.fn(), readdirSync: vi.fn(), readEnv: vi.fn(), @@ -29,6 +30,11 @@ vi.mock('node:fs', () => ({ // installed. vi.mock('node:child_process', () => ({ execFileSync, execFile })); vi.mock('../../../../src/app-config', () => ({ readEnv })); +// The logger reads readEnv().app.logLevel while its module is evaluated, so it +// has to be mocked rather than imported for real. +vi.mock('../../../../src/nest/audit/audit-log.logger', () => ({ + logDebug, logInfo: vi.fn(), logWarn: vi.fn(), logError: vi.fn(), +})); import { join } from 'node:path'; import { KitineraryExtractorService } from '../../../../src/nest/booking-import/kitinerary-extractor.service'; @@ -124,3 +130,91 @@ describe('KitineraryExtractorService binary probe', () => { ); }); }); + +// #2261 — the extractor's age decides which providers parse at all, and a stale +// one is indistinguishable from an unsupported provider without this. +describe('KitineraryExtractorService diagnostics', () => { + it('KIT-EXT-010: reports the version the binary prints', () => { + existsSync.mockImplementation((p: string) => p === '/opt/ki'); + execFileSync.mockReturnValue(Buffer.from('kitinerary-extractor 6.3.3 +')); + + expect(boot({ kitineraryExtractorPath: '/opt/ki' }).describe()).toEqual({ + available: true, path: '/opt/ki', version: '6.3.3', configuredPath: '/opt/ki', + }); + }); + + it('KIT-EXT-011: a binary that will not answer --version stays usable', () => { + existsSync.mockImplementation((p: string) => p === '/opt/ki'); + execFileSync.mockImplementation(() => { throw new Error('nope'); }); + + const described = boot({ kitineraryExtractorPath: '/opt/ki' }).describe(); + expect(described.available).toBe(true); + expect(described.version).toBeNull(); + }); + + it('KIT-EXT-012: a configured path that does not exist is reported as such', () => { + existsSync.mockReturnValue(false); + + expect(boot({ kitineraryExtractorPath: '/nope/ki' }).describe()).toEqual({ + available: false, path: null, version: null, configuredPath: '/nope/ki', + }); + }); + + it('KIT-EXT-013: nothing found at all reads as nothing configured', () => { + expect(boot().describe()).toEqual({ + available: false, path: null, version: null, configuredPath: null, + }); + }); + + it('KIT-EXT-014: the version is probed for the /usr/lib branch too, not only for PATH', () => { + const found = join('/usr/lib', 'x86_64-linux-gnu', 'libexec', 'kf6', 'kitinerary-extractor'); + readdirSync.mockReturnValue(['x86_64-linux-gnu']); + existsSync.mockImplementation((p: string) => p === found); + execFileSync.mockReturnValue(Buffer.from('kitinerary-extractor 6.3.3')); + + expect(boot().describe().version).toBe('6.3.3'); + }); +}); + +describe('KitineraryExtractorService stderr handling', () => { + /** Drive one extraction with a canned stderr. */ + async function extractWith(stderr: string) { + existsSync.mockImplementation((p: string) => p === '/opt/ki'); + execFileSync.mockReturnValue(Buffer.from('kitinerary-extractor 6.3.3')); + execFile.mockImplementation((_bin: string, _args: string[], _opts: unknown, cb: (e: unknown, r: unknown) => void) => { + cb(null, { stdout: '[]', stderr }); + }); + await boot({ kitineraryExtractorPath: '/opt/ki' }).extract(Buffer.from(''), 'booking.eml'); + } + + it('KIT-EXT-015: passes every raw line to the debug log, script errors included', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await extractWith('JS ERROR: lufthansa.js failed +Invalid result type from script +'); + + const logged = logDebug.mock.calls.map(c => String(c[0])); + expect(logged.some(l => l.includes('JS ERROR: lufthansa.js failed'))).toBe(true); + expect(logged.some(l => l.includes('Invalid result type from script'))).toBe(true); + // And the default level still says nothing about them. + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('KIT-EXT-016: an unexpected line still reaches console.warn', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await extractWith('JS ERROR: noise +something genuinely odd +'); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('booking.eml'), 'something genuinely odd'); + warn.mockRestore(); + }); + + it('KIT-EXT-017: the debug dump is capped so one document cannot flood the log', async () => { + await extractWith(Array.from({ length: 500 }, (_, i) => `line ${i}`).join(' +')); + expect(logDebug.mock.calls.length).toBe(200); + }); +}); diff --git a/server/tests/unit/nest/llm-parse/clients.test.ts b/server/tests/unit/nest/llm-parse/clients.test.ts index 00cb758cd..6a96ca0d1 100644 --- a/server/tests/unit/nest/llm-parse/clients.test.ts +++ b/server/tests/unit/nest/llm-parse/clients.test.ts @@ -121,6 +121,72 @@ describe('OpenAiCompatibleClient', () => { expect(second.messages).toEqual(first.messages); }); + // #2262 — reasoning models (the gpt-5 family) reject any explicit temperature. + // temperature: 0 sat in the shared body, so it went out on every attempt + // including both retries, and the import could not succeed at all. + const TEMP_400 = { error: { message: "Unsupported value: 'temperature' does not support 0 with this model. Only the default (1) value is supported.", code: 'unsupported_value' } }; + + it('drops temperature when the model rejects it (400, #2262)', async () => { + safeFetchLlmMock + .mockResolvedValueOnce(jsonResponse(TEMP_400, false, 400)) + .mockResolvedValueOnce(jsonResponse({ choices: [{ message: { content: '{"reservations":[{"@type":"FlightReservation"}]}' } }] })); + const out = await new OpenAiCompatibleClient().extract(baseInput); + expect(out).toEqual([{ '@type': 'FlightReservation' }]); + expect(safeFetchLlmMock).toHaveBeenCalledTimes(2); + + const first = JSON.parse((safeFetchLlmMock.mock.calls[0][1] as RequestInit).body as string); + const second = JSON.parse((safeFetchLlmMock.mock.calls[1][1] as RequestInit).body as string); + expect(first.temperature).toBe(0); + expect('temperature' in second).toBe(false); + // Only temperature goes; the schema and the token param are untouched. + expect(second.response_format.type).toBe('json_schema'); + expect(second.max_tokens).toBe(4096); + }); + + // A gpt-5 rejects both parameters, and the API names one per response. Either + // order has to converge, which a chain of one-shot ifs cannot do. + it('combines the token-param and temperature remedies, token param named first', async () => { + safeFetchLlmMock + .mockResolvedValueOnce(jsonResponse({ error: { message: "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead." } }, false, 400)) + .mockResolvedValueOnce(jsonResponse(TEMP_400, false, 400)) + .mockResolvedValueOnce(jsonResponse({ choices: [{ message: { content: '{"reservations":[{"@type":"FlightReservation"}]}' } }] })); + const out = await new OpenAiCompatibleClient().extract(baseInput); + expect(out).toEqual([{ '@type': 'FlightReservation' }]); + expect(safeFetchLlmMock).toHaveBeenCalledTimes(3); + + const third = JSON.parse((safeFetchLlmMock.mock.calls[2][1] as RequestInit).body as string); + expect(third.max_completion_tokens).toBe(4096); + expect('temperature' in third).toBe(false); + expect(third.response_format.type).toBe('json_schema'); + }); + + it('combines them the other way round too, temperature named first', async () => { + safeFetchLlmMock + .mockResolvedValueOnce(jsonResponse(TEMP_400, false, 400)) + .mockResolvedValueOnce(jsonResponse({ error: { message: "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead." } }, false, 400)) + .mockResolvedValueOnce(jsonResponse({ choices: [{ message: { content: '{"reservations":[{"@type":"FlightReservation"}]}' } }] })); + const out = await new OpenAiCompatibleClient().extract(baseInput); + expect(out).toEqual([{ '@type': 'FlightReservation' }]); + expect(safeFetchLlmMock).toHaveBeenCalledTimes(3); + + const third = JSON.parse((safeFetchLlmMock.mock.calls[2][1] as RequestInit).body as string); + expect(third.max_completion_tokens).toBe(4096); + expect('temperature' in third).toBe(false); + }); + + it('keeps temperature when a 400 only mentions the word in passing', async () => { + safeFetchLlmMock + .mockResolvedValueOnce(jsonResponse({ error: { message: 'Your prompt mentions temperature but the schema is invalid' } }, false, 400)) + .mockResolvedValueOnce(jsonResponse({ choices: [{ message: { content: '{"reservations":[]}' } }] })); + await new OpenAiCompatibleClient().extract(baseInput); + + const second = JSON.parse((safeFetchLlmMock.mock.calls[1][1] as RequestInit).body as string); + // Not a parameter rejection, so it falls through to the json_object retry + // with deterministic sampling intact — which is what local models need. + expect(second.temperature).toBe(0); + expect(second.response_format.type).toBe('json_object'); + }); + it('throws when the json_object retry also fails (400 twice)', async () => { safeFetchLlmMock .mockResolvedValueOnce(jsonResponse({ error: 'no json_schema' }, false, 400)) diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md index 58c6e20f5..5237cdc63 100644 --- a/wiki/Environment-Variables.md +++ b/wiki/Environment-Variables.md @@ -269,7 +269,12 @@ Request bodies validated with Zod are documented automatically from the same sch The official TREK Docker image bundles the binary automatically: on both amd64 and arm64 it installs `libkitinerary-bin` via apt (Debian trixie) and symlinks it to `/usr/local/bin/kitinerary-extractor`, which the image also pins via `KITINERARY_EXTRACTOR_PATH`. When running TREK from source, install `libkitinerary-bin` (Debian trixie / -Ubuntu 25.04+) or download the static binary directly and place it anywhere on `PATH`. The +Ubuntu 25.04+). There is no static binary to download; to run a newer extractor than your distribution +packages, build one and point `KITINERARY_EXTRACTOR_PATH` at it, or derive an image from the official one with your own +apt sources. The extractor's version is in the startup log and in `GET /api/admin/system-info` (admin only) — worth +checking before reporting that a provider is unsupported, since a missing vendor script and an unsupported provider +both come back empty. `LOG_LEVEL=debug` additionally passes the extractor's raw stderr through, including the +`JS ERROR` lines that name a failing script; it is read once at startup, so it needs a restart. The `GET /api/health/features` endpoint returns `{ "bookingImport": true }` when the binary is found. The Import button in the Reservations panel is hidden only when **neither** extractor is available — an instance running the AI Parsing addon still offers it with `bookingImport: false`.