feat(ai): add per-thinking-level sampling parameters (#9776)

This commit is contained in:
Duncan Ogilvie 2026-10-04 01:29:01 +02:00 • committed by GitHub
parent 70eceaade6
commit 76dfb88f63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 250 additions and 31 deletions

View file

@ -182,6 +182,7 @@
- Added a Built-in section in `pi config` to disable the built-in `mcp`, `llama.cpp`, `codemode`, and `tool-search` extensions globally or per project, stored as `-builtin:<name>` in the `extensions` setting. SDK inline extensions opt in with `builtin: true`.
- Added `+name` and `-name` entries to the `defaultTools` setting to add or remove tools without repeating the defaults, for example `"defaultTools": ["+codemode"]`. Project entries of this form apply on top of the user setting. Documented how to enable `codemode` without MCP and how to use classifier models such as Jev from codemode scripts.
- Added the token usage and cost of codemode `models.classify()` calls to the codemode tool result, so they count toward the session cost; the codemode result shows each call's cost.
- Added `samplingParamsByThinkingLevel` to `models.json` for per-thinking-level sampling parameter overrides.
### Changed

View file

@ -98,6 +98,32 @@ Use `promptCache` to declare the provider's best-effort cache lifetime in second
Choose the conservative end of any published range. A model without a lifetime for the active tier is not eligible for cache warming. A `modelOverrides` entry can set `inputLimits` or `promptCache` for a built-in or extension model, including a model accessed through a validated proxy. See [`cacheWarming`](settings.md#model-and-thinking).
### Configure sampling by thinking level
OpenAI-compatible APIs support free-form `samplingParams` model defaults and `samplingParamsByThinkingLevel` overrides. The latter uses Pi thinking-level keys (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`), not provider values from `thinkingLevelMap`:
```json
{
"id": "qwen-thinking-model",
"reasoning": true,
"samplingParams": {
"temperature": 1.0,
"top_p": 0.95
},
"samplingParamsByThinkingLevel": {
"off": {
"temperature": 0.7,
"top_p": 0.8
},
"high": {
"top_k": 20
}
}
}
```
Pi first clamps unsupported thinking levels, then merges model `samplingParams`, the effective level's override, and request-level `samplingParams` in that order. Later values win per key. Missing levels inherit the model defaults. `modelOverrides` merges per-level entries per key with the base model. These fields apply only to `openai-completions`, `openai-responses`, and `azure-openai-responses`; other APIs ignore them.
Compatibility settings should describe verified differences in the endpoint's request or response behavior. Do not enable them based only on an endpoint advertising OpenAI or Anthropic compatibility.
## Use classifier models

View file

@ -63,6 +63,17 @@ const ThinkingLevelMapSchema = Type.Object({
max: Type.Optional(ThinkingLevelMapValueSchema),
});
const SamplingParamsSchema = Type.Record(Type.String(), Type.Unknown());
const SamplingParamsByThinkingLevelSchema = Type.Object({
off: Type.Optional(SamplingParamsSchema),
minimal: Type.Optional(SamplingParamsSchema),
low: Type.Optional(SamplingParamsSchema),
medium: Type.Optional(SamplingParamsSchema),
high: Type.Optional(SamplingParamsSchema),
xhigh: Type.Optional(SamplingParamsSchema),
max: Type.Optional(SamplingParamsSchema),
});
const ChatTemplateKwargScalarSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]);
const ChatTemplateKwargVariableSchema = Type.Object({
$var: Type.Union([Type.Literal("thinking.enabled"), Type.Literal("thinking.effort")]),
@ -198,7 +209,8 @@ const ModelDefinitionSchema = Type.Object({
promptCache: Type.Optional(ModelPromptCacheSchema),
contextWindow: Type.Optional(Type.Number()),
maxTokens: Type.Optional(Type.Number()),
samplingParams: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
samplingParams: Type.Optional(SamplingParamsSchema),
samplingParamsByThinkingLevel: Type.Optional(SamplingParamsByThinkingLevelSchema),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
compat: Type.Optional(ProviderCompatSchema),
});
@ -221,7 +233,8 @@ const ModelOverrideSchema = Type.Object({
promptCache: Type.Optional(ModelPromptCacheSchema),
contextWindow: Type.Optional(Type.Number()),
maxTokens: Type.Optional(Type.Number()),
samplingParams: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
samplingParams: Type.Optional(SamplingParamsSchema),
samplingParamsByThinkingLevel: Type.Optional(SamplingParamsByThinkingLevelSchema),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
compat: Type.Optional(ProviderCompatSchema),
});

View file

@ -68,7 +68,8 @@ export interface ProviderChatModelConfig extends ProviderModelConfigBase {
promptCache?: Model<Api>["promptCache"];
contextWindow: number;
maxTokens: number;
samplingParams?: Record<string, unknown>;
samplingParams?: Model<Api>["samplingParams"];
samplingParamsByThinkingLevel?: Model<Api>["samplingParamsByThinkingLevel"];
compat?: Model<Api>["compat"];
}
@ -160,6 +161,19 @@ function mergeInputLimits(
};
}
function mergeSamplingParamsByThinkingLevel(
base: Model<Api>["samplingParamsByThinkingLevel"],
override: Model<Api>["samplingParamsByThinkingLevel"],
): Model<Api>["samplingParamsByThinkingLevel"] {
if (!override) return base;
const merged = { ...base };
for (const level of ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const) {
const params = override[level];
if (params) merged[level] = { ...base?.[level], ...params };
}
return merged;
}
function applyModelOverride(model: Model<Api>, override: ModelsJsonModelOverride): Model<Api> {
return {
...model,
@ -185,6 +199,10 @@ function applyModelOverride(model: Model<Api>, override: ModelsJsonModelOverride
samplingParams: override.samplingParams
? { ...model.samplingParams, ...override.samplingParams }
: model.samplingParams,
samplingParamsByThinkingLevel: mergeSamplingParamsByThinkingLevel(
model.samplingParamsByThinkingLevel,
override.samplingParamsByThinkingLevel,
),
compat: mergeCompat(model.compat, override.compat),
};
}
@ -224,6 +242,7 @@ function modelFromJson(
contextWindow: definition.contextWindow ?? 128000,
maxTokens: definition.maxTokens ?? 16384,
samplingParams: definition.samplingParams,
samplingParamsByThinkingLevel: definition.samplingParamsByThinkingLevel,
headers: undefined,
compat: mergeCompat(providerConfig.compat, definition.compat),
};

View file

@ -754,7 +754,7 @@ describe("ModelRegistry", () => {
expect(compat?.allowedFallbackModels).toEqual([]);
});
test("custom model and model override carry sampling params", async () => {
test("custom models and model overrides carry sampling params", async () => {
writeRawModelsJson({
openrouter: {
baseUrl: "https://my-proxy.example.com/v1",
@ -763,11 +763,22 @@ describe("ModelRegistry", () => {
{
id: "custom/sampling-model",
samplingParams: { temperature: 1, top_p: 0.95, top_k: 0 },
samplingParamsByThinkingLevel: {
low: { temperature: 0.6, top_p: 0.95 },
high: { temperature: 0.8 },
},
},
],
modelOverrides: {
"custom/sampling-model": {
samplingParamsByThinkingLevel: {
low: { temperature: 0.5, top_k: 20 },
max: { temperature: 1 },
},
},
"anthropic/claude-sonnet-4": {
samplingParams: { top_p: 0.9 },
samplingParamsByThinkingLevel: { high: { temperature: 0.8 } },
},
},
},
@ -778,13 +789,20 @@ describe("ModelRegistry", () => {
const custom = models.find((m) => m.id === "custom/sampling-model");
expect(custom?.samplingParams).toEqual({ temperature: 1, top_p: 0.95, top_k: 0 });
expect(custom?.samplingParamsByThinkingLevel).toEqual({
low: { temperature: 0.5, top_p: 0.95, top_k: 20 },
high: { temperature: 0.8 },
max: { temperature: 1 },
});
const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4");
expect(sonnet?.samplingParams).toEqual({ top_p: 0.9 });
expect(sonnet?.samplingParamsByThinkingLevel).toEqual({ high: { temperature: 0.8 } });
// Models without sampling config keep it unset.
const opus = models.find((m) => m.id === "anthropic/claude-opus-4.1");
expect(opus?.samplingParams).toBeUndefined();
expect(opus?.samplingParamsByThinkingLevel).toBeUndefined();
});
test("custom model and model override carry prompt cache lifetimes", async () => {