diff --git a/src/providers/ollama-adapter.ts b/src/providers/ollama-adapter.ts index 3e875a3..2e7b2ce 100644 --- a/src/providers/ollama-adapter.ts +++ b/src/providers/ollama-adapter.ts @@ -343,6 +343,39 @@ function repairJson(input: string): string { return s; } +/** + * The ordered `think` values to retry with when a call comes back empty. + * + * The retry ladder exists because some models (e.g. glm-5.1:cloud) default to thinking-only output + * when `think` is omitted, emitting reasoning and no content — nudging them to an explicit mode + * gets a real answer out. + * + * `think: false` is the one request that must not climb that ladder. It is an operator decision + * ("this model wastes its budget thinking"), so silently re-enabling thinking on an empty response + * would undo exactly what was asked for — and for a model whose thinking ratio is 85%, that turns a + * deliberate 7x speedup back into the slow path without a word. Explicit `false` therefore falls + * back only to the model's own default, and only as a last resort so an empty reply is not a dead + * end. Measured 2026-08-12 on muse-glimmer: think:false produced 0 empty answers in 20 questions, + * so this fallback should stay cold in practice. + */ +export function buildThinkCandidates(requested?: boolean | 'high' | 'medium' | 'low') { + const candidates: Array = []; + const push = (v: boolean | 'high' | 'medium' | 'low' | undefined) => { + if (!candidates.some(x => x === v)) candidates.push(v); + }; + if (requested === false) { + push(false); + push(undefined); + return candidates; + } + push(requested); + if (requested !== 'low') push('low'); + push(undefined); + if (requested !== true) push(true); + push('medium'); + return candidates; +} + export class OllamaAdapter implements LLMProvider { readonly id: 'ollama' | 'ollama_local'; private client: Ollama; @@ -864,15 +897,6 @@ export class OllamaAdapter implements LLMProvider { } private buildThinkCandidates(requested?: boolean | 'high' | 'medium' | 'low') { - const candidates: Array = []; - const push = (v: boolean | 'high' | 'medium' | 'low' | undefined) => { - if (!candidates.some(x => x === v)) candidates.push(v); - }; - push(requested); - if (requested !== 'low') push('low'); - push(undefined); - if (requested !== true) push(true); - push('medium'); - return candidates; + return buildThinkCandidates(requested); } } diff --git a/tests/think-candidates.test.ts b/tests/think-candidates.test.ts new file mode 100644 index 0000000..034ccdc --- /dev/null +++ b/tests/think-candidates.test.ts @@ -0,0 +1,53 @@ +/** + * buildThinkCandidates — think 재시도 사다리 + * + * 빈 응답이 오면 다른 think 값으로 재시도한다. glm-5.1:cloud처럼 think를 생략하면 생각만 하고 + * content를 안 내놓는 모델을 건지려고 만든 장치다. + * + * 문제는 `think:false`였다. 2026-08-12 muse-glimmer(생각비중 85%, 체감 3.1 tok/s) 측정 후 + * 생각을 끄기로 했는데, 사다리가 빈 응답 한 번에 thinking을 도로 켜버리면 그 결정이 조용히 + * 뒤집힌다. 명시적 false는 모델 기본값까지만 물러선다. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildThinkCandidates } from '../src/providers/ollama-adapter'; + +describe('think:false는 사다리를 오르지 않는다', () => { + test('false → [false, undefined]까지만', () => { + assert.deepEqual(buildThinkCandidates(false), [false, undefined]); + }); + + test('생각을 켜는 값이 하나도 없다', () => { + const c = buildThinkCandidates(false); + for (const v of [true, 'low', 'medium', 'high']) { + assert.ok(!c.includes(v as any), `${v}가 포함되면 안 됨`); + } + }); + + test('빈 응답이 막다른 길이 되지 않게 기본값 폴백은 남긴다', () => { + assert.ok(buildThinkCandidates(false).includes(undefined)); + }); +}); + +describe('나머지 경로는 그대로', () => { + test('요청값이 맨 앞에 온다', () => { + assert.equal(buildThinkCandidates(true)[0], true); + assert.equal(buildThinkCandidates('low')[0], 'low'); + assert.equal(buildThinkCandidates(undefined)[0], undefined); + }); + + test('true 요청은 여전히 여러 모드로 재시도한다', () => { + const c = buildThinkCandidates(true); + assert.ok(c.includes('low')); + assert.ok(c.includes(undefined)); + assert.ok(c.includes('medium')); + }); + + test('중복이 없다', () => { + for (const req of [undefined, true, false, 'low', 'medium', 'high'] as const) { + const c = buildThinkCandidates(req); + assert.equal(new Set(c).size, c.length, `중복 발생: ${JSON.stringify(c)}`); + } + }); +});