@earendil-works/pi-ai
Version:
Unified LLM API with automatic model discovery and provider configuration
162 lines • 7 kB
JavaScript
import { formatProviderError, normalizeProviderError } from "../utils/error-body.js";
import { isRecord, parseClassifierUsage, postClassifierRequest, requiredNumber, } from "./classifier-shared.js";
/**
* OpenAI's Decisions API: `POST /v1/decisions` with `{ model, input, questions }`.
* https://developers.openai.com/api/docs/guides/decisions
*
* The state is sent as JSON text. With images, the input becomes one user message with the state
* as `input_text` followed by `input_image` data URLs. Questions map to Decisions types:
* `choice` to `choice`, `score` to `score`, and `bool` to `predicate`. Predicates have no
* criteria field, so the meanings of true and false are appended to the instructions.
*
* Only OpenAI API keys work: Sign in with ChatGPT tokens are rejected on this route.
*/
const LABEL = "OpenAI Decisions";
/** The endpoint accepts at most this many image parts per request. */
const MAX_IMAGES = 128;
function predicateInstructions(question) {
const meanings = [
question.criteria.true ? `True means: ${question.criteria.true}` : "",
question.criteria.false ? `False means: ${question.criteria.false}` : "",
].filter(Boolean);
return meanings.length > 0 ? `${question.instructions}\n\n${meanings.join("\n")}` : question.instructions;
}
function wireQuestion(name, question) {
if (question.type === "choice") {
return {
type: "choice",
name,
instructions: question.instructions,
choices: Object.entries(question.criteria).map(([value, description]) => description ? { value, description } : { value }),
};
}
if (question.type === "score") {
return {
type: "score",
name,
instructions: question.instructions,
levels: question.criteria.map((label) => ({ label })),
};
}
return { type: "predicate", name, instructions: predicateInstructions(question) };
}
function wireInput(context) {
const state = JSON.stringify(context.state);
const images = context.images ?? [];
if (images.length === 0)
return state;
if (images.length > MAX_IMAGES) {
throw new Error(`${LABEL} accepts at most ${MAX_IMAGES} images, got ${images.length}`);
}
return [
{
role: "user",
content: [
{ type: "input_text", text: state },
...images.map((image) => ({
type: "input_image",
image_url: `data:${image.mimeType};base64,${image.data}`,
})),
],
},
];
}
function choiceProbabilities(value, id) {
if (!Array.isArray(value))
throw new Error(`${LABEL} returned invalid probabilities for ${id}`);
return Object.fromEntries(value.map((entry) => {
if (!isRecord(entry) || typeof entry.value !== "string") {
throw new Error(`${LABEL} returned invalid probabilities for ${id}`);
}
return [entry.value, requiredNumber(LABEL, entry.probability, `probability for ${id}.${entry.value}`)];
}));
}
function parseAnswer(id, question, answer) {
if (answer.type === "refusal")
throw new Error(`${LABEL} refused to answer ${id}`);
if (question.type === "choice") {
if (answer.type !== "choice" || typeof answer.choice !== "string") {
throw new Error(`${LABEL} did not return a choice answer for ${id}`);
}
return {
type: "choice",
choice: answer.choice,
probabilities: choiceProbabilities(answer.probabilities, id),
confidence: requiredNumber(LABEL, answer.confidence, `confidence for ${id}`),
};
}
if (question.type === "score") {
if (answer.type !== "score")
throw new Error(`${LABEL} did not return a score answer for ${id}`);
return {
type: "score",
score: requiredNumber(LABEL, answer.score, `score for ${id}`),
confidence: requiredNumber(LABEL, answer.confidence, `confidence for ${id}`),
};
}
if (answer.type !== "predicate")
throw new Error(`${LABEL} did not return a predicate answer for ${id}`);
return { type: "bool", probability: requiredNumber(LABEL, answer.probability, `probability for ${id}`) };
}
function parseAnswers(value, context) {
if (!Array.isArray(value))
throw new Error(`${LABEL} returned an unexpected response`);
const byName = new Map();
for (const answer of value) {
if (isRecord(answer) && typeof answer.name === "string")
byName.set(answer.name, answer);
}
return Object.fromEntries(Object.entries(context.questions).map(([id, question]) => {
const answer = byName.get(id);
if (!answer)
throw new Error(`${LABEL} did not return an answer for ${id}`);
return [id, parseAnswer(id, question, answer)];
}));
}
/**
* Cloudflare in front of api.openai.com answers 504 with an HTML page when a request runs longer
* than about five seconds. Large inputs, currently above roughly 600K tokens, hit this limit, and
* retrying the same input runs into it again, so 504 is not retried.
*/
const NO_RETRY_STATUSES = [504];
function errorMessage(error) {
if (error.status === 504) {
return `${LABEL} error (504): the request timed out at the gateway. Very large inputs (above roughly 600K tokens) currently exceed its time limit.`;
}
return formatProviderError(normalizeProviderError(error), `${LABEL} error`);
}
/** Classification through OpenAI's Decisions API. */
export const classify = async (model, context, options) => {
const output = {
api: model.api,
provider: model.provider,
model: model.id,
answers: {},
stopReason: "stop",
timestamp: Date.now(),
};
try {
if (model.api !== "openai-decisions")
throw new Error(`Unsupported classifier API: ${model.api}`);
const body = await postClassifierRequest(LABEL, new URL("decisions", `${model.baseUrl.replace(/\/+$/u, "")}/`), model, {
model: model.id,
input: wireInput(context),
questions: Object.entries(context.questions).map(([id, question]) => wireQuestion(id, question)),
}, options, NO_RETRY_STATUSES);
if (!isRecord(body))
throw new Error(`${LABEL} returned an unexpected response`);
// Set before parsing answers: a request with malformed or refused answers was still billed.
const usage = parseClassifierUsage(body.usage, model);
if (usage)
output.usage = usage;
output.answers = parseAnswers(body.answers, context);
return output;
}
catch (error) {
output.answers = {};
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = errorMessage(error);
return output;
}
};
//# sourceMappingURL=openai-decisions.js.map