Add capability-probed structured outputs and a prototype/instance creation graph.
Probe API profiles for json_schema/tools, flatten worker artifacts onto a strict schema, and let users spawn or revise graph nodes instead of a one-shot DAG. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyOpenAiGeneration,
|
||||
applyRejectedGenerationField,
|
||||
classifyChatModel,
|
||||
fetchWithGenerationCompat,
|
||||
parseRejectedGenerationField,
|
||||
sanitizeReasoningEffort,
|
||||
sanitizeVerbosity,
|
||||
} from "../src/llm/generation-compat.js";
|
||||
import { buildRequestBody } from "../src/llm/client.js";
|
||||
import type { LlmConfig } from "../src/config/env.js";
|
||||
|
||||
const stGeneration = {
|
||||
temperature: 1,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
minP: 0.05,
|
||||
frequencyPenalty: 0,
|
||||
presencePenalty: 0,
|
||||
repetitionPenalty: 1.05,
|
||||
maxOutputTokens: 60000,
|
||||
seed: -1,
|
||||
reasoningEffort: "auto",
|
||||
verbosity: "auto",
|
||||
};
|
||||
|
||||
describe("sanitizeReasoningEffort", () => {
|
||||
it("omits SillyTavern auto and empty values", () => {
|
||||
expect(sanitizeReasoningEffort("auto")).toBeUndefined();
|
||||
expect(sanitizeReasoningEffort("AUTO")).toBeUndefined();
|
||||
expect(sanitizeReasoningEffort("")).toBeUndefined();
|
||||
expect(sanitizeReasoningEffort(" ")).toBeUndefined();
|
||||
expect(sanitizeReasoningEffort(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps values the provider enum accepts", () => {
|
||||
expect(sanitizeReasoningEffort("none")).toBe("none");
|
||||
expect(sanitizeReasoningEffort("minimal")).toBe("minimal");
|
||||
expect(sanitizeReasoningEffort("low")).toBe("low");
|
||||
expect(sanitizeReasoningEffort("medium")).toBe("medium");
|
||||
expect(sanitizeReasoningEffort("high")).toBe("high");
|
||||
expect(sanitizeReasoningEffort("xhigh")).toBe("xhigh");
|
||||
expect(sanitizeReasoningEffort("max")).toBe("max");
|
||||
});
|
||||
|
||||
it("maps SillyTavern min to minimal and ignores unknown values", () => {
|
||||
expect(sanitizeReasoningEffort("min")).toBe("minimal");
|
||||
expect(sanitizeReasoningEffort("MIN")).toBe("minimal");
|
||||
expect(sanitizeReasoningEffort("unknown")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeVerbosity", () => {
|
||||
it("omits auto and unknown values", () => {
|
||||
expect(sanitizeVerbosity("auto")).toBeUndefined();
|
||||
expect(sanitizeVerbosity("loud")).toBeUndefined();
|
||||
expect(sanitizeVerbosity("medium")).toBe("medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyChatModel", () => {
|
||||
it("treats o-series, gpt-5, kimi-k2.5 and deepseek-reasoner as no-sampling", () => {
|
||||
expect(classifyChatModel("o3-mini").omitSampling).toBe(true);
|
||||
expect(classifyChatModel("openai/o4-mini").omitSampling).toBe(true);
|
||||
expect(classifyChatModel("gpt-5").omitSampling).toBe(true);
|
||||
expect(classifyChatModel("deepseek-reasoner").omitSampling).toBe(true);
|
||||
expect(classifyChatModel("kimi-k2.5").omitSampling).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps sampling for ordinary chat models", () => {
|
||||
expect(classifyChatModel("gpt-4o").omitSampling).toBe(false);
|
||||
expect(classifyChatModel("deepseek-v4-pro").omitSampling).toBe(false);
|
||||
expect(classifyChatModel("gpt-5-chat-latest").omitSampling).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOpenAiGeneration", () => {
|
||||
it("does not send ST extras or auto sentinels on a chat model", () => {
|
||||
const body: Record<string, unknown> = { model: "deepseek-v4-pro" };
|
||||
applyOpenAiGeneration(body, stGeneration, "deepseek-v4-pro");
|
||||
expect(body.temperature).toBe(1);
|
||||
expect(body.top_p).toBe(0.95);
|
||||
expect(body.max_tokens).toBe(60000);
|
||||
expect(body.top_k).toBeUndefined();
|
||||
expect(body.min_p).toBeUndefined();
|
||||
expect(body.repetition_penalty).toBeUndefined();
|
||||
expect(body.reasoning_effort).toBeUndefined();
|
||||
expect(body.verbosity).toBeUndefined();
|
||||
expect(body.seed).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops sampling params for reasoning models and uses max_completion_tokens", () => {
|
||||
const body: Record<string, unknown> = { model: "o3-mini" };
|
||||
applyOpenAiGeneration(body, stGeneration, "o3-mini");
|
||||
expect(body.temperature).toBeUndefined();
|
||||
expect(body.top_p).toBeUndefined();
|
||||
expect(body.frequency_penalty).toBeUndefined();
|
||||
expect(body.presence_penalty).toBeUndefined();
|
||||
expect(body.max_tokens).toBeUndefined();
|
||||
expect(body.max_completion_tokens).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRejectedGenerationField", () => {
|
||||
it("reads OpenAI unsupported parameter errors", () => {
|
||||
expect(
|
||||
parseRejectedGenerationField(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Unsupported parameter: 'temperature' is not supported with this model.",
|
||||
param: "temperature",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe("temperature");
|
||||
});
|
||||
|
||||
it("reads strict JSON deserializer unknown variant errors", () => {
|
||||
expect(
|
||||
parseRejectedGenerationField(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"Failed to deserialize the JSON body into the target type: reasoning_effort: unknown variant `auto`, expected one of `none`, `minimal`",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBe("reasoning_effort");
|
||||
});
|
||||
|
||||
it("drops top_p when temperature and top_p are mutually exclusive", () => {
|
||||
expect(
|
||||
parseRejectedGenerationField(
|
||||
"temperature and top_p cannot both be specified for this model",
|
||||
),
|
||||
).toBe("top_p");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRejectedGenerationField", () => {
|
||||
it("renames max_completion_tokens to max_tokens once", () => {
|
||||
const body: Record<string, unknown> = { max_completion_tokens: 100 };
|
||||
const tried = new Set<string>();
|
||||
expect(applyRejectedGenerationField(body, "max_completion_tokens", tried)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(body.max_tokens).toBe(100);
|
||||
expect(body.max_completion_tokens).toBeUndefined();
|
||||
expect(applyRejectedGenerationField(body, "max_tokens", tried)).toBe(true);
|
||||
expect(body.max_completion_tokens).toBeUndefined();
|
||||
expect(body.max_tokens).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchWithGenerationCompat", () => {
|
||||
it("retries a 400 after stripping the rejected field", async () => {
|
||||
const sent: Array<Record<string, unknown>> = [];
|
||||
const response = await fetchWithGenerationCompat(async (body) => {
|
||||
sent.push({ ...body });
|
||||
if ("temperature" in body) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Unsupported parameter: 'temperature'",
|
||||
param: "temperature",
|
||||
},
|
||||
}),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}, { model: "o3", temperature: 1, messages: [] });
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(sent).toHaveLength(2);
|
||||
expect(sent[0].temperature).toBe(1);
|
||||
expect(sent[1].temperature).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRequestBody", () => {
|
||||
const messages = [{ role: "user" as const, content: "hi" }];
|
||||
|
||||
it("does not send reasoning_effort when the preset says auto", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
apiKey: "k",
|
||||
model: "deepseek-v4-pro",
|
||||
};
|
||||
const body = buildRequestBody(config, messages, {
|
||||
generation: { reasoningEffort: "auto", temperature: 0.5 },
|
||||
});
|
||||
expect(body.reasoning_effort).toBeUndefined();
|
||||
expect(body.temperature).toBe(0.5);
|
||||
});
|
||||
|
||||
it("sends a supported effort value", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
apiKey: "k",
|
||||
model: "gpt-5",
|
||||
};
|
||||
const body = buildRequestBody(config, messages, {
|
||||
generation: { reasoningEffort: "High" },
|
||||
});
|
||||
expect(body.reasoning_effort).toBe("high");
|
||||
expect(body.temperature).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses profile default reasoningEffort when generation omits it", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
apiKey: "k",
|
||||
model: "glm-5.3",
|
||||
reasoningEffort: "high",
|
||||
};
|
||||
const body = buildRequestBody(config, messages, {
|
||||
generation: { temperature: 1 },
|
||||
});
|
||||
expect(body.reasoning_effort).toBe("high");
|
||||
});
|
||||
|
||||
it("lets generation reasoningEffort override the profile default", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
apiKey: "k",
|
||||
model: "glm-5.3",
|
||||
reasoningEffort: "max",
|
||||
};
|
||||
const body = buildRequestBody(config, messages, {
|
||||
generation: { reasoningEffort: "low" },
|
||||
});
|
||||
expect(body.reasoning_effort).toBe("low");
|
||||
});
|
||||
|
||||
it("does not forward top_k from an imported ST preset", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
apiKey: "k",
|
||||
model: "deepseek-v4-pro",
|
||||
};
|
||||
const body = buildRequestBody(config, messages, {
|
||||
generation: stGeneration,
|
||||
});
|
||||
expect(body.top_k).toBeUndefined();
|
||||
expect(body.min_p).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user