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:
@@ -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" && (
|
||||
|
||||
Reference in New Issue
Block a user