Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/model-type-extrabody.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
'smart-decisions': minor
---

**Breaking (pre-1.0):** the provider settings on `Question` moved into a new `Model` type.

- `apiBaseUrl`, `apiKey` and `model` no longer sit flat on `Question`; they are now
`question.model.{apiBaseUrl, apiKey, model}`. This groups everything about how the
model is reached and served into one reusable object to pass to every `choice()`.
- `Model` gains `extraBody`: extra fields forwarded verbatim into the chat completions
request body for engine- or model-specific settings (i.e. `chat_template_kwargs`
thinking toggles on llama.cpp/vLLM/SGLang, `reasoning_effort` on OpenAI/OpenRouter/
Ollama, Ollama's native `think`). Reserved request keys the library's single-token
trick depends on (`model`, `messages`, `stream`, `logprobs`, `top_logprobs`,
`max_tokens`, `temperature`) cannot be overridden through it; `chat_template_kwargs`
merges one level deep with user keys winning per key.
- System 1's request now asks for `top_logprobs: 20` instead of 50: 20 is the highest
portable window (OpenAI and OpenRouter cap it at 20, vLLM's server default
`--max-logprobs` is 20). On llama.cpp (accepts up to 50) the smaller window is
enough for realistic option counts.
- New `Model` type exported from the package root; `generateText` accepts extra
top-level body fields via `ChatCompletionRequest`'s index signature.

### Hardening

- The transport no longer follows redirects (`redirect: 'error'`): the Bearer token
stays off unexpected paths, and a misconfigured base URL fails loudly.
- Response bodies are read under a 10 MB safety cap instead of being buffered
unconditionally; an over-cap body fails fast and is not retried. A declared
`Content-Length` over the cap is refused before reading anything.
- Non-finite `maxRetries` (i.e. `NaN`) no longer causes an infinite retry loop: it
falls back to the default, negatives clamp to 0 and fractions to whole attempts.
- `extraBody` ignores `__proto__` and `constructor` keys, so prototype-smuggled
config can never re-parent the request object.
- Malformed entries inside `top_logprobs` (missing/null token) are skipped instead
of crashing mid-read.
- Error messages strip query strings from the request URL, so providers that take
credentials as query parameters cannot leak them into logs.
- The endpoint path is joined through the URL API, preserving a query string in
`apiBaseUrl` instead of swallowing it, and an invalid base URL throws a clear
`Invalid model.apiBaseUrl` error.
- CI actions are pinned by commit SHA; the release script spawns every subprocess
as an argv array (no shell), so interpolated values can never be re-parsed as
shell syntax.
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ jobs:
node: [22, 24] # engines: >=22 — test the floor and the current LTS

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
# Pin the PR head SHA so coverage line numbers map onto the diff view.
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ matrix.node }}
cache: npm
Expand All @@ -46,7 +46,7 @@ jobs:
matrix.node == 22 &&
github.event_name == 'pull_request' &&
!github.event.pull_request.head.repo.fork
uses: actions/upload-code-coverage@v1
uses: actions/upload-code-coverage@bfa741d815a28cb064a8e3a0837e577457a017d5 # v1
with:
file: coverage/cobertura-coverage.xml
language: JavaScript
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
Expand All @@ -37,7 +37,7 @@ jobs:

- run: npm ci

- uses: changesets/action@v1
- uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
with:
# Single command: the action whitespace-splits this input and spawns
# it as args (no shell), so chaining belongs in the package script.
Expand Down
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ npm install smart-decisions
```typescript
import { choice } from 'smart-decisions';

const answer = await choice({
const model = {
apiBaseUrl: 'http://localhost:8000/v1', // your API url. i.e. your llama.cpp server
apiKey: 'a-super-secret-api-key', // as required or not by your provider
model: '/models/Qwen3.5-4B-Q4_K_M.gguf',
};

const answer = await choice({
model,
state: "It is raining and I am at home. I'm bored.",
instructions: 'Give me a good plan to do now',
criteria: {
Expand Down Expand Up @@ -82,6 +86,12 @@ Classify incoming tickets. When the distribution is too flat to trust, the retur
```typescript
import { choice } from 'smart-decisions';

const model = {
apiBaseUrl: 'http://localhost:8000/v1',
apiKey: 'a-super-secret-api-key',
model: '/models/Qwen3.5-4B-Q4_K_M.gguf',
};

const criteria = {
billing: 'Customer asks about invoices, payments, refunds or charges',
technical: 'Customer reports a bug, error or product malfunction',
Expand All @@ -90,9 +100,7 @@ const criteria = {
};

const answer = await choice({
apiBaseUrl: 'http://localhost:8000/v1',
apiKey: 'a-super-secret-api-key',
model: '/models/Qwen3.5-4B-Q4_K_M.gguf',
model,
mode: 'system1',
state:
'Customer support ticket:\n"Hi, I was charged twice this month. Can you refund the extra payment?"',
Expand All @@ -114,10 +122,7 @@ routeTo(answer.choice); // 'billing'

> ⚠️ **Work in progress.**
> Only [llama.cpp](https://github.com/ggml-org/llama.cpp) has been
> tested for now.
>
> Other OpenAI-compatible servers (vLLM, LM Studio, Ollama, …)
> should keep working as long as they return logprobs, but are unverified yet.
> tested for now

## Use case

Expand Down
6 changes: 5 additions & 1 deletion examples/basic.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { choice } from '../src/index.js';

const answer = await choice({
const model = {
apiBaseUrl: process.env.API_BASE_URL!,
apiKey: process.env.API_KEY!,
model: '/models/Qwen3.5-4B-Q4_K_M.gguf',
};

const answer = await choice({
model: model,
mode: 'system1',
state: "It is raining and I am at home. I'm bored.",
instructions: 'Give me a good plan to do now',
Expand Down
10 changes: 7 additions & 3 deletions examples/ticket-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ const criteria = {

const ticket = 'Hi, I was charged twice this month. Can you refund the extra payment?';

const model = {
apiBaseUrl: process.env.API_BASE_URL!,
apiKey: process.env.API_KEY!,
model: '/models/Qwen3.5-4B-Q4_K_M.gguf',
};

async function main() {
const answer = await choice({
apiBaseUrl: process.env.API_BASE_URL!,
apiKey: process.env.API_KEY!,
model: '/models/Qwen3.5-4B-Q4_K_M.gguf',
model: model,
mode: 'system1',
state: `Customer support ticket:\n"${ticket}"`,
instructions: 'Classify the ticket into the right department',
Expand Down
26 changes: 18 additions & 8 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand All @@ -20,18 +20,22 @@ import { join } from 'node:path';
* exists. Runs on every publish-path run, so a stranded announcement heals
* on the next run — necessary because the registry's post-upload validation
* window can keep a freshly accepted version invisible to registry checks.
*
* Every subprocess is spawned as an argv array (no shell): interpolated values
* can never be re-parsed as shell syntax, so a tampered package.json version
* cannot turn into command injection.
*/
const { name, version } = JSON.parse(readFileSync('package.json', 'utf8'));
const tag = `v${version}`;

const env = { ...process.env };
delete env.GITHUB_TOKEN;

execSync('npm run build', { stdio: 'inherit' });
execFileSync('npm', ['run', 'build'], { stdio: 'inherit' });

let registered = false;
try {
execSync(`npm view ${name}@${version} version`, { env, stdio: 'ignore' });
execFileSync('npm', ['view', `${name}@${version}`, 'version'], { env, stdio: 'ignore' });
registered = true;
} catch {
registered = false;
Expand All @@ -40,7 +44,10 @@ try {
if (registered) {
console.log(`${name}@${version} is already registered — skipping publish.`);
} else {
execSync('npm publish --provenance --access public', { env, stdio: 'inherit' });
execFileSync('npm', ['publish', '--provenance', '--access', 'public'], {
env,
stdio: 'inherit',
});
console.log(`Published ${name}@${version}`);
}

Expand All @@ -59,17 +66,20 @@ if (!section) {
throw new Error(`No CHANGELOG.md section found for ${version}`);
}

const tagRemote = execSync(`git ls-remote origin refs/tags/${tag}`, { encoding: 'utf8' });
const tagRemote = execFileSync('git', ['ls-remote', 'origin', `refs/tags/${tag}`], {
encoding: 'utf8',
});
if (tagRemote.includes(`refs/tags/${tag}`)) {
console.log(`Tag ${tag} already exists — skipping.`);
} else {
execSync(`git tag ${tag} && git push origin ${tag}`, { stdio: 'inherit' });
execFileSync('git', ['tag', tag], { stdio: 'inherit' });
execFileSync('git', ['push', 'origin', tag], { stdio: 'inherit' });
console.log(`Tagged ${tag}`);
}

let releaseExists = false;
try {
execSync(`gh release view ${tag}`, { stdio: 'ignore' });
execFileSync('gh', ['release', 'view', tag], { stdio: 'ignore' });
releaseExists = true;
} catch {
releaseExists = false;
Expand All @@ -80,7 +90,7 @@ if (releaseExists) {
} else {
const notesFile = join(mkdtempSync(join(tmpdir(), 'release-notes-')), 'notes.md');
writeFileSync(notesFile, `${section.trimEnd()}\n`);
execSync(`gh release create ${tag} --title ${tag} --notes-file ${notesFile}`, {
execFileSync('gh', ['release', 'create', tag, '--title', tag, '--notes-file', notesFile], {
stdio: 'inherit',
});
console.log(`Created GitHub release ${tag}`);
Expand Down
10 changes: 6 additions & 4 deletions src/choice/choice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { system1Choice } from './system1-choice.js';
* inference (a single forward pass generating a single token), while System 2 is slower
* and more costly: the LLM may reason and has to generate a full structured response.
*
* @param question - The decision to make: options, state, instructions and provider settings.
* @param question - The decision to make: options, state, instructions and the model to query.
* @returns The winning option, the probability distribution over every option (sums to 1),
* and a 0..1 confidence (flat distribution → low, single peak → high).
* @throws If there are fewer than 2 options, or if a criteria value is not a string.
Expand All @@ -22,9 +22,11 @@ import { system1Choice } from './system1-choice.js';
* @example
* ```ts
* const answer = await choice({
* apiBaseUrl: 'http://localhost:8000/v1',
* apiKey: process.env.API_KEY!,
* model: '/models/Qwen3.5-4B-Q4_K_M.gguf',
* model: {
* apiBaseUrl: 'http://localhost:8000/v1',
* apiKey: process.env.API_KEY!,
* model: '/models/Qwen3.5-4B-Q4_K_M.gguf',
* },
* state: "It is raining and I am at home. I'm bored.",
* instructions: 'Give me a good plan to do now',
* criteria: {
Expand Down
45 changes: 31 additions & 14 deletions src/choice/system1-choice.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generateText } from '../utils/llms/generate-text.js';
import { applyExtraBody } from '../utils/llms/apply-extra-body.js';
import { normalizeEntropy } from '../utils/math/normalize-entropy.js';
import type { ChoiceAnswer } from '../types/choice-answer.js';
import type { Question } from '../types/question.js';
Expand All @@ -17,17 +18,19 @@ const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
* candidate letter tokens. No deliberation, no chain-of-thought, no structured
* output — that's System 2's job.
*
* @param question - The decision to make: options, state, instructions and provider settings.
* @param question - The decision to make: options, state, instructions and the model to query.
* @returns The winning option (highest letter probability), the probability
* distribution over every option (sums to 1), and a 0..1 confidence
* based on the distribution's entropy (flat → low, single peak → high).
* @throws If there are fewer than 2 or more than 26 options (one per letter).
* @example
* ```ts
* const answer = await system1Choice({
* apiBaseUrl: 'http://localhost:8000/v1',
* apiKey: process.env.API_KEY!,
* model: '/models/Qwen3.5-4B-Q4_K_M.gguf',
* model: {
* apiBaseUrl: 'http://localhost:8000/v1',
* apiKey: process.env.API_KEY!,
* model: '/models/Qwen3.5-4B-Q4_K_M.gguf',
* },
* state: "It is raining and I am at home. I'm bored.",
* instructions: 'Give me a good plan to do now',
* criteria: { walk: 'Go for a walk', movie: 'Watch a movie' },
Expand Down Expand Up @@ -62,16 +65,24 @@ export async function system1Choice(question: Question): Promise<ChoiceAnswer> {
// nudges the model towards emitting just the chosen option's letter.
// TODO: probably better to move instructions to system prompt for KV cache reuse
const res = await generateText(
{ apiBaseUrl: question.apiBaseUrl, apiKey: question.apiKey },
{
model: question.model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 1, // the answer is a single letter
temperature: 0, // greedy: always the most likely letter
logprobs: true,
top_logprobs: 50, // llama.cpp server max; margin so every declared letter (and its token variants) lands in the report
chat_template_kwargs: { enable_thinking: false },
},
{ apiBaseUrl: question.model.apiBaseUrl, apiKey: question.model.apiKey },
applyExtraBody(
{
model: question.model.model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 1, // the answer is a single letter
temperature: 0, // greedy: always the most likely letter
logprobs: true,
// 20 is the highest portable window: OpenAI and OpenRouter cap top_logprobs
// at 20, and vLLM's server default --max-logprobs is also 20. llama.cpp
// accepts up to 50, so 20 is safe everywhere logprobs exist. The margin is
// enough for realistic option counts; letters falling outside the window
// just contribute 0 to their option's probability.
top_logprobs: 20,
chat_template_kwargs: { enable_thinking: false },
},
question.model.extraBody,
),
{ maxRetries: question.maxRetries, timeoutMs: question.timeoutMs },
);

Expand All @@ -90,6 +101,12 @@ export async function system1Choice(question: Question): Promise<ChoiceAnswer> {
// TODO: check if keeping the highest one is the best option
const letterProbability = new Map<string, number>();
for (const t of tops) {
// A non-conforming server can send malformed entries (missing or null token);
// skip them instead of crashing mid-read — the remaining candidates still
// carry the decision.
if (typeof t?.token !== 'string') {
continue;
}
const letter = t.token.trim().toUpperCase();
const p = Math.exp(t.logprob);
if (p > (letterProbability.get(letter) ?? 0)) {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export type { ChoiceMode } from './types/choice-mode.js';
export type { Model } from './types/model.js';
export type { Question } from './types/question.js';
export { choice } from './choice/choice.js';
6 changes: 6 additions & 0 deletions src/types/chat-completion-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,10 @@ export interface ChatCompletionRequest {
top_logprobs?: number;
/** Extra parameters forwarded to the model's chat template, i.e. `{ enable_thinking: false }` for llama.cpp */
chat_template_kwargs?: Record<string, unknown>;
/**
* Engine- or model-specific fields beyond the standard keys above, i.e. merged in
* from `Model['extraBody']`. They ride on the body verbatim: OpenAI-compatible
* engines ignore unknown fields, so only keys the backend understands take effect.
*/
[key: string]: unknown;
}
Loading
Loading