fix(llm): let a model refuse temperature, and say which extractor is installed

Two reports, both about an import that fails without saying why.

#2262 — temperature: 0 sat in the shared request body, so it went out on every
attempt including both retries. Reasoning models (the gpt-5 family) reject any
explicit temperature, and the json_object retry fires on any 400, so the import
spent two doomed requests and could not succeed at all. The parameter is now
dropped once a server has explicitly said it will not take it.

Not as a third link in the chain, though. A gpt-5 rejects max_tokens AND
temperature, the API names one parameter per response, and it may name either
first — a chain of one-shot ifs converges on only one of the two orders. The
retries are a loop over what the server actually said instead: each remedy
applies at most once and they compose. Detection is a phrase, not the bare word,
because dropping temperature costs the deterministic sampling that small local
models depend on, and an unfamiliar wording still falls through to the
json_object retry exactly as before. NuExtract and Ollama are untouched: the
first attempt always carries temperature, and it is the only one they ever see.

#2261 — the bundled extractor is Debian trixie's 24.12.3, roughly eighteen
months behind upstream, and the reporter is right that this is invisible: a
stale extractor, a mistyped KITINERARY_EXTRACTOR_PATH and a genuinely
unsupported provider all look the same from the UI, which is nothing imported
and no reason given. The version now appears in the startup log and in a new
admin-only GET /api/admin/system-info, and LOG_LEVEL=debug passes the
extractor's raw stderr through — the JS ERROR and Invalid-result-type lines the
filter drops are the only evidence that a vendor script is missing or failing.
Capped at 200 lines, and opt-in because those lines can carry document
fragments.

Not bumping the package: the filter the reporter needs first ships in KDE Gear
26.08 and no Debian suite carries it, and sid's 26.04.3 wants KF6 6.22 and Qt6
6.10.2 against trixie's 6.6 and 6.8.2 — a partial upgrade of Qt6, KF6 and libc6
from unstable into the production image, on two architectures, that still would
not fix the reported carrier. It comes for free with the base-image move after
Debian 14.
This commit is contained in:
Maurice
2026-09-06 14:21:00 +02:00
committed by Maurice
parent 8fe90d1783
commit 888cd4ee08
8 changed files with 308 additions and 33 deletions
+2
View File
@@ -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.
+11
View File
@@ -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() }; }
+2 -1
View File
@@ -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],
})
@@ -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<KiReservation[]> {
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'));
@@ -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<Record<string, unknown>[]> {
@@ -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(() => '');
}
@@ -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);
});
});
@@ -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))
+6 -1
View File
@@ -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`.