feat(models): 模型映射新增最大输出 Token 列(max_output_size 直写 config.toml,models.dev 参考值一键填入/空值自动补全),移除无效的声明支持 1M 复选框;思考等级顺序修正为 low/medium/high/xhigh/max 且标签全英文 — bump to v0.8.0
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
This commit is contained in:
1 parent
30371a1ba6
commit
3c3c18925d
19 files changed
+412
-38
No files matched your search
+100
-17
@@ -1,11 +1,17 @@
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { findPresetForProvider } from "../config/providerPresets";
|
||||
import { getDefaultMaxContextSize } from "../lib/model-defaults";
|
||||
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
|
||||
import {
|
||||
parseMaxOutputInput,
|
||||
readMaxOutputSize,
|
||||
setMaxOutputSize,
|
||||
withMaxOutputSize,
|
||||
} from "../lib/model-max-output";
|
||||
import { capabilitiesFromRef, getModelRef, modelsDevReady } from "../lib/models-dev";
|
||||
import { getIconMetadata } from "../icons/extracted/metadata";
|
||||
import { AgentSettingsPanel } from "./AgentSettingsPanel";
|
||||
import { KimiOAuthDialog } from "./KimiOAuthDialog";
|
||||
@@ -567,6 +573,33 @@ function ModelMapping({
|
||||
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
||||
const [fetchThinking, setFetchThinking] = useState(true);
|
||||
|
||||
// The models.dev index loads in the background; re-render once it lands so
|
||||
// the max-output "参考" hints appear even when this panel mounted first.
|
||||
const [, forceModelsDevReady] = useReducer((x: number) => x + 1, 0);
|
||||
// One-shot backfill: models with no max_output_size yet get the models.dev
|
||||
// `output` cap filled in automatically. onModelChange only mutates the
|
||||
// in-memory config — the user still reviews and presses 保存配置.
|
||||
const backfillDone = useRef(false);
|
||||
const modelsRef = useRef(models);
|
||||
modelsRef.current = models;
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
modelsDevReady().then(() => {
|
||||
if (!alive) return;
|
||||
forceModelsDevReady();
|
||||
if (backfillDone.current || agent !== "kimi_code") return;
|
||||
backfillDone.current = true;
|
||||
for (const m of modelsRef.current) {
|
||||
if (readMaxOutputSize(m.raw_other) !== undefined) continue;
|
||||
const output = getModelRef(m.model)?.output;
|
||||
if (output !== undefined) onModelChange(withMaxOutputSize(m, output));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDiscover = async () => {
|
||||
setDiscovering(true);
|
||||
setDiscoverError(null);
|
||||
@@ -636,6 +669,12 @@ function ModelMapping({
|
||||
: fetchThinking
|
||||
? ["thinking"]
|
||||
: [],
|
||||
// Seed the max_output_size override only when models.dev knows the
|
||||
// cap — an absent key means the upstream default applies. kimi_code
|
||||
// only: Pi's equivalent is `maxTokens` (not written by this UI).
|
||||
...(agent === "kimi_code" && ref?.output
|
||||
? { raw_other: setMaxOutputSize(undefined, ref.output) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
onBulkAdd(toAdd);
|
||||
@@ -648,7 +687,10 @@ function ModelMapping({
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-content-primary">{t("modelMapping")}</h3>
|
||||
<p className="text-xs text-content-muted mt-1">{t("modelMappingDesc")}</p>
|
||||
<p className="text-xs text-content-muted mt-1">
|
||||
{t("modelMappingDesc")}
|
||||
{agent === "kimi_code" ? ` ${t("maxOutputSizeDesc")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -756,7 +798,9 @@ function ModelMapping({
|
||||
<th className="text-left px-4 py-3 font-medium">{t("displayName")}</th>
|
||||
<th className="text-left px-4 py-3 font-medium">{t("actualModel")}</th>
|
||||
<th className="text-left px-4 py-3 font-medium w-28">{t("contextSize")}</th>
|
||||
<th className="text-center px-4 py-3 font-medium w-28">{t("supports1M")}</th>
|
||||
{agent === "kimi_code" && (
|
||||
<th className="text-left px-4 py-3 font-medium w-32">{t("maxOutputSize")}</th>
|
||||
)}
|
||||
{agent === "kimi_code" && (
|
||||
<th className="text-left px-4 py-3 font-medium">{t("capabilities")}</th>
|
||||
)}
|
||||
@@ -797,19 +841,14 @@ function ModelMapping({
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={m.supports_1m || false}
|
||||
onChange={(e) =>
|
||||
onModelChange({
|
||||
...m,
|
||||
supports_1m: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="w-4 h-4 rounded border-border bg-input text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</td>
|
||||
{agent === "kimi_code" && (
|
||||
<td className="px-4 py-2">
|
||||
<MaxOutputCell
|
||||
model={m}
|
||||
onChange={(next) => onModelChange(next)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{agent === "kimi_code" && (
|
||||
<td className="px-4 py-2">
|
||||
<CapabilitiesCell
|
||||
@@ -985,3 +1024,47 @@ function CapabilitiesCell({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Max output tokens override (`[models."<alias>"] max_output_size`).
|
||||
*
|
||||
* Not a first-class Model field, so the value lives in `raw_other` (see
|
||||
* lib/model-max-output.ts). Blank input removes the key — "unset" means the
|
||||
* upstream default applies. When models.dev knows the model's cap it is shown
|
||||
* as a click-to-fill reference underneath.
|
||||
*/
|
||||
function MaxOutputCell({
|
||||
model,
|
||||
onChange,
|
||||
}: {
|
||||
model: Model;
|
||||
onChange: (next: Model) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const current = readMaxOutputSize(model.raw_other);
|
||||
const refOutput = getModelRef(model.model)?.output;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1024}
|
||||
className="w-full bg-transparent border border-border rounded px-2 py-1.5 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={current ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(withMaxOutputSize(model, parseMaxOutputInput(e.target.value)))
|
||||
}
|
||||
/>
|
||||
{refOutput !== undefined && refOutput !== current && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(withMaxOutputSize(model, refOutput))}
|
||||
className="mt-1 text-[11px] text-content-muted hover:text-blue-500"
|
||||
>
|
||||
{t("maxOutputRef", { value: refOutput })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ interface SubagentSettingsPageProps {
|
||||
|
||||
// Effort tiers the CLI accepts; the value is forwarded verbatim upstream, and
|
||||
// per-model support comes from `[models."<alias>"] support_efforts`.
|
||||
const EFFORTS = ["low", "medium", "high", "max", "xhigh"] as const;
|
||||
const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
|
||||
const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
|
||||
low: "thinkingLow",
|
||||
medium: "thinkingMedium",
|
||||
|
||||
+4
-2
@@ -111,7 +111,8 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
baseUrlHint: "Enter a {type} API-compatible endpoint without a trailing slash.",
|
||||
envPairs: "Env pairs",
|
||||
addEnv: "+ Add",
|
||||
modelMappingDesc: "Display name only affects the /model menu; 1M declares context capability for Kimi Code.",
|
||||
modelMappingDesc: "Display name only affects the /model menu.",
|
||||
maxOutputSizeDesc: "Max output tokens writes max_output_size — leave blank to use the upstream default.",
|
||||
oneClickSetup: "One-click setup",
|
||||
fetchModels: "Fetch models",
|
||||
fetchingModels: "Fetching...",
|
||||
@@ -121,7 +122,8 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
displayName: "Display name",
|
||||
actualModel: "Actual model",
|
||||
contextSize: "Context size",
|
||||
supports1M: "Supports 1M",
|
||||
maxOutputSize: "Max output tokens",
|
||||
maxOutputRef: "Ref {value}",
|
||||
default: "Default",
|
||||
operation: "Operation",
|
||||
isDefault: "Default",
|
||||
|
||||
+7
-5
@@ -108,7 +108,8 @@ export const zhTranslations = {
|
||||
baseUrlHint: "填写兼容 {type} API 的服务端点地址,不要以斜杠结尾",
|
||||
envPairs: "Env 键值对",
|
||||
addEnv: "+ 添加",
|
||||
modelMappingDesc: "显示名称只影响 /model 菜单;1M 只是给 Kimi Code 的上下文能力声明。",
|
||||
modelMappingDesc: "显示名称只影响 /model 菜单。",
|
||||
maxOutputSizeDesc: "「最大输出 Token」写入 max_output_size,留空则用上游默认值。",
|
||||
oneClickSetup: "一键设置",
|
||||
fetchModels: "获取模型列表",
|
||||
fetchingModels: "获取中...",
|
||||
@@ -118,7 +119,8 @@ export const zhTranslations = {
|
||||
displayName: "显示名称",
|
||||
actualModel: "实际请求模型",
|
||||
contextSize: "上下文长度",
|
||||
supports1M: "声明支持 1M",
|
||||
maxOutputSize: "最大输出 Token",
|
||||
maxOutputRef: "参考 {value}",
|
||||
default: "默认",
|
||||
operation: "操作",
|
||||
isDefault: "已默认",
|
||||
@@ -158,9 +160,9 @@ export const zhTranslations = {
|
||||
enableThinking: "启用思考",
|
||||
thinkingLevel: "思考等级",
|
||||
thinkingKeep: "保留思考内容",
|
||||
thinkingLow: "低",
|
||||
thinkingMedium: "中",
|
||||
thinkingHigh: "高",
|
||||
thinkingLow: "Low",
|
||||
thinkingMedium: "Medium",
|
||||
thinkingHigh: "High",
|
||||
thinkingMax: "Max",
|
||||
thinkingXHigh: "XHigh",
|
||||
thinkingEffortUnsupported: "当前默认模型不支持思考。",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseMaxOutputInput,
|
||||
readMaxOutputSize,
|
||||
setMaxOutputSize,
|
||||
withMaxOutputSize,
|
||||
} from "./model-max-output";
|
||||
|
||||
const model = (raw_other: unknown) =>
|
||||
({
|
||||
alias: "a",
|
||||
provider: "p",
|
||||
model: "m",
|
||||
max_context_size: 0,
|
||||
display_name: null,
|
||||
raw_other,
|
||||
}) as const;
|
||||
|
||||
describe("readMaxOutputSize", () => {
|
||||
it("reads the key from a model entry's preserved config fields", () => {
|
||||
expect(readMaxOutputSize({ max_output_size: 32768 })).toBe(32768);
|
||||
});
|
||||
|
||||
it("treats absent / malformed / non-positive values as unset", () => {
|
||||
expect(readMaxOutputSize(undefined)).toBeUndefined();
|
||||
expect(readMaxOutputSize(null)).toBeUndefined();
|
||||
expect(readMaxOutputSize({})).toBeUndefined();
|
||||
expect(readMaxOutputSize("32768")).toBeUndefined();
|
||||
expect(readMaxOutputSize({ max_output_size: 0 })).toBeUndefined();
|
||||
expect(readMaxOutputSize({ max_output_size: -1 })).toBeUndefined();
|
||||
expect(readMaxOutputSize({ max_output_size: NaN })).toBeUndefined();
|
||||
expect(readMaxOutputSize([1, 2])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMaxOutputInput", () => {
|
||||
it("keeps positive integers", () => {
|
||||
expect(parseMaxOutputInput("32768")).toBe(32768);
|
||||
});
|
||||
|
||||
it("maps blank / zero / garbage to unset", () => {
|
||||
expect(parseMaxOutputInput("")).toBeUndefined();
|
||||
expect(parseMaxOutputInput("0")).toBeUndefined();
|
||||
expect(parseMaxOutputInput("-5")).toBeUndefined();
|
||||
expect(parseMaxOutputInput("abc")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMaxOutputSize", () => {
|
||||
it("sets the key while preserving other preserved fields", () => {
|
||||
expect(setMaxOutputSize({ support_efforts: ["low"] }, 16384)).toEqual({
|
||||
support_efforts: ["low"],
|
||||
max_output_size: 16384,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops the key when the value is unset", () => {
|
||||
expect(setMaxOutputSize({ max_output_size: 16384, force: true }, undefined)).toEqual({
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("starts from an empty object for null / non-object raw_other", () => {
|
||||
expect(setMaxOutputSize(null, 1024)).toEqual({ max_output_size: 1024 });
|
||||
expect(setMaxOutputSize("junk", 1024)).toEqual({ max_output_size: 1024 });
|
||||
expect(setMaxOutputSize({ max_output_size: 1 }, undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMaxOutputSize", () => {
|
||||
it("returns a new model with the override applied", () => {
|
||||
const before = model({ max_output_size: 4096 });
|
||||
const after = withMaxOutputSize(before, 8192);
|
||||
expect(readMaxOutputSize(after.raw_other)).toBe(8192);
|
||||
expect(readMaxOutputSize(before.raw_other)).toBe(4096);
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it("clears the override without touching other fields", () => {
|
||||
const after = withMaxOutputSize(model({ max_output_size: 4096 }), undefined);
|
||||
expect(after.raw_other).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Model } from "../types";
|
||||
|
||||
/**
|
||||
* Helpers for the `max_output_size` model field.
|
||||
*
|
||||
* kimi-code keeps it in `[models."<alias>"] max_output_size`; it is not a
|
||||
* first-class field of Kimi Switch's Rust `Model` struct, so it round-trips
|
||||
* through `raw_other` (the pass-through bucket for unknown keys). Absent key =
|
||||
* unset = the upstream default applies, so clearing the field removes the key
|
||||
* rather than writing 0.
|
||||
*/
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Read the override; undefined when absent or malformed (treated as unset). */
|
||||
export function readMaxOutputSize(rawOther: unknown): number | undefined {
|
||||
const value = asRecord(rawOther).max_output_size;
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Parse the number input: blank / 0 / NaN mean "unset". */
|
||||
export function parseMaxOutputInput(text: string): number | undefined {
|
||||
const value = parseInt(text, 10);
|
||||
if (isNaN(value) || value <= 0) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (positive value) or drop (undefined) `max_output_size` on a raw_other
|
||||
* blob; every other preserved key is kept.
|
||||
*/
|
||||
export function setMaxOutputSize(
|
||||
rawOther: unknown,
|
||||
value: number | undefined,
|
||||
): Record<string, unknown> {
|
||||
const next = { ...asRecord(rawOther) };
|
||||
if (value === undefined) {
|
||||
delete next.max_output_size;
|
||||
} else {
|
||||
next.max_output_size = Math.floor(value);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Immutable update of a model entry's max_output_size override. */
|
||||
export function withMaxOutputSize(
|
||||
model: Model,
|
||||
value: number | undefined,
|
||||
): Model {
|
||||
return { ...model, raw_other: setMaxOutputSize(model.raw_other, value) };
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export interface ModelCost {
|
||||
export interface ModelRef {
|
||||
name?: string;
|
||||
context?: number;
|
||||
/** Upper bound on generated tokens (`limit.output` on models.dev). */
|
||||
output?: number;
|
||||
reasoning?: boolean;
|
||||
tool_call?: boolean;
|
||||
structured_output?: boolean;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Thinking-effort capability resolution.
|
||||
*
|
||||
* kimi-code's `[thinking] effort` is a free-form string (low/medium/high/
|
||||
* max/xhigh in practice) that the CLI forwards verbatim to OpenAI-compatible
|
||||
* xhigh/max in practice) that the CLI forwards verbatim to OpenAI-compatible
|
||||
* upstreams as `reasoning_effort`. Which tiers a given model accepts is
|
||||
* declared per model in config.toml (`[models."<alias>"] support_efforts`);
|
||||
* models.dev only carries a boolean `reasoning` flag with no tier list. The
|
||||
@@ -16,8 +16,8 @@ export const THINKING_EFFORTS = [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"max",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
export type ThinkingEffort = (typeof THINKING_EFFORTS)[number];
|
||||
|
||||
Reference in new issue
Block a user