feat: per-model max output tokens and a conversation-time thinking level backed by agent settings (#28)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-22 22:22:41 +08:00
committed by GitHub
parent 99f391f379
commit cabbab1c16
36 changed files with 797 additions and 42 deletions
@@ -32,6 +32,9 @@ function presetToRow(p: PresetEntry): RowState {
modelId: p.model_id,
original: null,
...presetFields(p),
// The output cap is user-owned, not catalog-owned (deliberately outside presetFields,
// so a sync never clobbers it on existing rows): fresh rows inherit the Agent setting.
maxTokens: "",
originalBaseUrl: "",
apiKeyInput: "",
clearApiKey: false,
@@ -177,6 +177,8 @@ export interface RowState {
/** Environment variable name used as fallback when api_key is empty (given by the server based on catalog/protocol). */
envKey?: string;
contextWindow: string;
/** Per-model max output tokens ("" = inherit the Agent setting): caps output per request; user-only, never preset by the catalog. */
maxTokens: string;
/** AgentHub client protocol: defaults for preset models (auto-routed), "openai" for new custom models; kept as-is, not user-editable. */
clientType: string;
cacheRead: string;
@@ -237,7 +239,10 @@ function FieldError({ text }: { text: string }) {
/** Fields in the config dialog that can be highlighted red on error (keys match RowState field names, so they can be cleared per edit action). */
type FieldErrors = Partial<
Record<"modelId" | "baseUrl" | "contextWindow" | "cacheRead" | "cacheWrite" | "output", string>
Record<
"modelId" | "baseUrl" | "contextWindow" | "maxTokens" | "cacheRead" | "cacheWrite" | "output",
string
>
>;
/**
@@ -270,6 +275,7 @@ export function toRow(m: ModelsResponse["models"][number]): RowState {
original: { provider: m.provider, modelId: m.modelId },
vision: m.vision !== false,
contextWindow: m.contextWindow !== undefined ? String(m.contextWindow) : "",
maxTokens: m.maxTokens !== undefined ? String(m.maxTokens) : "",
clientType: m.clientType ?? "",
cacheRead: m.pricing ? String(m.pricing.cacheRead) : "",
cacheWrite: m.pricing ? String(m.pricing.cacheWrite) : "",
@@ -300,6 +306,9 @@ function rowToEntry(row: RowState): ModelUpdateEntry {
if (row.clientType.trim()) entry.clientType = row.clientType.trim();
// Supported by default: submit false only when explicitly marked "unsupported" (preset vision models and checked custom models aren't persisted).
if (!row.vision) entry.vision = false;
// Output cap ("" = inherit the Agent setting): submitted only when filled; omitting clears the stored annotation.
const mt = Number(row.maxTokens.trim());
if (row.maxTokens.trim() && Number.isFinite(mt) && mt > 0) entry.maxTokens = mt;
const cr = Number(row.cacheRead.trim());
const cwr = Number(row.cacheWrite.trim());
const out = Number(row.output.trim());
@@ -1047,6 +1056,7 @@ function ModelDialog({
original: null,
vision: true,
contextWindow: "",
maxTokens: "",
clientType: vendorAdd ? "" : "openai",
cacheRead: "",
cacheWrite: "",
@@ -1161,6 +1171,14 @@ function ModelDialog({
if (contextWindow && !Number.isFinite(Number(contextWindow))) {
errs.contextWindow = S.models.contextWindowInvalid;
}
// Output cap: digits-only input can still hold "0"/pasted junk; the server requires a positive integer.
const maxTokensInput = form.maxTokens.trim();
if (
maxTokensInput &&
!(Number.isInteger(Number(maxTokensInput)) && Number(maxTokensInput) > 0)
) {
errs.maxTokens = S.models.maxTokensInvalid;
}
if (Object.keys(errs).length > 0) {
setFieldErrors(errs);
@@ -1517,7 +1535,33 @@ function ModelDialog({
{fieldErrors.contextWindow && <FieldError text={fieldErrors.contextWindow} />}
</label>
{/* 4) Pricing: three fields side by side; currency and unit (/M tok) both
{/* 4) Max output tokens: per-model cap on the request's output — when set it wins
over the Agent's system_config value; empty inherits it. Lets a small-context
local model stay under its window (the per-Agent default may not fit). */}
<label className="block">
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.models.maxTokens}
</span>
<Input
size="sm"
value={form.maxTokens}
inputMode="numeric"
disabled={!canEdit}
invalid={Boolean(fieldErrors.maxTokens)}
onChange={(e) => set({ maxTokens: digitsOnly(e.target.value) })}
className="font-mono"
placeholder={S.models.maxTokensHint}
/>
{fieldErrors.maxTokens ? (
<FieldError text={fieldErrors.maxTokens} />
) : (
<span className="mt-1 block text-xs text-gray-400 dark:text-gray-500">
{S.models.maxTokensCapHint}
</span>
)}
</label>
{/* 5) Pricing: three fields side by side; currency and unit (/M tok) both
shown inside the input, no need to repeat in the title. */}
<div>
<p className="mb-1.5 text-xs font-semibold text-gray-600 dark:text-gray-400">
@@ -1557,7 +1601,7 @@ function ModelDialog({
</div>
</div>
{/* 5) Identity: model id (renamable) + display name and group (side by side) */}
{/* 6) Identity: model id (renamable) + display name and group (side by side) */}
{!isNew && identityFields}
{/* Legacy entries carrying a non-openai client_type (historical config): read-only display. */}
{!isNew && !preset && form.clientType && form.clientType !== "openai" && (