From a12ab00fe2db3fb5d2d2a602970cef68079b0c1a Mon Sep 17 00:00:00 2001 From: Sylvain Gougouzian Date: Mon, 13 Jul 2026 20:34:52 +0200 Subject: [PATCH] feat(config): configurable DMR endpoint and reasoning toggle Add dmrScheme/dmrHost/dmrPort with validated fallbacks so paikea can target a remote or containerised runner, and a thinking flag (off by default) that drives enable_thinking on thinking-capable models. --- src/__tests__/config.test.ts | 64 +++++++++++++++++++++++++++++ src/services/dmr-client.ts | 18 +++++--- src/state/config.ts | 80 ++++++++++++++++++++++++++++++++++-- 3 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/config.test.ts diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts new file mode 100644 index 0000000..c1e81f6 --- /dev/null +++ b/src/__tests__/config.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { + dmrBaseUrl, + resolveDmrHost, + resolveDmrPort, + resolveDmrScheme, +} from "../state/config"; + +describe("DMR scheme config", () => { + it("defaults to http when unset or unknown", () => { + expect(resolveDmrScheme(undefined)).toBe("http"); + expect(resolveDmrScheme("")).toBe("http"); + expect(resolveDmrScheme("ftp")).toBe("http"); + }); + + it("honours https (case-insensitive)", () => { + expect(resolveDmrScheme("https")).toBe("https"); + expect(resolveDmrScheme("HTTPS")).toBe("https"); + }); +}); + +describe("DMR host config", () => { + it("defaults to localhost when unset or blank", () => { + expect(resolveDmrHost(undefined)).toBe("localhost"); + expect(resolveDmrHost("")).toBe("localhost"); + expect(resolveDmrHost(" ")).toBe("localhost"); + }); + + it("honours a configured host and trims it", () => { + expect(resolveDmrHost("192.168.1.50")).toBe("192.168.1.50"); + expect(resolveDmrHost(" dmr.local ")).toBe("dmr.local"); + }); +}); + +describe("DMR port config", () => { + it("defaults to 12434 when unset", () => { + expect(resolveDmrPort(undefined)).toBe(12434); + }); + + it("honours a valid configured port", () => { + expect(resolveDmrPort(8080)).toBe(8080); + expect(resolveDmrPort(1)).toBe(1); + expect(resolveDmrPort(65535)).toBe(65535); + }); + + it("rejects out-of-range or non-integer ports", () => { + expect(resolveDmrPort(0)).toBe(12434); + expect(resolveDmrPort(-5)).toBe(12434); + expect(resolveDmrPort(65536)).toBe(12434); + expect(resolveDmrPort(80.5)).toBe(12434); + expect(resolveDmrPort(Number.NaN)).toBe(12434); + }); +}); + +describe("DMR base URL", () => { + it("builds the base URL from scheme, host and port", () => { + expect(dmrBaseUrl("http", "localhost", 12434)).toBe( + "http://localhost:12434/engines/v1", + ); + expect(dmrBaseUrl("https", "dmr.example.com", 443)).toBe( + "https://dmr.example.com:443/engines/v1", + ); + }); +}); diff --git a/src/services/dmr-client.ts b/src/services/dmr-client.ts index 1f8743f..f099466 100644 --- a/src/services/dmr-client.ts +++ b/src/services/dmr-client.ts @@ -1,3 +1,4 @@ +import { getDmrBaseUrl } from "../state/config"; import type { DMRChatChunk, DMRChatRequest, @@ -6,11 +7,9 @@ import type { ToolDefinition, } from "../types"; -const DMR_BASE = "http://localhost:12434/engines/v1"; - export async function listModels(): Promise { try { - const res = await fetch(`${DMR_BASE}/models`); + const res = await fetch(`${getDmrBaseUrl()}/models`); const data: unknown = await res.json(); const typed = data as { data?: { id: string }[] }; return (typed.data ?? []).map((m) => ({ @@ -38,18 +37,26 @@ export async function* streamChat( model: string, tools?: ToolDefinition[], signal?: AbortSignal, + disableThinking?: boolean, ): AsyncGenerator { const body: DMRChatRequest & { tools?: { type: "function"; function: { name: string; description: string; parameters: unknown }; }[]; + chat_template_kwargs?: { enable_thinking: boolean }; } = { model, messages, stream: true, }; + // Qwen3-family templates honour enable_thinking via chat_template_kwargs; + // disabling it skips the model's chain-of-thought for faster answers. + if (disableThinking) { + body.chat_template_kwargs = { enable_thinking: false }; + } + if (tools && tools.length > 0) { body.tools = tools.map((t) => ({ type: "function" as const, @@ -62,8 +69,9 @@ export async function* streamChat( } const toolCallAccumulators = new Map(); + const endpoint = `${getDmrBaseUrl()}/chat/completions`; - const res = await fetch(`${DMR_BASE}/chat/completions`, { + const res = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), @@ -73,7 +81,7 @@ export async function* streamChat( // If tools caused a 400, retry without them if (!res.ok && res.status === 400 && body.tools) { delete body.tools; - const retryRes = await fetch(`${DMR_BASE}/chat/completions`, { + const retryRes = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/src/state/config.ts b/src/state/config.ts index 63a5950..fa03723 100644 --- a/src/state/config.ts +++ b/src/state/config.ts @@ -4,15 +4,34 @@ import { join } from "node:path"; const CONFIG_DIR = join(process.env.HOME ?? "~", ".paikea"); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); +const DEFAULT_DMR_SCHEME = "http"; +const DEFAULT_DMR_HOST = "localhost"; +const DEFAULT_DMR_PORT = 12434; + +type DmrScheme = "http" | "https"; + interface PaikeaConfig { theme: string; + // Scheme of the Docker Model Runner OpenAI-compatible API. Optional — + // falls back to DEFAULT_DMR_SCHEME unless explicitly "https". + dmrScheme?: DmrScheme; + // Host of the DMR API (hostname or IP, no scheme). Optional — falls back + // to DEFAULT_DMR_HOST when absent or blank. + dmrHost?: string; + // Port of the DMR API. Optional — falls back to DEFAULT_DMR_PORT when + // absent or invalid. + dmrPort?: number; + // Whether thinking-capable models should actually reason before answering. + // Optional — defaults to false (faster, direct responses). Set true to + // re-enable chain-of-thought. + thinking?: boolean; } function ensureConfigDir() { mkdirSync(CONFIG_DIR, { recursive: true }); } -// Guess a sensible default theme from the terminal's background color. +// Guess a sensible default marine theme from the terminal's background color. // Terminals like iTerm2, rxvt and many others export COLORFGBG as "fg;bg" // where the bg field is an ANSI color index (7 or 15 ≈ light background). function detectDefaultTheme(): string { @@ -20,9 +39,9 @@ function detectDefaultTheme(): string { if (colorfgbg) { const parts = colorfgbg.split(";"); const bg = Number(parts[parts.length - 1]); - if (bg === 7 || bg === 15) return "light"; + if (bg === 7 || bg === 15) return "dawn"; } - return "dark"; + return "deep-sea"; } export function loadConfig(): PaikeaConfig { @@ -51,3 +70,58 @@ export function saveTheme(themeName: string) { config.theme = themeName; saveConfig(config); } + +// Thinking is off unless explicitly enabled in the config. +export function getThinkingEnabled(): boolean { + return loadConfig().thinking === true; +} + +export function saveThinkingEnabled(enabled: boolean) { + const config = loadConfig(); + config.thinking = enabled; + saveConfig(config); +} + +// Only "https" overrides the default; any other value (missing, typo, wrong +// case) resolves to the default scheme. +export function resolveDmrScheme(configured?: string): DmrScheme { + return configured?.toLowerCase() === "https" ? "https" : DEFAULT_DMR_SCHEME; +} + +// A host is usable only if it is a non-blank string; anything else falls +// back to the default. The value is trimmed of surrounding whitespace. +export function resolveDmrHost(configured?: string): string { + return typeof configured === "string" && configured.trim() !== "" + ? configured.trim() + : DEFAULT_DMR_HOST; +} + +// A port is usable only if it is a whole number in the valid TCP range; +// anything else (missing, string, out of range) falls back to the default. +export function resolveDmrPort(configured?: number): number { + return typeof configured === "number" && + Number.isInteger(configured) && + configured > 0 && + configured < 65536 + ? configured + : DEFAULT_DMR_PORT; +} + +export function dmrBaseUrl( + scheme: DmrScheme, + host: string, + port: number, +): string { + return `${scheme}://${host}:${port}/engines/v1`; +} + +// Resolved base URL of the Docker Model Runner API, honouring `dmrScheme`, +// `dmrHost` and `dmrPort` from ~/.paikea/config.json. +export function getDmrBaseUrl(): string { + const config = loadConfig(); + return dmrBaseUrl( + resolveDmrScheme(config.dmrScheme), + resolveDmrHost(config.dmrHost), + resolveDmrPort(config.dmrPort), + ); +} -- 2.51.2