mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 08:01:10 +00:00
* fix(extra-params): preserve resolved cacheRetention when options carries undefined own-property When the proxy transport emits cacheRetention as an own property set to undefined, JS spread semantics clobber the resolved cacheRetention value from per-model params. Re-assert the resolved value after the spread so it takes precedence over an undefined own-property in the caller's options. Fixes #106014 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(agents): strengthen cache retention precedence proof * refactor(agents): keep cache retention merge compact * style(agents): format cache retention matrix --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
563 lines
18 KiB
TypeScript
563 lines
18 KiB
TypeScript
// Coverage for sampling, token, and response-format extra parameter precedence.
|
|
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stream-simple-mock.js";
|
|
import {
|
|
testing as extraParamsTesting,
|
|
applyExtraParamsToAgent,
|
|
resolveExtraParams,
|
|
resolvePreparedExtraParams,
|
|
} from "./extra-params.js";
|
|
|
|
vi.mock("./logger.js", () => ({
|
|
// Sampling tests assert call options only; silence warning/debug output from
|
|
// invalid or provider-specific extra params.
|
|
log: {
|
|
debug: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../llm/stream.js", () => createLlmStreamSimpleMock());
|
|
|
|
beforeEach(() => {
|
|
extraParamsTesting.setProviderRuntimeDepsForTest({
|
|
prepareProviderExtraParams: () => undefined,
|
|
resolveProviderExtraParamsForTransport: () => undefined,
|
|
wrapProviderStreamFn: () => undefined,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
extraParamsTesting.resetProviderRuntimeDepsForTest();
|
|
});
|
|
|
|
describe("createStreamFnWithExtraParams sampling overrides", () => {
|
|
it("forwards temperature, top_p, and maxTokens from override into the underlying streamFn options", () => {
|
|
const underlying = vi.fn(() => ({
|
|
push: vi.fn(),
|
|
result: vi.fn(async () => undefined),
|
|
[Symbol.asyncIterator]: vi.fn(async function* () {
|
|
// empty stream
|
|
}),
|
|
})) as unknown as StreamFn;
|
|
const agent: { streamFn?: StreamFn } = { streamFn: underlying };
|
|
|
|
applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5.4", {
|
|
temperature: 0.4,
|
|
topP: 0.7,
|
|
maxTokens: 512,
|
|
});
|
|
|
|
if (!agent.streamFn) {
|
|
throw new Error("expected extra params to wrap streamFn");
|
|
}
|
|
|
|
void agent.streamFn(
|
|
{ id: "gpt-5.4", api: "openai-completions", provider: "openai" } as never,
|
|
{ messages: [], tools: [] } as never,
|
|
undefined,
|
|
);
|
|
|
|
expect(underlying).toHaveBeenCalledTimes(1);
|
|
const callOptions = (underlying as unknown as { mock: { calls: unknown[][] } }).mock
|
|
.calls[0]?.[2] as { temperature?: number; topP?: number; maxTokens?: number } | undefined;
|
|
expect(callOptions?.temperature).toBe(0.4);
|
|
expect(callOptions?.topP).toBe(0.7);
|
|
expect(callOptions?.maxTokens).toBe(512);
|
|
});
|
|
|
|
it("forwards OpenAI completions token aliases into the underlying streamFn options", () => {
|
|
const underlying = vi.fn(() => ({
|
|
push: vi.fn(),
|
|
result: vi.fn(async () => undefined),
|
|
[Symbol.asyncIterator]: vi.fn(async function* () {
|
|
// empty stream
|
|
}),
|
|
})) as unknown as StreamFn;
|
|
const agent: { streamFn?: StreamFn } = { streamFn: underlying };
|
|
|
|
applyExtraParamsToAgent(agent, undefined, "dashscope", "kimi-k2.6", {
|
|
max_completion_tokens: 64_000,
|
|
});
|
|
|
|
if (!agent.streamFn) {
|
|
throw new Error("expected extra params to wrap streamFn");
|
|
}
|
|
|
|
void agent.streamFn(
|
|
{ id: "kimi-k2.6", api: "openai-completions", provider: "dashscope" } as never,
|
|
{ messages: [], tools: [] } as never,
|
|
undefined,
|
|
);
|
|
|
|
const callOptions = (underlying as unknown as { mock: { calls: unknown[][] } }).mock
|
|
.calls[0]?.[2] as { maxTokens?: number } | undefined;
|
|
expect(callOptions?.maxTokens).toBe(64_000);
|
|
});
|
|
|
|
it("keeps runtime maxTokens ahead of OpenAI completions token alias defaults", () => {
|
|
const underlying = vi.fn(() => ({
|
|
push: vi.fn(),
|
|
result: vi.fn(async () => undefined),
|
|
[Symbol.asyncIterator]: vi.fn(async function* () {
|
|
// empty stream
|
|
}),
|
|
})) as unknown as StreamFn;
|
|
const agent: { streamFn?: StreamFn } = { streamFn: underlying };
|
|
|
|
applyExtraParamsToAgent(agent, undefined, "dashscope", "kimi-k2.6", {
|
|
max_completion_tokens: 64_000,
|
|
});
|
|
|
|
if (!agent.streamFn) {
|
|
throw new Error("expected extra params to wrap streamFn");
|
|
}
|
|
|
|
void agent.streamFn(
|
|
{ id: "kimi-k2.6", api: "openai-completions", provider: "dashscope" } as never,
|
|
{ messages: [], tools: [] } as never,
|
|
{ maxTokens: 32_000 } as never,
|
|
);
|
|
|
|
const callOptions = (underlying as unknown as { mock: { calls: unknown[][] } }).mock
|
|
.calls[0]?.[2] as { maxTokens?: number } | undefined;
|
|
expect(callOptions?.maxTokens).toBe(32_000);
|
|
});
|
|
|
|
it("canonicalizes token aliases with config precedence before preparing stream params", () => {
|
|
// Canonicalization happens before provider preparation so plugins receive a
|
|
// single maxTokens field with agent-level precedence already applied.
|
|
const resolved = resolveExtraParams({
|
|
cfg: {
|
|
agents: {
|
|
defaults: {
|
|
params: {
|
|
maxTokens: 32_000,
|
|
},
|
|
models: {
|
|
"dashscope/kimi-k2.6": {
|
|
params: {
|
|
max_completion_tokens: 64_000,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
list: [
|
|
{
|
|
id: "bot",
|
|
params: {
|
|
max_tokens: 48_000,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
} as never,
|
|
provider: "dashscope",
|
|
modelId: "kimi-k2.6",
|
|
agentId: "bot",
|
|
});
|
|
|
|
expect(resolved?.maxTokens).toBe(48_000);
|
|
expect(resolved).not.toHaveProperty("max_completion_tokens");
|
|
expect(resolved).not.toHaveProperty("max_tokens");
|
|
});
|
|
|
|
it("lets runtime options override the wrapper sampling defaults", () => {
|
|
// Runtime call options are closest to the request and must beat configured
|
|
// defaults injected by the extra-params wrapper.
|
|
const underlying = vi.fn(() => ({
|
|
push: vi.fn(),
|
|
result: vi.fn(async () => undefined),
|
|
[Symbol.asyncIterator]: vi.fn(async function* () {
|
|
// empty stream
|
|
}),
|
|
})) as unknown as StreamFn;
|
|
const agent: { streamFn?: StreamFn } = { streamFn: underlying };
|
|
|
|
applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5.4", { temperature: 0.4, topP: 0.7 });
|
|
|
|
if (!agent.streamFn) {
|
|
throw new Error("expected extra params to wrap streamFn");
|
|
}
|
|
|
|
void agent.streamFn(
|
|
{ id: "gpt-5.4", api: "openai-completions", provider: "openai" } as never,
|
|
{ messages: [], tools: [] } as never,
|
|
{ topP: 0.9 } as never,
|
|
);
|
|
|
|
const callOptions = (underlying as unknown as { mock: { calls: unknown[][] } }).mock
|
|
.calls[0]?.[2] as { temperature?: number; topP?: number } | undefined;
|
|
expect(callOptions?.temperature).toBe(0.4);
|
|
expect(callOptions?.topP).toBe(0.9);
|
|
});
|
|
|
|
it("forwards response_format aliases into the underlying streamFn options", () => {
|
|
const underlying = vi.fn(() => ({
|
|
push: vi.fn(),
|
|
result: vi.fn(async () => undefined),
|
|
[Symbol.asyncIterator]: vi.fn(async function* () {
|
|
// empty stream
|
|
}),
|
|
})) as unknown as StreamFn;
|
|
const agent: { streamFn?: StreamFn } = { streamFn: underlying };
|
|
|
|
applyExtraParamsToAgent(
|
|
agent,
|
|
undefined,
|
|
"openai",
|
|
"gpt-5.4",
|
|
{
|
|
response_format: { type: "json_object" },
|
|
},
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{ preparedExtraParams: { temperature: 0.4 } },
|
|
);
|
|
|
|
if (!agent.streamFn) {
|
|
throw new Error("expected extra params to wrap streamFn");
|
|
}
|
|
|
|
void agent.streamFn(
|
|
{ id: "gpt-5.4", api: "openai-completions", provider: "openai" } as never,
|
|
{ messages: [], tools: [] } as never,
|
|
undefined,
|
|
);
|
|
|
|
const callOptions = (underlying as unknown as { mock: { calls: unknown[][] } }).mock
|
|
.calls[0]?.[2] as
|
|
| { responseFormat?: Record<string, unknown>; temperature?: number }
|
|
| undefined;
|
|
expect(callOptions?.responseFormat).toEqual({ type: "json_object" });
|
|
expect(callOptions?.temperature).toBe(0.4);
|
|
});
|
|
|
|
it("lets request responseFormat override configured response_format", () => {
|
|
const underlying = vi.fn(() => ({
|
|
push: vi.fn(),
|
|
result: vi.fn(async () => undefined),
|
|
[Symbol.asyncIterator]: vi.fn(async function* () {
|
|
// empty stream
|
|
}),
|
|
})) as unknown as StreamFn;
|
|
const agent: { streamFn?: StreamFn } = { streamFn: underlying };
|
|
|
|
applyExtraParamsToAgent(
|
|
agent,
|
|
{
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai/gpt-5.4": {
|
|
params: {
|
|
response_format: { type: "text" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"openai",
|
|
"gpt-5.4",
|
|
{
|
|
responseFormat: { type: "json_object" },
|
|
},
|
|
);
|
|
|
|
if (!agent.streamFn) {
|
|
throw new Error("expected extra params to wrap streamFn");
|
|
}
|
|
|
|
void agent.streamFn(
|
|
{ id: "gpt-5.4", api: "openai-completions", provider: "openai" } as never,
|
|
{ messages: [], tools: [] } as never,
|
|
undefined,
|
|
);
|
|
|
|
const callOptions = (underlying as unknown as { mock: { calls: unknown[][] } }).mock
|
|
.calls[0]?.[2] as { responseFormat?: Record<string, unknown> } | undefined;
|
|
expect(callOptions?.responseFormat).toEqual({ type: "json_object" });
|
|
});
|
|
|
|
it("keeps request-scoped response_format out of prepared extra params cache", () => {
|
|
const prepareProviderExtraParams = vi.fn((params) => ({
|
|
...params.context.extraParams,
|
|
prepared: true,
|
|
}));
|
|
extraParamsTesting.setProviderRuntimeDepsForTest({
|
|
prepareProviderExtraParams,
|
|
resolveProviderExtraParamsForTransport: () => undefined,
|
|
wrapProviderStreamFn: () => undefined,
|
|
});
|
|
|
|
const cfg = { agents: { defaults: {} } } as never;
|
|
const first = resolvePreparedExtraParams({
|
|
cfg,
|
|
provider: "openai",
|
|
modelId: "gpt-5.4",
|
|
extraParamsOverride: {
|
|
temperature: 0.4,
|
|
response_format: {
|
|
type: "json_schema",
|
|
json_schema: { name: "one", schema: { type: "object" } },
|
|
},
|
|
},
|
|
});
|
|
const second = resolvePreparedExtraParams({
|
|
cfg,
|
|
provider: "openai",
|
|
modelId: "gpt-5.4",
|
|
extraParamsOverride: {
|
|
temperature: 0.4,
|
|
response_format: {
|
|
type: "json_schema",
|
|
json_schema: { name: "two", schema: { type: "object" } },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(prepareProviderExtraParams).toHaveBeenCalledTimes(1);
|
|
expect(first).toBe(second);
|
|
expect(first).not.toHaveProperty("response_format");
|
|
expect(first).not.toHaveProperty("responseFormat");
|
|
expect(first.temperature).toBe(0.4);
|
|
});
|
|
|
|
it("keeps request-scoped stop out of prepared extra params cache", () => {
|
|
const prepareProviderExtraParams = vi.fn((params) => ({
|
|
...params.context.extraParams,
|
|
prepared: true,
|
|
}));
|
|
extraParamsTesting.setProviderRuntimeDepsForTest({
|
|
prepareProviderExtraParams,
|
|
resolveProviderExtraParamsForTransport: () => undefined,
|
|
wrapProviderStreamFn: () => undefined,
|
|
});
|
|
|
|
const cfg = { agents: { defaults: {} } } as never;
|
|
const first = resolvePreparedExtraParams({
|
|
cfg,
|
|
provider: "openai",
|
|
modelId: "gpt-5.4",
|
|
extraParamsOverride: { temperature: 0.4, stop: ["User:"] },
|
|
});
|
|
const second = resolvePreparedExtraParams({
|
|
cfg,
|
|
provider: "openai",
|
|
modelId: "gpt-5.4",
|
|
extraParamsOverride: { temperature: 0.4, stop: ["Assistant:", "\n\n"] },
|
|
});
|
|
|
|
expect(prepareProviderExtraParams).toHaveBeenCalledTimes(1);
|
|
expect(first).toBe(second);
|
|
expect(first).not.toHaveProperty("stop");
|
|
expect(first.temperature).toBe(0.4);
|
|
});
|
|
|
|
it("forwards frequency_penalty, presence_penalty, and seed from override into stream options", () => {
|
|
const underlying = vi.fn(() => ({
|
|
push: vi.fn(),
|
|
result: vi.fn(async () => undefined),
|
|
[Symbol.asyncIterator]: vi.fn(async function* () {}),
|
|
})) as unknown as StreamFn;
|
|
const agent: { streamFn?: StreamFn } = { streamFn: underlying };
|
|
|
|
applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5.4", {
|
|
frequencyPenalty: 0.8,
|
|
presencePenalty: 0.3,
|
|
seed: 12345,
|
|
});
|
|
|
|
if (!agent.streamFn) {
|
|
throw new Error("expected extra params to wrap streamFn");
|
|
}
|
|
|
|
void agent.streamFn(
|
|
{ id: "gpt-5.4", api: "openai-completions", provider: "openai" } as never,
|
|
{ messages: [], tools: [] } as never,
|
|
undefined,
|
|
);
|
|
|
|
expect(underlying).toHaveBeenCalledTimes(1);
|
|
const callOptions = (underlying as unknown as { mock: { calls: unknown[][] } }).mock
|
|
.calls[0]?.[2] as
|
|
| {
|
|
frequencyPenalty?: number;
|
|
presencePenalty?: number;
|
|
seed?: number;
|
|
}
|
|
| undefined;
|
|
expect(callOptions?.frequencyPenalty).toBe(0.8);
|
|
expect(callOptions?.presencePenalty).toBe(0.3);
|
|
expect(callOptions?.seed).toBe(12345);
|
|
});
|
|
|
|
it("forwards stop sequences from override into stream options", () => {
|
|
const underlying = vi.fn(() => ({
|
|
push: vi.fn(),
|
|
result: vi.fn(async () => undefined),
|
|
[Symbol.asyncIterator]: vi.fn(async function* () {}),
|
|
})) as unknown as StreamFn;
|
|
const agent: { streamFn?: StreamFn } = { streamFn: underlying };
|
|
|
|
applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5.4", {
|
|
stop: ["User:", "Assistant:"],
|
|
});
|
|
|
|
if (!agent.streamFn) {
|
|
throw new Error("expected extra params to wrap streamFn");
|
|
}
|
|
|
|
void agent.streamFn(
|
|
{ id: "gpt-5.4", api: "openai-completions", provider: "openai" } as never,
|
|
{ messages: [], tools: [] } as never,
|
|
undefined,
|
|
);
|
|
|
|
expect(underlying).toHaveBeenCalledTimes(1);
|
|
const callOptions = (underlying as unknown as { mock: { calls: unknown[][] } }).mock
|
|
.calls[0]?.[2] as { stop?: string[] } | undefined;
|
|
expect(callOptions?.stop).toEqual(["User:", "Assistant:"]);
|
|
});
|
|
|
|
it("prefers camelCase runtime overrides over snake_case config for penalty params", () => {
|
|
const underlying = vi.fn(() => ({
|
|
push: vi.fn(),
|
|
result: vi.fn(async () => undefined),
|
|
[Symbol.asyncIterator]: vi.fn(async function* () {}),
|
|
})) as unknown as StreamFn;
|
|
const agent: { streamFn?: StreamFn } = { streamFn: underlying };
|
|
|
|
applyExtraParamsToAgent(
|
|
agent,
|
|
{
|
|
agents: {
|
|
defaults: {
|
|
models: {
|
|
"openai/gpt-5.4": {
|
|
params: {
|
|
frequency_penalty: 0.1,
|
|
presence_penalty: 0.1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
"openai",
|
|
"gpt-5.4",
|
|
{
|
|
frequencyPenalty: 0.9,
|
|
presencePenalty: 0.7,
|
|
},
|
|
);
|
|
|
|
if (!agent.streamFn) {
|
|
throw new Error("expected extra params to wrap streamFn");
|
|
}
|
|
|
|
void agent.streamFn(
|
|
{ id: "gpt-5.4", api: "openai-completions", provider: "openai" } as never,
|
|
{ messages: [], tools: [] } as never,
|
|
undefined,
|
|
);
|
|
|
|
const callOptions = (underlying as unknown as { mock: { calls: unknown[][] } }).mock
|
|
.calls[0]?.[2] as
|
|
| {
|
|
frequencyPenalty?: number;
|
|
presencePenalty?: number;
|
|
}
|
|
| undefined;
|
|
expect(callOptions?.frequencyPenalty).toBe(0.9);
|
|
expect(callOptions?.presencePenalty).toBe(0.7);
|
|
});
|
|
|
|
it("keeps dynamic fast mode overrides out of prepared extra params cache", () => {
|
|
const prepareProviderExtraParams = vi.fn((params) => ({
|
|
...params.context.extraParams,
|
|
prepared: true,
|
|
}));
|
|
extraParamsTesting.setProviderRuntimeDepsForTest({
|
|
prepareProviderExtraParams,
|
|
resolveProviderExtraParamsForTransport: () => undefined,
|
|
wrapProviderStreamFn: () => undefined,
|
|
});
|
|
|
|
const cfg = { agents: { defaults: {} } } as never;
|
|
const firstFastMode = () => true;
|
|
const secondFastMode = () => false;
|
|
const first = resolvePreparedExtraParams({
|
|
cfg,
|
|
provider: "openai",
|
|
modelId: "gpt-5.4",
|
|
extraParamsOverride: { fastMode: firstFastMode },
|
|
});
|
|
const second = resolvePreparedExtraParams({
|
|
cfg,
|
|
provider: "openai",
|
|
modelId: "gpt-5.4",
|
|
extraParamsOverride: { fastMode: secondFastMode },
|
|
});
|
|
|
|
expect(prepareProviderExtraParams).toHaveBeenCalledTimes(2);
|
|
expect(first).not.toBe(second);
|
|
expect(first.fastMode).toBe(firstFastMode);
|
|
expect(second.fastMode).toBe(secondFastMode);
|
|
});
|
|
|
|
it.each([
|
|
{ requestRetention: undefined, expected: "long", name: "own undefined" },
|
|
{ requestRetention: "none" as const, expected: "none", name: "explicit none" },
|
|
{ requestRetention: "short" as const, expected: "short", name: "explicit short" },
|
|
{ requestRetention: "long" as const, expected: "long", name: "explicit long" },
|
|
])(
|
|
"merges configured cache retention with $name request options",
|
|
({ requestRetention, expected }) => {
|
|
const underlying = vi.fn(() => ({
|
|
push: vi.fn(),
|
|
result: vi.fn(async () => undefined),
|
|
[Symbol.asyncIterator]: vi.fn(async function* () {
|
|
// empty stream
|
|
}),
|
|
})) as unknown as StreamFn;
|
|
const agent: { streamFn?: StreamFn } = { streamFn: underlying };
|
|
|
|
applyExtraParamsToAgent(
|
|
agent,
|
|
undefined,
|
|
"anthropic",
|
|
"claude-sonnet-5",
|
|
{ cacheRetention: "long" },
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{ supportsPromptCacheKey: true } as never,
|
|
);
|
|
|
|
if (!agent.streamFn) {
|
|
throw new Error("expected extra params to wrap streamFn");
|
|
}
|
|
|
|
const requestOptions = { cacheRetention: requestRetention };
|
|
expect(requestOptions).toHaveProperty("cacheRetention");
|
|
void agent.streamFn(
|
|
{ id: "claude-sonnet-5", api: "anthropic-messages", provider: "anthropic" } as never,
|
|
{ messages: [], tools: [] } as never,
|
|
requestOptions,
|
|
);
|
|
|
|
expect(underlying).toHaveBeenCalledTimes(1);
|
|
const callOptions = (underlying as unknown as { mock: { calls: unknown[][] } }).mock
|
|
.calls[0]?.[2] as { cacheRetention?: string } | undefined;
|
|
expect(callOptions?.cacheRetention).toBe(expected);
|
|
},
|
|
);
|
|
});
|