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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Added `chooseMany()` for independent questions with common context and optional images.

## 0.7.0 - 2026-09-28

- Reduced llama.cpp scoring requests with batched tokenization and overlapping vision checks.
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,32 @@ Supported media types are PNG, JPEG, and WebP. llama.cpp image inputs currently

The MCP server accepts image file paths through `imagePaths` in `labels` mode. Paths are resolved from the server's working directory by default; set `CHOOSEKIT_IMAGE_ROOT` to use another root. Every image must remain within that root and be a PNG, JPEG, or WebP file. With OpenRouter, the image contents are sent to the remote service.

## Multiple questions

Use `chooseMany()` to ask several independent questions about the same context:

```ts
import { chooseMany } from "choosekit";

const decisions = await chooseMany(choose, {
context: "A payout failed three days in a row. Payroll is due today.",
questions: {
route: {
question: "Which team should handle this?",
choices: { billing: "Payments and payouts", technical: "Bugs and API errors" },
},
urgency: {
question: "How urgent is this?",
choices: { high: "Needs attention today", low: "Can wait" },
},
},
});

console.log(decisions.route.choice, decisions.urgency.choice);
```

Each question calls `choose()` concurrently and returns its own decision and, when available, token and request counts. Supplied images are sent with each call. If one call fails, the group rejects while calls already in progress continue.

## Scoring modes

| Mode | Candidate representation | Use when |
Expand Down
38 changes: 38 additions & 0 deletions src/choose-many.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {
ChoiceKey, Chooser, Choices, Decision, ImageInput,
} from "./types.js";
import { isRecord } from "./validation.js";

type Questions = Readonly<Record<string, { readonly question: string; readonly choices: Choices }>>;

type Request<Q extends Questions> = {
readonly context: string;
readonly questions: Q;
readonly images?: readonly ImageInput[];
readonly signal?: AbortSignal;
};

type Results<Q extends Questions> = Readonly<{
[K in keyof Q]: Decision<ChoiceKey<Q[K]["choices"]>>;
}>;

export async function chooseMany<const Q extends Questions>(
choose: Chooser, request: Request<Q>,
): Promise<Results<Q>> {
if (typeof choose !== "function") throw new TypeError("choose must be a function.");
const { context, questions, images, signal } = request;
signal?.throwIfAborted();
if (!isRecord(questions) || Object.getOwnPropertySymbols(questions).length !== 0) {
throw new TypeError("questions must be an object of named questions.");
}
const entries = Object.entries(questions);
if (entries.length === 0) throw new TypeError("Provide at least one question.");

const decisions = await Promise.all(entries.map(async ([name, { question, choices }]) =>
[name, await choose({
context, question, choices,
...(images === undefined ? {} : { images }),
...(signal === undefined ? {} : { signal }),
})] as const));
return Object.freeze(Object.fromEntries(decisions)) as Results<Q>;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createFormattedChooser } from "./internal-chooser.js";
export { chooseMany } from "./choose-many.js";
import type { Chooser, ChooserOptions, Scorer } from "./types.js";

export type {
Expand Down
109 changes: 109 additions & 0 deletions tests/choose-many.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { chooseMany, createChooser } from "../dist/esm/index.js";

test("returns separate decisions for named questions with shared inputs", async () => {
const controller = new AbortController();
const images = [{ mediaType: "image/png", base64: "aW1hZ2U=" }];
const seen = [];
const choose = createChooser(({ prompt, candidates, images: receivedImages, signal }) => {
seen.push({ prompt, receivedImages, signal });
return {
logprobs: candidates.map((_, index) => -index - 1),
usage: {
promptTokens: prompt.includes("Which team?") ? 10 : 20,
cachedTokens: 2, completionTokens: 1, requests: 1,
},
};
});
const decisions = await chooseMany(choose, {
context: "A payout failed.", images, signal: controller.signal,
questions: {
route: {
question: "Which team?",
choices: { billing: "Payments and refunds", technical: "Bugs and API errors" },
},
urgency: {
question: "How urgent?",
choices: { high: "Needs attention today", low: "Can wait" },
},
},
});

assert.deepEqual(Object.keys(decisions), ["route", "urgency"]);
assert.equal(decisions.route.choice, "billing");
assert.equal(decisions.urgency.choice, "high");
assert.deepEqual(Object.keys(decisions.route.distribution), ["billing", "technical"]);
assert.deepEqual(Object.keys(decisions.urgency.distribution), ["high", "low"]);
assert.deepEqual(decisions.route.usage, {
promptTokens: 10, cachedTokens: 2, completionTokens: 1, requests: 1,
});
assert.deepEqual(decisions.urgency.usage, {
promptTokens: 20, cachedTokens: 2, completionTokens: 1, requests: 1,
});
assert.ok(Object.isFrozen(decisions));
assert.equal(seen.length, 2);
for (const call of seen) {
assert.ok(call.prompt.startsWith("A payout failed."));
assert.deepEqual(call.receivedImages, images);
assert.equal(call.signal, controller.signal);
}
});

test("starts questions concurrently and rejects when one fails", async () => {
const failure = new Error("Backend failed");
const started = [];
let finishOther;
let reportCompletion;
const completed = new Promise((resolve) => { reportCompletion = resolve; });
const choose = async ({ question }) => {
started.push(question);
if (question === "First?") throw failure;
await new Promise((resolve) => { finishOther = resolve; });
reportCompletion();
return { choice: "yes" };
};
const pending = chooseMany(choose, {
context: "Shared.",
questions: {
first: { question: "First?", choices: { yes: "Yes", no: "No" } },
second: { question: "Second?", choices: { yes: "Yes", no: "No" } },
},
});
await assert.rejects(pending, (error) => error === failure);
assert.deepEqual(started, ["First?", "Second?"]);
finishOther();
await completed;
});

test("rejects an empty question set or an aborted signal before starting work", async () => {
let called = false;
const choose = async () => { called = true; throw new Error("Unexpected call"); };
await assert.rejects(chooseMany(choose, { context: "", questions: {} }), TypeError);
const signal = AbortSignal.abort();
await assert.rejects(chooseMany(choose, {
context: "", signal,
questions: { one: { question: "One?", choices: { yes: "Yes", no: "No" } } },
}), (error) => error === signal.reason);
assert.equal(called, false);
});

test("passes a shared signal to every in-flight question", async () => {
const controller = new AbortController();
const reason = new Error("Cancelled");
const received = [];
const choose = ({ signal }) => new Promise((_, reject) => {
received.push(signal);
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
const pending = chooseMany(choose, {
context: "", signal: controller.signal,
questions: {
first: { question: "First?", choices: { yes: "Yes", no: "No" } },
second: { question: "Second?", choices: { yes: "Yes", no: "No" } },
},
});
assert.deepEqual(received, [controller.signal, controller.signal]);
controller.abort(reason);
await assert.rejects(pending, (error) => error === reason);
});
8 changes: 7 additions & 1 deletion tests/types/api.cts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createChooser, type Scorer } from "choosekit";
import { chooseMany, createChooser, type Scorer } from "choosekit";
import { fromLlamaCpp } from "choosekit/llama-cpp";
import { fromOllama } from "choosekit/ollama";
import { fromOpenRouter } from "choosekit/openrouter";
Expand All @@ -8,3 +8,9 @@ void choose({ context: "", question: "Next?", choices: { test: "Test", done: "Do
void fromLlamaCpp({ baseURL: "http://127.0.0.1:8080" });
void fromOllama({ model: "test-model" });
void fromOpenRouter({ apiKey: "test-key", model: "test/model" });
void chooseMany(choose, {
context: "", questions: { route: { question: "Next?", choices: { test: "Test", done: "Done" } } },
}).then((decisions) => {
const key: "test" | "done" = decisions.route.choice;
void key;
});
24 changes: 23 additions & 1 deletion tests/types/api.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createChooser, type Scorer, type Decision, type ImageInput } from "choosekit";
import { chooseMany, createChooser, type Scorer, type Decision, type ImageInput } from "choosekit";
import { fromLlamaCpp } from "choosekit/llama-cpp";
import { fromOllama } from "choosekit/ollama";
import { fromOpenRouter } from "choosekit/openrouter";
Expand Down Expand Up @@ -54,3 +54,25 @@ const cached: number | null | undefined = decision.usage?.cachedTokens;
void cached;
// @ts-expect-error No calibrated correctness confidence is claimed.
decision.confidence;

const decisions = await chooseMany(choose, {
context: "A payout failed.",
images: [image],
questions: {
route: { question: "Which team?", choices: { billing: "Payments", technical: "API bugs" } },
urgency: { question: "How urgent?", choices: { high: "Today", low: "Can wait" } },
},
});
const route: "billing" | "technical" = decisions.route.choice;
const urgency: "high" | "low" = decisions.urgency.choice;
const routeProbability: number = decisions.route.distribution.billing;
void route; void urgency; void routeProbability;
// @ts-expect-error Route choices do not include urgency choices.
decisions.route.distribution.high;
// @ts-expect-error Urgency choices do not include route choices.
const wrongUrgency: "billing" = decisions.urgency.choice;
void wrongUrgency;
// @ts-expect-error Each named question requires a question.
void chooseMany(choose, { context: "", questions: { route: { choices: { a: "A", b: "B" } } } });
// @ts-expect-error Choice descriptions must be strings.
void chooseMany(choose, { context: "", questions: { route: { question: "?", choices: { a: 1, b: "B" } } } });
Loading